const {useState, useMemo, useRef, useEffect, useCallback} = React; // ===== BẪY LỖI TOÀN CỤC: khi app crash (trước đây chỉ thấy "màn hình xanh" trống), hiện HỘP ĐỎ ghi rõ lỗi + vị trí để chẩn đoán ===== (function(){ if(window.__ctgErrTrap) return; window.__ctgErrTrap=1; function show(msg){ try{ var el=document.getElementById('ctg-fatal-err'); if(!el){ el=document.createElement('div'); el.id='ctg-fatal-err'; el.style.cssText='position:fixed;left:10px;right:10px;bottom:10px;z-index:99999;background:#7f1d1d;color:#fff;border:2px solid #ef4444;border-radius:10px;padding:12px 14px;font:12px/1.5 monospace;max-height:45vh;overflow:auto;white-space:pre-wrap;'; document.body.appendChild(el); } el.textContent='💥 APP GẶP LỖI — chụp màn hình hộp này gửi hỗ trợ:\n'+msg+'\n(F5 để tải lại app)'; }catch(e){} } window.addEventListener('error', function(ev){ show((ev.message||'')+'\n'+(ev.filename||'')+':'+(ev.lineno||'')+(ev.error&&ev.error.stack?('\n'+String(ev.error.stack).split('\n').slice(0,6).join('\n')):'')); }); window.addEventListener('unhandledrejection', function(ev){ var r=ev.reason||{}; show('Promise: '+(r.message||String(r))+(r.stack?('\n'+String(r.stack).split('\n').slice(0,6).join('\n')):'')); }); })(); // ===== MIỄN NHIỄM TRÌNH DỊCH/EXTENSION SỬA DOM (Cốc Cốc/Google Translate...) ===== // Các trình dịch tự bọc chữ trong bảng bằng thẻ -> React dọn DOM cũ khi đổi tuần thì node đã bị tráo -> // "Failed to execute 'removeChild' on 'Node'" và app sập. Đây là workaround CHUẨN cho React + translate (facebook/react#11538): // removeChild/insertBefore trở nên "bao dung" khi node đã bị bên thứ ba di dời, thay vì ném lỗi làm chết app. (function(){ if(window.__ctgDomPatch) return; window.__ctgDomPatch=1; if(typeof Node==='function' && Node.prototype){ var _remove=Node.prototype.removeChild; Node.prototype.removeChild=function(child){ if(child && child.parentNode!==this){ try{ console.warn('CTG: bỏ qua removeChild lệch cha (trình dịch/extension đã sửa DOM)'); }catch(e){} return child; } return _remove.apply(this, arguments); }; var _insert=Node.prototype.insertBefore; Node.prototype.insertBefore=function(newNode, refNode){ if(refNode && refNode.parentNode!==this){ try{ console.warn('CTG: insertBefore lệch cha -> chèn cuối'); }catch(e){} return _insert.call(this, newNode, null); } return _insert.apply(this, arguments); }; } // Cấm trình dịch tự động đụng vào vùng app (giảm hẳn nguồn sửa DOM) function tagRoot(){ var r=document.getElementById('root'); if(r){ r.setAttribute('translate','no'); r.classList.add('notranslate'); } } if(document.readyState==='loading') document.addEventListener('DOMContentLoaded', tagRoot); else tagRoot(); })(); const TODAY = (function(){var d=new Date();function z(n){return (n<10?"0":"")+n;}return d.getFullYear()+"-"+z(d.getMonth()+1)+"-"+z(d.getDate());})(); /* ============== HẰNG SỐ (đúng theo app gốc) ============== */ const GENDERS=["Nam","Nữ"]; const FATMETHOD=["InBody","Kẹp mỡ 1 điểm","Kẹp mỡ 3 điểm"]; const SLEEP=["Ngủ như chết","Cũng cũng","Mất ngủ"]; const STRESS=["Tươi phơi phới","Cũng cũng","Stress vcl"]; const TANGTIEN=["Tăng tiến tốt","Chững tạ","Yếu đi"]; const GOALS=["Tăng cơ","Giảm mỡ","Giữ cân","Mini cutting","Tăng cơ + giảm mỡ"]; const SETQ=["Nỗ lực tối đa","Bình thường","Chưa hết sức"]; const PROG=["Tốt","Chững","Tệ"]; const MET_REF=[["Đá bóng","7.0–8.0"],["Bơi lội (trung bình)","5.8"],["Chạy bộ (8km/h)","8.3"],["Cầu lông / Tennis","5.5–7.0"],["Yoga / Pilates","2.5–3.0"],["Đạp xe (thong thả)","4.0"]]; const MEAL_NAMES=["Bữa sáng","Bữa trưa","Bữa tối","Bữa phụ 1","Bữa phụ 2"]; /* ── WordPress REST API helper ── */ async function apiFetch(path,opts={}){ const base=window.location.origin; const ctrl=new AbortController(); const t=(opts.timeoutMs===0)?null:setTimeout(()=>ctrl.abort(),opts.timeoutMs||15000); let r; try{ r=await fetch(base+"/wp-json/ctg/v1/"+path,{ headers:{"Content-Type":"application/json","X-WP-Nonce":window.wpApiSettings?.nonce||window.ctgNonce||""}, credentials:"include",signal:ctrl.signal,...opts }); }finally{ if(t) clearTimeout(t); } if(!r.ok){ const txt=await r.text().catch(()=>""); let errMsg="API error "+r.status; try{ const e=JSON.parse(txt); if(e.message) errMsg=e.message; }catch(err){ if(txt) errMsg+=": "+txt.slice(0,300); } throw new Error(errMsg); } return r.json(); } // ── Check-in bước chân + Mục tiêu ngày (tự tính, coach sửa tay được) ── async function getDailyCheckin(userId,date){ if(!userId||!date) return null; try{ return await apiFetch("daily-checkin?user_id="+userId+"&date="+date); } catch(e){ return null; } } async function postDailyCheckin(payload){ console.log("postDailyCheckin called with payload:", payload); try { const res = await apiFetch("daily-checkin",{method:"POST",body:JSON.stringify(payload)}); console.log("postDailyCheckin success:", res); return res; } catch(e) { console.error("postDailyCheckin failed:", e); throw e; } } // Lịch sử toàn bộ bước chân đã check-in của 1 user (không kèm date) — dùng để dựng báo cáo thật theo khoảng ngày async function getDailyCheckinHistory(userId){ if(!userId) return []; try{ const d=await apiFetch("daily-checkin?user_id="+userId); let history = Array.isArray(d)?d:[]; // Merge fallback goals from localStorage if backend dropped them try { const storedGoals = JSON.parse(localStorage.getItem('ctg_daily_goals') || '{}'); const storedSteps = JSON.parse(localStorage.getItem('ctg_daily_steps') || '{}'); history = history.map(item => { const localG = storedGoals[`${userId}_${item.checkin_date}`]; const localS = storedSteps[`${userId}_${item.checkin_date}`]; return { ...item, goal: (localG && !item.goal) ? localG : item.goal, steps: (localS != null && item.steps == null) ? localS : item.steps, }; }); // Inject days that might only exist in localStorage (goals) Object.keys(storedGoals).forEach(key => { if (key.startsWith(userId + '_')) { const dt = key.split('_')[1]; if (!history.find(x => x.checkin_date === dt)) { history.push({ checkin_date: dt, goal: storedGoals[key], steps: storedSteps[`${userId}_${dt}`] ?? null }); } } }); // Inject days that might only exist in localStorage (steps only) Object.keys(storedSteps).forEach(key => { if (key.startsWith(userId + '_')) { const dt = key.split('_')[1]; if (!history.find(x => x.checkin_date === dt)) { history.push({ checkin_date: dt, steps: storedSteps[key], goal: storedGoals[`${userId}_${dt}`] || null }); } } }); } catch(e) {} return history; } catch(e){ let history = []; try { const storedGoals = JSON.parse(localStorage.getItem('ctg_daily_goals') || '{}'); const storedSteps = JSON.parse(localStorage.getItem('ctg_daily_steps') || '{}'); const allDates = new Set([ ...Object.keys(storedGoals).filter(k=>k.startsWith(userId+'_')).map(k=>k.split('_')[1]), ...Object.keys(storedSteps).filter(k=>k.startsWith(userId+'_')).map(k=>k.split('_')[1]), ]); allDates.forEach(dt => { history.push({ checkin_date: dt, goal: storedGoals[`${userId}_${dt}`]||null, steps: storedSteps[`${userId}_${dt}`]??null }); }); } catch(err) {} return history; } } // Gộp /meals (đã fetch full lịch sử) theo ngày + lịch sử bước chân + goal hiện tại // => mảng {email,date,steps,pro,carb,fat,goal,problem} thay thế hoàn toàn dữ liệu demo DAILY, dùng cho mọi báo cáo thật function buildDailyHistory(email,meals,checkinHistory,globalGoal){ const checkinByDate={}; const checkinDates = []; (checkinHistory||[]).forEach(c=>{ checkinByDate[c.checkin_date] = { steps: c.steps!=null ? +c.steps : null, goal: c.goal }; checkinDates.push(c.checkin_date); }); const byDate={}; (meals||[]).forEach(m=>{ const dt=m.date; if(!dt) return; if(!byDate[dt]) byDate[dt]={pro:0,carb:0,fat:0}; byDate[dt].pro+=(+m.pro||0); byDate[dt].carb+=(+m.carb||0); byDate[dt].fat+=(+m.fat||0); }); const allDates = [...new Set([...Object.keys(byDate), ...checkinDates])].sort(); let lastGoal = globalGoal; let lastSteps = 9000; const goalByDate = {}; const stepsByDate = {}; allDates.forEach(dt => { if (checkinByDate[dt]) { if (checkinByDate[dt].goal) lastGoal = checkinByDate[dt].goal; if (checkinByDate[dt].steps != null) lastSteps = checkinByDate[dt].steps; } goalByDate[dt] = lastGoal; stepsByDate[dt] = lastSteps; }); return Object.keys(byDate).sort().map(dt=>{ return { email, date:dt, goal: goalByDate[dt]||globalGoal, steps: stepsByDate[dt] != null ? stepsByDate[dt] : 9000, pro:byDate[dt].pro, carb:byDate[dt].carb, fat:byDate[dt].fat, problem:"" }; }); } async function getStudentGoal(userId){ if(!userId) return ""; console.log("getStudentGoal called for user:", userId); try{ const d=await apiFetch("student-goal?user_id="+userId); console.log("getStudentGoal result:", d); return d&&d.goal||""; } catch(e){ console.error("getStudentGoal failed:", e); return ""; } } async function postStudentGoal(userId,goal){ console.log("postStudentGoal called for user:", userId, "with goal:", goal); try { const res = await apiFetch("student-goal",{method:"POST",body:JSON.stringify({user_id:userId,goal})}); console.log("postStudentGoal success:", res); return res; } catch(err) { console.error("postStudentGoal failed:", err); throw err; } } // ── Số buổi tập cam kết/tuần + phân loại trạng thái tập luyện (dùng cho card riêng + Bảng quản lý) ── async function getSessionsTarget(userId){ if(!userId) return null; try{ const d=await apiFetch("sessions-target?user_id="+userId); return d&&d.sessionsTarget!=null?+d.sessionsTarget:null; } catch(e){ return null; } } async function postSessionsTarget(userId,n){ return await apiFetch("sessions-target",{method:"POST",body:JSON.stringify({user_id:userId,sessionsTarget:n})}); } async function getAllSessionsTargets(){ try{ const d=await apiFetch("sessions-target"); return (d&&typeof d==="object")?d:{}; }catch(e){ return {}; } } async function getAllMembersTier(){ try{ const d=await apiFetch("members-tier"); return (d&&typeof d==="object")?d:{}; }catch(e){ return {}; } } // Đếm số buổi tập (bản ghi workout) trong [r1,r2] của 1 email, quy đổi ra buổi/tuần trung bình async function getDashboardBatch(userIds){ if(!userIds || !userIds.length) return {}; try{ const res = await apiFetch('dashboard-batch?user_ids='+userIds.join(',')); return res && typeof res==='object' ? res : {}; }catch(e){ return {}; } } async function getUsersRegistered(ids){ if(!ids || !ids.length) return {}; try{ const res = await apiFetch('users-registered?ids='+ids.join(',')); return res && typeof res==='object' ? res : {}; }catch(e){ return {}; } } function sessionsPerWeekIn(email,r1,r2,workoutList){ const list=(workoutList||[]).filter(w=>w.email===email && D(w.date)>=D(r1) && D(w.date)<=D(r2)); const nd=dayDiff(r2,r1)+1; return {count:list.length, perWeek: list.length/nd*7, days:nd}; } // Thang 5 mức đã duyệt: ≥110% Xuất sắc · 90-110% Tốt · 70-90% Trung bình · 40-70% Cần cải thiện · <40% Bỏ tập function classifyTraining(actualPerWeek,targetPerWeek){ if(!targetPerWeek||targetPerWeek<=0) return {label:"Chưa đặt cam kết",color:"#9aa6b6",pct:null}; const pct=Math.round(actualPerWeek/targetPerWeek*100); if(pct>=110) return {label:"🏆 Xuất sắc",color:"#16a34a",pct}; if(pct>=90) return {label:"✅ Tốt",color:"#16a34a",pct}; if(pct>=70) return {label:"➖ Trung bình",color:"#d97706",pct}; if(pct>=40) return {label:"⚠️ Cần cải thiện",color:"#d97706",pct}; return {label:"🔴 Bỏ tập",color:"#dc2626",pct}; } // images: [{base64, mime}] — hỗ trợ nhiều ảnh 1 lần // Gọi qua proxy server (WordPress giữ API key, không lộ ra trình duyệt) async function analyzeFood(images,textDesc,tg,acc){ const rem={pro:Math.max(0,Math.round(tg.pro-acc.pro)),carb:Math.max(0,Math.round(tg.carb-acc.carb)),fat:Math.max(0,Math.round(tg.fat-acc.fat))}; const nImg=images.length; const prompt=`Bạn là chuyên gia dinh dưỡng thể thao. Mọi số liệu macro (đạm/carb/béo/calo) PHẢI lấy theo Bảng thành phần thực phẩm Việt Nam của Viện Dinh dưỡng Quốc gia, tính theo khẩu phần thực tế, KHÔNG được bịa số. NHIỆM VỤ ĐẶC BIỆT: 1. Phân tích ${nImg>0?nImg+" ảnh bữa ăn dưới đây":"mô tả bữa ăn dưới đây"} để ước tính khẩu phần. 2. NẾU TRONG ẢNH CÓ THỰC PHẨM ĐÓNG GÓI: Nhận diện nhãn hiệu để tra cứu macro chuẩn, hoặc ĐỌC trực tiếp Bảng Thành Phần Dinh Dưỡng (Nutrition Facts) trên bao bì nếu thấy rõ trong ảnh để tính toán chính xác tuyệt đối. ${textDesc?"3. LƯU Ý TỪ NGƯỜI DÙNG: \"\"\""+textDesc+"\"\"\". Bắt buộc tính toán khẩu phần dựa trên ghi chú này (VD: người dùng bảo ăn 1 nửa thì chia đôi macro của ảnh).":""} Trả về JSON tiếng Việt: {"foods":["tên món + khẩu phần ước tính, ví dụ: Cơm trắng 150g"],"macros":{"pro":0,"carb":0,"fat":0,"calo":0},"nhanXet":"nhận xét ngắn về bữa ăn (1-2 câu)","canhBao":["cảnh báo nếu có, để mảng rỗng nếu ổn"]} Mục tiêu cả ngày: ${Math.round(tg.calo)} kcal (${tg.goal||""}) · Pro ${Math.round(tg.pro)}g · Carb ${Math.round(tg.carb)}g · Fat ${Math.round(tg.fat)}g Đã nạp hôm nay trước bữa này: Pro ${Math.round(acc.pro)}g · Carb ${Math.round(acc.carb)}g · Fat ${Math.round(acc.fat)}g Còn dư để nạp: Pro ${rem.pro}g · Carb ${rem.carb}g · Fat ${rem.fat}g Nếu bữa này làm vượt lượng còn dư >20% ở bất kỳ macro nào, hãy cảnh báo cụ thể và đề xuất điều chỉnh bữa sau.`; return await apiFetch("analyze-food",{method:"POST",timeoutMs:0,body:JSON.stringify({prompt,images})}); } /* ============== HẰNG SỐ VAI TRÒ (không phải dữ liệu mẫu) ============== */ const ADMINS = ["coach@ctgfitness.com"]; const ADMIN = {email:"coach@ctgfitness.com", name:"Coach CTG"}; /* ============== CALC ENGINE (theo spec verbatim) ============== */ const D = s => new Date(s+"T00:00:00"); const pad = n => String(n).padStart(2,"0"); const iso = d => d.getFullYear()+"-"+pad(d.getMonth()+1)+"-"+pad(d.getDate()); const fmtD = s => { const d=D(s); return d.getDate()+"/"+(d.getMonth()+1); }; const fmtDMY = s => { const d=D(s); return pad(d.getDate())+"/"+pad(d.getMonth()+1)+"/"+d.getFullYear(); }; const addDays = (d,n) => { const x=new Date(d); x.setDate(x.getDate()+n); return x; }; const startOfWeek = d => { const x=new Date(d); const wd=(x.getDay()+6)%7; return addDays(x,-wd); }; const WDS = ["T2","T3","T4","T5","T6","T7","CN"]; const dayDiff = (a,b)=> Math.round((D(a)-D(b))/86400000); const r1n = x => Math.round(x*10)/10; // bodyfat tự tính (theo phương pháp đo) — Jackson–Pollock function calcBodyfat(method,gender,age,inbody,s1,s2,s3,one){ if(method==="InBody") return +inbody||0; if(method==="Kẹp mỡ 3 điểm"){ const S=(+s1||0)+(+s2||0)+(+s3||0); if(S<=0) return 0; if(gender==="Nam") return r1n(495/(1.10938 - 0.0008267*S + (16*S*S/1e7) - 0.0002574*age) - 450); return r1n(495/(1.0994921 - 0.0009929*S + (23*S*S/1e7) - 0.0001392*age) - 450); } if(method==="Kẹp mỡ 1 điểm"){ const x=+one||0; if(x<=0) return 0; if(gender==="Nam") return r1n(x*1.12 - x*x*0.011 + age*0.13 - 1.2); return r1n(x*1.28 - x*x*0.013 + age*0.12 + 4.5); } return 0; } // bodyList/workoutList: mảng dữ liệu thật từ server — luôn truyền vào từ nơi gọi, mặc định [] nếu chưa có (không còn dữ liệu demo fallback) function latestBody(email,date,bodyList){ const list = bodyList || []; const rows = list.filter(b=>D(b.date)<=D(date)).sort((a,b)=>D(b.date)-D(a.date)); return rows[0] || list.sort((a,b)=>D(a.date)-D(b.date))[0] || null; } function bodyOn(email,date,bodyList){ const list=bodyList||[]; return list.find(b=>b.date===date) || null; } const lbmOf = b => b ? +(b.weight*(1-b.bodyfat/100)).toFixed(1) : 0; function bmr(email,date,bodyList){ const b=latestBody(email,date,bodyList); return b ? 370 + 21.6*lbmOf(b) : 0; } function weightAt(email,date,bodyList){ const b=latestBody(email,date,bodyList); return b ? b.weight : 0; } function caloBurn(w,bodyList){ const wt=weightAt(w.email,w.date,bodyList); return (w.met*3.5*wt/200)*w.min; } function tdee(email,date,steps,workoutList,bodyList){ const wt=weightAt(email,date,bodyList); const stepCal = steps/100*(2.5*3.5*wt/200); const wlist = workoutList || []; const workoutCal = wlist.filter(w=>w.date===date).reduce((s,w)=>s+caloBurn(w,bodyList),0); return bmr(email,date,bodyList) + stepCal + workoutCal; } const caloActual = d => d.pro*4 + d.carb*4 + d.fat*9; const CALO_FACTOR = {"Tăng cơ":1.1,"Giảm mỡ":0.8,"Giữ cân":1.0,"Mini cutting":0.7,"Tăng cơ + giảm mỡ":0.9}; const PRO_FACTOR = {"Tăng cơ":2.0,"Giữ cân":2.2,"Tăng cơ + giảm mỡ":2.6,"Giảm mỡ":2.7,"Mini cutting":2.9}; const FAT_FACTOR = {"Tăng cơ":1.25,"Giữ cân":1.05,"Tăng cơ + giảm mỡ":0.9,"Giảm mỡ":0.65,"Mini cutting":0.55}; const CALO_OFFSET = {"Tăng cơ":10,"Giảm mỡ":-20,"Tăng cơ + giảm mỡ":-10}; function targets(email,date,goal,steps,workoutList,bodyList){ const t=tdee(email,date,steps,workoutList,bodyList); const lbm=lbmOf(latestBody(email,date,bodyList)); const __rawCalo=t*(CALO_FACTOR[goal]??1);const __bmr=370+21.6*lbm;const __belowBmr=__rawCalo<__bmr;const calo=__belowBmr?__bmr:__rawCalo; const pro=Math.round(lbm*(PRO_FACTOR[goal]??2.0)); const fat=Math.round(lbm*(FAT_FACTOR[goal]??1.0)); const carb=Math.max(0, Math.round((calo - pro*4 - fat*9)/4)); // chuẩn Atwater 4/4/9, không tính hiệu ứng nhiệt thực phẩm return {tdee:t, calo, pro, fat, carb, lbm, bmr:__bmr, belowBmr:__belowBmr, rawCalo:Math.round(__rawCalo)}; } const dotc = (pct,g,y)=> pct<=g ? "🟢" : pct<=y ? "🟡" : "🔴"; // tính 4 chấm + điểm /10 theo actual & target tuỳ ý (dùng cho cả bản ghi & preview form) function complianceOf(ca,tdeeV,goal,act,tg){ const caloDev=Math.abs(((ca-tdeeV)/tdeeV*100)-(CALO_OFFSET[goal]??0)); const pPro=Math.abs(act.pro-tg.pro)/Math.max(tg.pro,1)*100; const pCarb=Math.abs(act.carb-tg.carb)/Math.max(tg.carb,1)*100; const pFat=Math.abs(act.fat-tg.fat)/Math.max(tg.fat,1)*100; let s=caloDev<=5?7:caloDev<=10?5.5:caloDev<=15?4:caloDev<=20?2:0; s+=pPro<=10?2:pPro<=20?0.5:0; s+=pCarb<=15?0.5:0; s+=pFat<=15?0.5:0; return {dots:[dotc(caloDev,10,20),dotc(pPro,10,20),dotc(pCarb,15,25),dotc(pFat,15,25)], score:Math.round(s*10)/10}; } function adherence(d,tgOverride){ const tg=tgOverride||targets(d.email,d.date,d.goal,d.steps); const ca=caloActual(d); const c=complianceOf(ca,tg.tdee,d.goal,d,tg); return {dots:c.dots, score:c.score, ca, tg}; } function dailyScore(d,tgOverride){ return adherence(d,tgOverride).score; } // nhãn lịch buổi tập = "chất lượng tổng thể" function workoutLabel(w){ const setScore = w.setq==="Nỗ lực tối đa"?2 : w.setq==="Bình thường"?1 : 0; const progScore = w.prog==="Tốt"?2 : w.prog==="Chững"?1 : 0; const total=setScore+progScore; let icon; if((w.setq==="Nỗ lực tối đa"&&w.prog==="Tốt")||(w.setq==="Nỗ lực tối đa"&&w.prog==="Chững")||(w.setq==="Bình thường"&&w.prog==="Tốt")) icon="🟢 Tốt: "; else if(w.setq==="Chưa hết sức"&&w.prog==="Tệ") icon="🔴 Tệ: "; else icon="🟡 trung bình: "; return icon+total; } function workoutColor(w){ const l=workoutLabel(w); return l.startsWith("🟢")?"#16a34a":l.startsWith("🔴")?"#dc2626":"#d97706"; } // evalGoal: đánh giá tỉ lệ calo IN/OUT so với mục tiêu — dùng bởi SummaryCard (dữ liệu thật) function evalGoal(goal,r){ const p=Math.round(r*100); if(goal==="Giảm mỡ") return p>90?"❌ Ăn dư, khó giảm mỡ (cần ~80% TDEE)":p<70?"⚠️ Thâm hụt quá sâu":"✅ Chuẩn vùng 70–90% TDEE"; if(goal==="Tăng cơ") return p<105?"❌ Ăn thiếu, cơ khó lớn (cần ~110%)":p>120?"⚠️ Dư quá nhiều, dễ tích mỡ":"✅ Chuẩn vùng 105–120% TDEE"; if(goal==="Mini cutting") return p>80?"❌ Ăn quá nhiều cho mini-cut":p<60?"⚠️ Nhịn quá đà":"✅ Chuẩn 60–80% TDEE"; if(goal==="Tăng cơ + giảm mỡ") return p>95?"❌ Cần thâm hụt nhẹ (~90%)":p<85?"⚠️ Thâm hụt quá sâu":"✅ Chuẩn vùng 85–95% TDEE"; return p>105?"⚠️ Hơi dư":p<95?"⚠️ Hơi thiếu":"✅ Giữ cân tốt (95–105%)"; } /* ============== UI dùng chung ============== */ function Panel({title, right, children, style}){ return
{title}{right}
{children}
; } /* Panel thu gọn/xổ ra khi bấm vào tiêu đề — dùng cho các khối nội dung dài (báo cáo, tổng kết...) để đỡ chiếm chỗ, tối ưu cả mobile lẫn PC */ function CollapsePanel({title, right, children, style, defaultOpen=false}){ const [open,setOpen]=useState(defaultOpen); return
setOpen(o=>!o)}> {title} {right&&{e.stopPropagation();setOpen(true);}}>{right}}
{open&&
{children}
}
; } function Stat({label,value,unit,accent="#14224d"}){ return
{label}
{value} {unit}
; } function ReportBox({text}){ return
{text}
; } function Empty({msg}){ return
{msg}
; } /* ============== Khóa tính năng / liên hệ mở khóa (Khách hàng dùng thử) ============== */ const CTG_CONTACT_PHONE="0708673007"; function ContactUnlock(){ return
📞 {CTG_CONTACT_PHONE.replace(/(\d{4})(\d{3})(\d{3})/,"$1 $2 $3")} QR Zalo
Quét mã Zalo hoặc gọi điện: {CTG_CONTACT_PHONE}
; } function Modal({onClose,children}){ return
e.stopPropagation()} style={{background:"#0e1b3d",borderRadius:16,padding:22,maxWidth:380,width:"100%",boxShadow:"0 12px 32px rgba(15,23,42,.25)",position:"relative"}}> {children}
; } function AiFeatureLockedInfo(){ return <>
🤖 Check-in bữa ăn bằng AI
AI tự động phân tích ảnh chụp hoặc mô tả bữa ăn để tính calo, đạm, béo, carb — giúp check-in dinh dưỡng nhanh chóng, không cần nhập tay từng món. Đây là tính năng dành riêng cho Khách hàng chính thức.
; } function TrialExpiredLock(){ return
Gói tập của bạn đã hết hạn
Liên hệ chúng tôi để gia hạn / nâng cấp tài khoản và tiếp tục sử dụng đầy đủ tính năng theo dõi tiến độ tập luyện.
; } const MACRO_NAMES=["Calo","Protein","Carb","Fat"]; const STATUS_COLOR={"🟢":"#16a34a","🟡":"#d97706","🔴":"#dc2626"}; const STATUS_TXT={"🟢":"tốt","🟡":"bình thường","🔴":"lệch"}; function MacroIcon({i,color,size}){ const z=size||16; const s={width:z,height:z,viewBox:"0 0 24 24",fill:"none",stroke:color,strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round",style:{verticalAlign:"-3px"}}; if(i===0) return ; if(i===1) return ; if(i===2) return ; return ; } function DotRow({dots,size}){ return {dots.map((d,i)=>)} ; } function MacroLegend(){ return {MACRO_NAMES.map((n,i)=>{n})} ; } /* ===== Thành phần biểu đồ báo cáo ===== */ function Bar({pct,color,h}){ return
; } function EnergyCols({bmr,tdee,target,intake}){const H=225;const mx=Math.max(tdee||0,intake||0,target||0,bmr||0,1)*1.12;const p=v=>Math.max(0,Math.min(100,((v||0)/mx)*100));const Col=(title,barVal,barCol,markVal,markLab,markCol,tCol)=>
{title}
{Math.round(barVal||0)}
{markLab+" "+Math.round(markVal||0)}
;return
{Col("🔥 TDEE (đốt)",tdee,"linear-gradient(180deg,#f59e0b,#ea580c)",bmr,"BMR","#c4b5fd","#ea580c")}{Col("🍽️ Calo nạp",intake,"linear-gradient(180deg,#6366f1,#14224d)",target,"Mục tiêu","#86efac","#14224d")}
Vạch tím = BMR (trên cột TDEE) · Vạch xanh = mục tiêu (trên cột nạp) — 2 cột cùng thang đo
;} function Mini({label,val,unit,color}){ return
{label}
{val} {unit||"kcal"}
; } function Badge({text,color}){ const c=color||"#6b7280"; return {text}; } function ScoreRing({score,max,size}){ const m=max||10, z=size||66, r=z/2-7, c=2*Math.PI*r, frac=Math.max(0,Math.min(1,score/m)); const col=score>=7?"#16a34a":score>=5?"#d97706":"#dc2626"; return
{score}
/{m}
; } function macroColor(i,actual,target){ const dev=target?Math.abs(actual-target)/target*100:100; const thr=(i===2||i===3)?[15,25]:[10,20]; return dev<=thr[0]?"#16a34a":dev<=thr[1]?"#d97706":"#dc2626"; } function MacroRow({i,label,actual,target,unit}){ const color=macroColor(i,actual,target); const pct=target?actual/target*100:0; const max=Math.max(target*1.3,actual*1.05,1); const fillW=actual/max*100, markW=target/max*100; return
{label} {Math.round(actual)}/{Math.round(target)}{unit} · {Math.round(pct)}%
; } function CompareRow({label,val,max,color}){ return
{label}
{Math.round(val)}
; } // 1 thanh so sánh 2 giá trị: thanh màu = value, vạch trắng = ref (tham chiếu) function RefBar({label,value,mark,unit,color,vLabel,rLabel}){ const max=Math.max(value,mark,1)*1.18; const fillW=value/max*100, markW=mark/max*100; return
{label} {vLabel||"Thực tế"} {Math.round(value)} · {rLabel||"Lý thuyết"} {Math.round(mark)}{unit||""}
; } function MarkNote({text}){ return
{text}
; } function DailyReportCard({d,tg:tgProp,defaultOpen=false}){ const [open,setOpen]=useState(defaultOpen); const tg=tgProp||targets(d.email,d.date,d.goal,d.steps); const ca=caloActual(d), score=dailyScore(d,tgProp); const caPct=tg.calo?ca/tg.calo*100:0, caDiff=Math.round(ca-tg.calo); const caDev=tg.calo?Math.abs(ca-tg.calo)/tg.calo*100:100; const caCol=caDev<=10?"#16a34a":caDev<=20?"#d97706":"#dc2626"; const bmrVal=(tg.lbm && tg.lbm > 0) ? (370+21.6*tg.lbm) : (tg.weight ? tg.weight*22 : 1200); const caMax=Math.max(tg.tdee,tg.calo,ca,bmrVal,1)*1.05; const sug=[]; if(d.protg.pro)sug.push(["Dư Pro "+(d.pro-tg.pro)+"g","#d97706"]); if(d.carbtg.carb)sug.push(["Dư Carb "+(d.carb-tg.carb)+"g","#d97706"]); if(d.fattg.fat)sug.push(["Dư Fat "+(d.fat-tg.fat)+"g","#d97706"]); return
setOpen(true)}>
📊 Chi tiết Cân bằng năng lượng
{fmtDMY(d.date)} · Mục tiêu: {d.goal}
{open&&setOpen(false)}>
📊 Cân bằng năng lượng ({fmtDMY(d.date)})
{tg.belowBmr&&
⚠️ Mục tiêu tính ra ({tg.rawCalo} kcal) đang thấp hơn BMR ({Math.round(bmrVal)} kcal). Đã đặt tối thiểu = BMR để tránh nạp dưới mức chuyển hóa cơ bản. Nên tăng vận động / bước chân để nâng mục tiêu an toàn.
}
Calo nạp {Math.round(caPct)}% mục tiêu · {caDiff>0?"dư ":"thiếu "}{Math.abs(caDiff)} kcal
▏vạch xanh = mục tiêu nạp · ▏vạch cam = TDEE · ▏vạch tím = BMR
💡 Gợi ý điều chỉnh
{sug.length? sug.map((x,i)=>) : }
}
; } function SummaryCard({email,r1,r2,goal,dailyList=[],workoutList=[],bodyList=[]}){ /* ===== NGÂN SÁCH CALO CHU KỲ (2026-07-05) ===== Toàn bộ số liệu THẬT: bữa ăn (dailyList từ server), bước chân (checkin), buổi tập (workoutList), BMR/LBM (bodyList), hệ số theo goal (targets()). */ const nd=dayDiff(r2,r1)+1; if(nd<=0||nd>120) return ; const dates=[]; for(let k=0;k{byDate[d.date]=d;}); const stepsLogged=dates.map(dt=>byDate[dt]).filter(d=>d&&+d.steps>0).map(d=>+d.steps); const stepFb=stepsLogged.length?Math.round(stepsLogged.reduce((a,b)=>a+b,0)/stepsLogged.length):9000; const stepsOf=dt=>(byDate[dt]&&+byDate[dt].steps>0)?+byDate[dt].steps:stepFb; const dayT={}; dates.forEach(dt=>{dayT[dt]=targets(email,dt,goal,stepsOf(dt),workoutList,bodyList);}); const bmrNow=Math.round(bmr(email,r2,bodyList))||0; if(!bmrNow) return ; const elapsed=dates.filter(dt=>D(dt)D(dt)>=D(TODAY)); const logged=elapsed.filter(dt=>byDate[dt]); const missing=elapsed.length-logged.length; const cycleTarget=Math.round(dates.reduce((s,dt)=>s+dayT[dt].calo,0)); const avgDayTarget=Math.round(cycleTarget/nd); const avgTdeeAll=Math.round(dates.reduce((s,dt)=>s+dayT[dt].tdee,0)/nd); /* --- đánh giá các ngày đã qua có log --- */ let ev="", evCol="#9aa6b6", avgIn=0, avgTdeeL=0, avgP=0, avgC=0, avgF=0, tgP=0, tgC=0, tgF=0; if(logged.length){ avgIn=Math.round(logged.reduce((s,dt)=>s+caloActual(byDate[dt]),0)/logged.length); avgTdeeL=Math.round(logged.reduce((s,dt)=>s+dayT[dt].tdee,0)/logged.length); avgP=Math.round(logged.reduce((s,dt)=>s+(+byDate[dt].pro||0),0)/logged.length); avgC=Math.round(logged.reduce((s,dt)=>s+(+byDate[dt].carb||0),0)/logged.length); avgF=Math.round(logged.reduce((s,dt)=>s+(+byDate[dt].fat||0),0)/logged.length); tgP=Math.round(logged.reduce((s,dt)=>s+dayT[dt].pro,0)/logged.length); tgC=Math.round(logged.reduce((s,dt)=>s+dayT[dt].carb,0)/logged.length); tgF=Math.round(logged.reduce((s,dt)=>s+dayT[dt].fat,0)/logged.length); ev=evalGoal(goal, avgTdeeL?avgIn/avgTdeeL:0); evCol=ev.indexOf("✅")===0?"#16a34a":ev.indexOf("⚠️")===0?"#d97706":"#dc2626"; } /* --- ngân sách còn lại: ngày trống coi như ăn đúng mục tiêu (không nợ/không dư) + cảnh báo thiếu dữ liệu --- */ const balance=Math.round(logged.reduce((s,dt)=>s+(dayT[dt].calo-caloActual(byDate[dt])),0)); const remainBudget=Math.round(remain.reduce((s,dt)=>s+dayT[dt].calo,0))+balance; const y=remain.length; let dailyCal=0, proT=0, fatT=0, carbG=0, minCal=0, danger=false, ext=0, extNote="", capped=false, adviseCal=0; if(y){ dailyCal=Math.round(remainBudget/y); proT=Math.round(remain.reduce((s,dt)=>s+dayT[dt].pro,0)/y); fatT=Math.round(remain.reduce((s,dt)=>s+dayT[dt].fat,0)/y); adviseCal=dailyCal; const capCal=Math.round(avgDayTarget*1.15); if(dailyCal>capCal){adviseCal=capCal;capped=true;} /* tran an toan: khong khuyen an bu don dap */ carbG=Math.round((adviseCal-proT*4-fatT*9)/4); minCal=Math.max(bmrNow, proT*4+fatT*9+200); /* sàn: BMR hoặc đủ đạm+béo+50g carb */ danger=carbG<50||dailyCal0) ext=Math.ceil(Math.max(0,y*minCal-remainBudget)/denom); else extNote="Mục tiêu/ngày của chu kỳ đang thấp hơn mức an toàn tối thiểu — coach cần nới hệ số hoặc mục tiêu."; } } const box=(bg,bd)=>({background:bg,border:"1px solid "+bd,borderRadius:10,padding:"10px 12px",marginTop:10,fontSize:13,lineHeight:1.55}); return
Đã qua {elapsed.length} ngày (có log {logged.length}) · Còn lại {y} ngày (tính cả hôm nay) · TDEE = BMR + bước chân + buổi tập của từng ngày
{logged.length>0 &&
📊 Trung bình {logged.length} ngày đã log · {avgIn} kcal/ngày (TDEE thật {avgTdeeL})
} {logged.length===0 && elapsed.length>0 &&
⚠️ Chưa có ngày nào được check-in bữa ăn trong phần đã qua của chu kỳ — chưa thể đánh giá.
} {missing>0 && logged.length>0 &&
⚠️ Thiếu dữ liệu {missing}/{elapsed.length} ngày (không check-in) — các ngày này được coi là ăn đúng mục tiêu khi tính ngân sách, độ chính xác giảm.
} {y>0 && !danger &&
🍚 Lời khuyên {y} ngày còn lại (ngân sách {remainBudget} kcal{balance!==0?(balance>0?" · đang dư "+balance:" · đang lố "+(-balance))+" kcal so với kế hoạch":""})
Mỗi ngày ăn ~{capped?adviseCal:dailyCal} kcal: Đạm {proT}g + Fat {fatT}g (giữ nguyên hệ số theo mục tiêu) → Tinh bột còn ≈{carbG}g/ngày.{capped &&
Đang dư ngân sách nhiều (do các ngày trước ăn thiếu) — KHÔNG cần ăn bù toàn bộ; đã giới hạn ở ~115% mục tiêu ngày cho an toàn.
}
} {y>0 && danger &&
🚨 Cảnh báo: đã ăn quá lố so với ngân sách chu kỳ
Để đạt mục tiêu trong {y} ngày còn lại phải ăn ~{dailyCal} kcal/ngày{carbG<50?" (tinh bột chỉ còn ≈"+Math.max(carbG,0)+"g — dưới sàn an toàn 50g)":""}{dailyCal{ext>0?("👉 Muốn an toàn (ăn tối thiểu "+minCal+" kcal/ngày): cần KÉO DÀI chu kỳ thêm ~"+ext+" ngày để trả nợ phần dư."):extNote}
} {y===0 &&
🏁 Chu kỳ đã kết thúc — chỉ còn phần đánh giá phía trên. Chọn "Từ ngày/Đến ngày" mới để lập chu kỳ tiếp theo.
} ; } function WorkoutReportCard({w,bodyList=[]}){ const cal=Math.round(caloBurn(w,bodyList)); // CHỈ hiện CHI TIẾT buổi tập (set/rep/tạ/chấn thương) — đã bỏ hoàn toàn thanh "chất lượng set / mức độ tăng tiến" kiểu cũ. const detailBlock=(w.problem||"").indexOf("[BUỔI]")===0 ? (w.problem||"").replace(/^\[BUỔI\]\n?/,"") : ""; const detailLines=detailBlock?detailBlock.split("\n").filter(Boolean):[]; const oldNote=(w.problem&&detailLines.length===0)?w.problem:""; return
{detailLines.length>0 ?
📋 Chi tiết buổi tập ({detailLines.length} bài)
{detailLines.map(function(ln,i){ var m=ln.replace(/^•\s?/,"").split(/:(.+)/); var head=(m[0]||""); var nm=head.split(" [")[0]; var tgt=head.indexOf(" [")>=0?(head.split(" [")[1]||"").replace(/\]$/,""):""; var done=(m[1]||"").trim(); var inj=done.indexOf("🩹")>=0; return
0?"1px solid rgba(20,34,77,.12)":"none"}}>
{nm}{tgt? · kế hoạch {tgt}:null}
=0?"#9aa6b6":(inj?"#dc2626":"#16a34a"),fontWeight:600,marginTop:1}}>{done.indexOf("chưa ghi")>=0?"— chưa ghi tạ/rep":("Đã tập: "+done)}
; })}
:
{oldNote?("Ghi chú: "+oldNote):"Buổi này chưa ghi chi tiết set/rep. Vào Lịch tập nhập tạ × rep từng hiệp rồi bấm 🏁 KẾT THÚC BUỔI."}
}
; } function MetabolicCard({email,r1,r2,bodyList=[],workoutList=[],dailyList=[]}){ const bS=bodyOn(email,r1,bodyList), bE=bodyOn(email,r2,bodyList); if(!bS||!bE) return
⚠️ Lỗi chọn ngày
Ngày {!bS?fmtDMY(r1):fmtDMY(r2)} không trùng ngày có dữ liệu cân–đo mỡ. Hãy chọn lại.
; const fatS=bS.weight*bS.bodyfat/100, fatE=bE.weight*bE.bodyfat/100; const nd=Math.max(1,dayDiff(r2,r1)); const theory=(fatE-fatS)*7700/nd; const rows=(dailyList||[]).filter(d=>D(d.date)>=D(r1)&&D(d.date)<=D(r2)&&D(d.date)<=D(TODAY)); const reported=rows.length?rows.reduce((s,d)=>s+(caloActual(d)-tdee(d.email,d.date,d.steps,workoutList,bodyList)),0)/rows.length:0; const tdAvg=rows.length?rows.reduce((s,d)=>s+tdee(d.email,d.date,d.steps,workoutList,bodyList),0)/rows.length:0; const bmrNow=bmr(email,r2,bodyList); const strong=Math.abs(theory)>=Math.abs(reported); const realTdee=strong?tdAvg+(Math.abs(theory)-Math.abs(reported)):tdAvg-(Math.abs(reported)-Math.abs(theory)); const realBmr=strong?bmrNow+(Math.abs(theory)-Math.abs(reported)):bmrNow-(Math.abs(reported)-Math.abs(theory)); const gap=Math.abs(Math.round(Math.abs(theory)-Math.abs(reported))); const maxD=Math.max(Math.abs(theory),Math.abs(reported),1)*1.1; const maxE=Math.max(bmrNow,realBmr,tdAvg,realTdee,1)*1.05; return
🔬 Đối chiếu trao đổi chất
{fmtDMY(r1)} → {fmtDMY(r2)} · {nd} ngày
kcal/ngày · số âm = đốt mỡ, dương = tích mỡ
Cơ địa thật vs công thức lý thuyết
{strong?"🔥 Trao đổi chất MẠNH hơn lý thuyết":"🐢 Trao đổi chất CHẬM hơn lý thuyết"}
Chênh ~{gap} kcal/ngày so với tính toán.
; } function Toggle({options,value,onChange}){ return
{options.map(o=>)}
; } function NumField({label,value,onChange,step}){ return
onChange(e.target.value)} style={{textAlign:"center"}}/>
; } function FormBtns({onClose,onSave,saving=false,label,note,disabledSave=false}){ return
{note&&
{note}
}
; } function StudentPicker({list,picked,onPick}){ const [resetKey,setResetKey]=useState(0); const [statusMap,setStatusMap]=useState({}); const idsKey=list.map(s=>s.id).filter(Boolean).join(','); useEffect(()=>{ let alive=true; if(!idsKey){ setStatusMap({}); return; } (async()=>{ const ids=idsKey.split(',').map(Number); const r2p=TODAY, r1p=iso(addDays(D(TODAY),-6)); const [batch,sessionsTargets,tierMap]=await Promise.all([getDashboardBatch(ids),getAllSessionsTargets(),getAllMembersTier()]); if(!alive) return; const results=await Promise.all(list.map(st=>fetchStudentDashboard(st,r1p,r2p,sessionsTargets,tierMap,batch[st.id]))); const byEmail={}; results.forEach(r=>{byEmail[r.s.email]=r;}); setStatusMap(byEmail); })(); return ()=>{alive=false;}; },[idsKey]); const pickedStu=list.find(s=>s.email===picked); return {list.map(s=>{ const d=statusMap[s.email]||{}; return (
{onPick(s.email);setResetKey(k=>k+1);}}>
{s.name}
{s.email}
{d.training && 🏋️ {d.training.label}} {d.eating && 🍱 {d.eating.label}} {d.metabolic && {d.metabolic.label}}
{s.goal}
); })}
; } /* ============== LỊCH (Day / Week / Month) ============== */ function Calendar({title, events, onSelect, onDayClick, selected, renderDayAddon}){ const [mode,setMode]=useState("week"); const [anchor,setAnchor]=useState(()=>D(TODAY)); const byDate={}; events.forEach(e=>{(byDate[e.date]=byDate[e.date]||[]).push(e);}); const move=n=>{ if(mode==="month"){const x=new Date(anchor);x.setMonth(x.getMonth()+(n>0?1:-1));setAnchor(x);} else setAnchor(addDays(anchor,n*(mode==="day"?1:7))); }; const seg=["day","week","month"], segL={day:"Ngày",week:"Tuần",month:"Tháng"}; let rangeText, body; const chip=(e,i)=>; if(mode==="day"){ const k=iso(anchor); rangeText=fmtDMY(k); const evs=byDate[k]||[]; body=
{evs.length?evs.map(chip):}
; } else if(mode==="week"){ const sow=startOfWeek(anchor); const days=[...Array(7)].map((_,i)=>addDays(sow,i)); rangeText="Tuần "+fmtD(iso(days[0]))+" – "+fmtD(iso(days[6])); body=
{days.map((d,i)=>{const k=iso(d);const evs=byDate[k]||[];const isT=k===TODAY; return
{WDS[i]}
onDayClick(k):undefined} style={onDayClick?{cursor:"pointer",background:k===selected?"#d4af37":"#14224d",color:k===selected?"#0d1838":undefined,borderRadius:8,position:"relative"}:undefined}>
{d.getDate()}
{evs.map(chip)}{renderDayAddon && renderDayAddon(k, evs.length>0)}
;})}
; } else { const first=new Date(anchor.getFullYear(),anchor.getMonth(),1); const sow=startOfWeek(first); const days=[...Array(42)].map((_,i)=>addDays(sow,i)); rangeText=["Tháng 1","Tháng 2","Tháng 3","Tháng 4","Tháng 5","Tháng 6","Tháng 7","Tháng 8","Tháng 9","Tháng 10","Tháng 11","Tháng 12"][anchor.getMonth()]+" "+anchor.getFullYear(); body=
{WDS.map(w=>
{w}
)}
{days.map((d,i)=>{const k=iso(d);const evs=byDate[k]||[];const out=d.getMonth()!==anchor.getMonth();const isT=k===TODAY; return
onDayClick(k):()=>{if(evs.length){setMode("day");setAnchor(d);}}}>
{d.getDate()}
{evs.slice(0,8).map((e,j)=>)}
;})}
; } return {seg.map(m=>)}}>
{rangeText}
{body}
; } /* ============== VIEW 1: CHECK IN TẬP LUYỆN ============== */ function ViewTapLuyen({email,isAdmin,picked,onPick,students=[],serverWorkouts=null,serverBody=null,reloadData}){ const [sel,setSel]=useState(null); const bodyList = serverBody || []; const list=(serverWorkouts||[]).sort((a,b)=>D(b.date)-D(a.date)); // Chỉ hiện buổi trong 7 NGÀY gần nhất (theo ngày phân biệt) cho gọn — không đổ hết lịch sử ra const recentDates=[...new Set(list.map(w=>w.date))].slice(0,7); const recentList=list.filter(w=>recentDates.indexOf(w.date)>=0); // [ĐỔI HIỂN THỊ] Chip trên lịch: KHÔNG hiện nhãn chất lượng (Tốt/Chững/Tệ) như cơ chế cũ nữa — chỉ hiện THỜI GIAN buổi + CALO đốt const events=list.map(w=>({date:w.date,color:workoutColor(w),label:(w.min||0)+"p · "+Math.round(caloBurn(w,bodyList))+" kcal",title:w.sport,data:w})); const weekCal=Math.round(list.reduce((s,w)=>s+caloBurn(w,bodyList),0)); // targetUserId: ID thật của học viên đang xem — bắt buộc phải gửi kèm khi lưu buổi tập, tránh lưu nhầm vào tài khoản coach const st=students.find(s=>s.email===(picked||email))||{}; const [wpUserId,setWpUserId]=useState(0); useEffect(()=>{ fetch(window.location.origin+"/wp-json/wp/v2/users/me",{credentials:"include",headers:{"X-WP-Nonce":window.wpApiSettings?.nonce||""}}) .then(r=>r.ok?r.json():Promise.reject()).then(u=>setWpUserId(u.id||0)).catch(()=>setWpUserId(0)); },[]); const targetUserId = st.id || wpUserId; // ĐỐI SOÁT dấu ✅ check-in của LỊCH TẬP với buổi tập thật: buổi "Tập tạ —" nào đã bị xóa (kể cả xóa từ trước/thiết bị khác) thì gỡ dấu tương ứng useEffect(()=>{ if(!serverWorkouts) return; const valid={}; serverWorkouts.forEach(w=>{ if((w.sport||"").indexOf("Tập tạ — ")===0) valid[w.date+"_"+(+w.min||0)]=1; }); wsSweepCheckinMarks((isAdmin&&st.id)?st.id:"self", valid); try{ window.dispatchEvent(new CustomEvent("ctgWsSweepCheckin",{detail:{valid}})); }catch(e){} },[serverWorkouts]); return <> {isAdmin &&
}
{/* DANH SÁCH TÊN BUỔI TẬP — bấm vào 1 buổi để xem chi tiết set/rep/tạ/chấn thương (không còn form thêm buổi + mức nỗ lực kiểu cũ) */} {list.length===0 ? :
Hiện {recentDates.length} ngày gần nhất{list.length>recentList.length?" (còn buổi cũ hơn ẩn bớt)":""}
{recentList.map(function(w,i){ var on=sel&&(sel.id===w.id&&sel.date===w.date&&sel.sport===w.sport); var cal=Math.round(caloBurn(w,bodyList)); return
0?"1px solid rgba(212,175,55,.22)":"none"}}> {on &&
{w.id && }
}
; })}
}
Cho buổi tập KHÔNG phải tập tạ (chạy bộ, bơi, đá bóng...). Nhập tên + thời gian, bấm bảng MET để điền nhanh — tự tính calo và lưu lên lịch như các buổi khác.
{reloadData&&reloadData();}}/>
setSel(e.data)}/>
⬇ Mở LỊCH TẬP bên dưới, tập xong bấm "🏁 KẾT THÚC BUỔI" — buổi tập tự hiện lên lịch này kèm calo (MET 6 × thời gian buổi × cân nặng).
{/* LỊCH TẬP (dời từ tab Lên lịch tập sang đây): mở lịch -> tuần -> buổi -> khai báo từng set -> 🏁 KẾT THÚC BUỔI tự check-in lên lịch trên */}
; } function MetRef({onPick}){ const [o,setO]=useState(false); const firstNum=v=>parseFloat(String(v).replace(",",".").split(/[–-]/)[0])||6; return
{o &&
{MET_REF.map(([k,v])=>onPick ?
onPick(k,firstNum(v))} style={{display:"flex",justifyContent:"space-between",padding:"5px 6px",borderBottom:"1px solid #e6ebf2",cursor:"pointer",borderRadius:6}} onMouseDown={e=>e.currentTarget.style.background="#fff7e0"} onMouseUp={e=>e.currentTarget.style.background=""} onMouseLeave={e=>e.currentTarget.style.background=""}>{k}{v} METs
:
{k}{v} METs
)}
}
; } // CHECK-IN BỘ MÔN KHÁC (chạy/bơi/đá bóng...) — nhập tên + thời gian + MET (bấm bảng tra cứu để điền nhanh) -> tính calo -> LƯU vào workouts // (kèm user_id để KHÔNG lưu nhầm tài khoản coach — cùng cơ chế lưu như check-in buổi tập, tránh lỗi lưu không được). function OtherSportForm({email,targetUserId=0,useApi=false,bodyList=[],onSaved}){ const [f,setF]=useState({date:TODAY,sport:"",min:"30",met:"6"}); const set=(k,v)=>setF(o=>({...o,[k]:v})); const [saving,setSaving]=useState(false); const cal=Math.round(caloBurn({email:email,date:f.date,met:+f.met||0,min:+f.min||0},bodyList)); const save=async()=>{ if(!useApi){ alert("⏳ Chưa tải xong dữ liệu từ server — vui lòng tải lại trang (Ctrl+F5) rồi thử lại."); return; } if(!f.sport.trim()){ alert("Nhập tên bộ môn (VD: Chạy bộ, Bơi lội...)."); return; } if(!(+f.min>0)){ alert("Nhập thời gian tập (phút)."); return; } setSaving(true); try{ await apiFetch("workouts",{method:"POST",body:JSON.stringify({date:f.date,sport:f.sport.trim(),min:+f.min,met:+f.met,setq:"",prog:"",problem:"",user_id:targetUserId||undefined})}); alert("✅ Đã lưu: "+f.sport.trim()+" — "+f.min+" phút ≈ "+cal+" kcal"); setF(o=>Object.assign({},o,{sport:""})); onSaved&&onSaved(); }catch(e){ alert("❌ Lỗi lưu: "+(e&&e.message||e)+"\nDữ liệu chưa mất — thử lại sau."); } setSaving(false); }; return
set("date",e.target.value)}/>
set("sport",e.target.value)}/>
set("min",v)}/> set("met",v)} step={0.5}/>
{ set("met",String(met)); setF(o=>o.sport.trim()?o:Object.assign({},o,{sport:name,met:String(met)})); }}/>
Calo tiêu hao: {cal} kcal (MET × 3.5 × cân nặng ÷ 200 × phút)
{!useApi&&
⏳ Đang tải dữ liệu server — chưa thể lưu, đợi giây lát.
}
; } // [ĐÃ XÓA] WorkoutForm (thêm buổi tập thủ công + Chất lượng set / Mức độ tăng tiến) — thay bằng luồng check-in từ Lịch tập // (nhập tạ × rep từng hiệp, ghi chấn thương, 🏁 KẾT THÚC BUỔI tự tính calo). Báo cáo buổi tập giờ chỉ liệt kê tên buổi + chi tiết khi bấm vào. /* ============== VIEW 2: TỔNG QUAN TIẾN ĐỘ ============== */ function LineChart({labels,series}){ const ref=useRef(null),inst=useRef(null); useEffect(()=>{ if(inst.current) inst.current.destroy(); inst.current=new Chart(ref.current,{type:"line", data:{labels,datasets:series.map(s=>({label:s.name,data:s.data,borderColor:s.color,backgroundColor:s.color+"22",tension:.3,yAxisID:s.axis||"y",pointRadius:3}))}, options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{labels:{color:"#46536a"}}}, scales:{x:{ticks:{color:"#6b7280"},grid:{color:"#e7ecf3"}},y:{ticks:{color:"#6b7280"},grid:{color:"#e7ecf3"}},y1:{position:"right",ticks:{color:"#6b7280"},grid:{drawOnChartArea:false}}}}}); return ()=>{ if(inst.current) inst.current.destroy(); }; },[labels,series]); return
; } /* Nén/resize ảnh bằng canvas trước khi upload — tránh ảnh chụp từ điện thoại quá nặng (vài MB) làm chậm tải lên. */ function resizeImageFile(file,maxDim=1280,quality=0.82){ return new Promise((resolve,reject)=>{ const url=URL.createObjectURL(file); const img=new Image(); img.onload=()=>{ let{width,height}=img; if(width>maxDim||height>maxDim){ if(width>height){height=Math.round(height*maxDim/width);width=maxDim;} else{width=Math.round(width*maxDim/height);height=maxDim;} } const canvas=document.createElement("canvas"); canvas.width=width;canvas.height=height; canvas.getContext("2d").drawImage(img,0,0,width,height); canvas.toBlob(blob=>{ URL.revokeObjectURL(url); blob?resolve(blob):reject(new Error("Không nén được ảnh")); },"image/jpeg",quality); }; img.onerror=()=>{URL.revokeObjectURL(url);reject(new Error("Ảnh không hợp lệ"));}; img.src=url; }); } /* Ô ảnh có thể bấm để Chọn ảnh (thư viện) hoặc Chụp ảnh (camera) — ảnh được nén rồi tải thẳng lên WordPress Media Library, value lưu là URL thật trên server. */ function PhotoBox({label,value,onChange}){ const fileRef=useRef(null), camRef=useRef(null); const [uploading,setUploading]=useState(false); const pick=async e=>{ const file=(e&&e.target&&e.target.files&&e.target.files[0])||(e&&e.__file)||null; if(!file) return; setUploading(true); try{ let blob=file; try{ blob=await resizeImageFile(file); }catch(e){ blob=file; } const fd=new FormData(); fd.append("file",blob,(file.name||"photo").replace(/\.[^.]+$/,"")+".jpg"); const r=await fetch(window.location.origin+"/wp-json/ctg/v1/upload-photo",{ method:"POST",credentials:"include", headers:{"X-WP-Nonce":window.wpApiSettings?.nonce||window.ctgNonce||""}, body:fd }); const j=await r.json().catch(()=>({})); if(!r.ok||!j.url) throw new Error(j.message||"Tải ảnh lên thất bại"); onChange(j.url); }catch(err){ alert("Lỗi tải ảnh: "+err.message); } setUploading(false); }; return
e.preventDefault()} onDrop={e=>{e.preventDefault(); const f=e.dataTransfer.files&&e.dataTransfer.files[0]; if(f) pick({__file:f});}}> {uploading ?
⏳ Đang tải...
: value ?
{label}/
:
fileRef.current?.click()}>🖼️Chọn
camRef.current?.click()}>📷Chụp
}
; } function BodyForm({student={},targetUserId=0,useApi=false,onClose,onSaved,editing=null}){ const [f,setF]=useState(()=>editing?{ date:editing.date||TODAY, gender:student.gender||"Nam", age:String(student.age||""), weight:String(editing.weight??"70"), method:"InBody", inbody:String(editing.bodyfat??"18"), one:"20", s1:"15", s2:"20", s3:"10", tang:editing.tang||"Tăng tiến tốt", sleep:editing.sleep||"Cũng cũng", stress:editing.stress||"Cũng cũng", problem:"", img1:editing.img1||"", img2:editing.img2||"", img3:editing.img3||"" }:{date:TODAY,gender:student.gender||"Nam",age:String(student.age||""),weight:"70",method:"InBody",inbody:"18",one:"20",s1:"15",s2:"20",s3:"10",tang:"Tăng tiến tốt",sleep:"Cũng cũng",stress:"Cũng cũng",problem:"",img1:"",img2:"",img3:""}); const set=(k,v)=>setF(o=>({...o,[k]:v})); const bf=calcBodyfat(f.method,f.gender,+f.age,f.inbody,f.s1,f.s2,f.s3,f.one); const lbm=r1n((+f.weight||0)*(1-bf/100)); const [saving,setSaving]=useState(false); const handleSave=async()=>{if(useApi){setSaving(true);try{ // QUAN TRỌNG: phải gửi kèm user_id — nếu không, server sẽ lưu nhầm vào tài khoản coach đang đăng nhập thay vì học viên đang xem (bug gốc gây ra "bấm thêm nhưng không thấy lưu"). const payload={date:f.date,weight:+f.weight,bodyfat:bf,method:f.method,sleep:f.sleep,stress:f.stress,tang:f.tang,user_id:targetUserId||undefined}; let rowId=editing&&editing.id; if(rowId){ await apiFetch('body/'+rowId,{method:'PUT',body:JSON.stringify(payload)}); } else{ const created=await apiFetch('body',{method:'POST',body:JSON.stringify(payload)}); rowId=created&&(created.id||(created.data&&created.data.id)); if(!rowId){ // Backend không trả về id bản ghi mới -> tìm lại qua danh sách mới nhất (theo user_id + ngày) để biết id gắn ảnh vào. try{ const list=await apiFetch('body'+(targetUserId?'?user_id='+targetUserId:'')); const match=(Array.isArray(list)?list:[]).filter(r=>r.date===f.date).sort((a,b)=>(b.id||0)-(a.id||0))[0]; rowId=match&&match.id; }catch(e){} } } // Ảnh đã được PhotoBox tải lên Media Library ngay khi chọn -> f.img1/2/3 đã là URL thật trên server, giờ gắn URL này vào đúng bản ghi. if(rowId){ try{ await apiFetch('body-photos',{method:'POST',body:JSON.stringify({id:rowId,img1:f.img1||'',img2:f.img2||'',img3:f.img3||''})}); } catch(e){ /* không chặn luồng lưu chính nếu lưu ảnh lỗi */ } } onSaved?onSaved():onClose(); }catch(e){alert('Lỗi: '+e.message);}setSaving(false);}}; return
{editing?"✏️ Sửa nhật ký hình thể":"➕ Thêm nhật ký hình thể"}
{editing&&
ℹ️ % mỡ đang hiển thị theo giá trị đã lưu (InBody). Nếu lần đo gốc dùng kẹp mỡ, hãy chọn lại phương pháp và nhập lại số đo để tính lại chính xác.
}
set("date",e.target.value)}/>
set("age",v)}/>
set("gender",v)}/>
set("weight",v)}/>
set("method",v)}/>
{f.method==="InBody" &&
set("inbody",v)}/>
} {f.method==="Kẹp mỡ 1 điểm" &&
set("one",v)}/>
} {f.method==="Kẹp mỡ 3 điểm" &&
set("s1",v)}/> set("s2",v)}/> set("s3",v)}/>
}
💧 Bodyfat (tự tính): {bf}% · 💪 Lean body mass: {lbm} kg
📌 Ảnh chọn/chụp bên dưới sẽ được tải lên và lưu trên server khi bấm Lưu.
set("img1",v)}/> set("img2",v)}/> set("img3",v)}/>
set("tang",v)}/>
set("sleep",v)}/>
set("stress",v)}/>
set("problem",e.target.value)}/>
{!useApi&&
⏳ Đang tải dữ liệu server — chưa thể lưu, vui lòng đợi giây lát rồi thử lại.
}
; } function parsePlanText(text) { if (!text) return null; const lines = text.split('\n'); const result = {}; for (let line of lines) { if (line.includes('Chu kỳ:')) { result.chuKy = line.split('Chu kỳ:')[1].trim(); } else if (line.includes('Cân nặng:')) { result.canNang = line.split('Cân nặng:')[1].trim(); } else if (line.includes('%Mỡ:')) { const parts = line.split('|'); for (let part of parts) { if (part.includes('%Mỡ:')) result.mo = part.split('%Mỡ:')[1].trim(); if (part.includes('LBM:')) result.lbm = part.split('LBM:')[1].trim(); if (part.includes('FFMI:')) result.ffmi = part.split('FFMI:')[1].trim(); } } else if (line.includes('Thâm hụt calo') || line.includes('Dư thừa calo') || line.includes('Diet Break')) { const parts = line.split(':'); result.calType = parts[0].replace('📊', '').trim(); result.calVal = parts[1] ? parts[1].trim() : ''; } if (line.includes('Block này nhằm')) { result.targetDesc = line.replace('🎯', '').trim(); } else if (line.includes('Số bước chân')) { result.steps = line.split('Số bước chân / ngày:')[1].trim(); } else if (line.includes('Buổi tập/tuần:')) { result.trainDays = line.split('Buổi tập/tuần:')[1].trim(); } else if (line.includes('BMR:')) { const parts = line.split('|'); for (let part of parts) { if (part.includes('BMR:')) result.bmr = part.split('BMR:')[1].trim(); if (part.includes('TDEE:')) result.tdee = part.split('TDEE:')[1].trim(); if (part.includes('Calo mục tiêu:')) result.caloGoal = part.split('Calo mục tiêu:')[1].trim(); } } else if (line.includes('Protein:')) { const parts = line.split('·'); for (let part of parts) { if (part.includes('Protein:')) result.protein = part.split('Protein:')[1].trim(); if (part.includes('Carb:')) result.carb = part.split('Carb:')[1].trim(); if (part.includes('Fat:')) result.fat = part.split('Fat:')[1].trim(); } } } if (!result.chuKy && !result.canNang && !result.caloGoal) return null; return result; } function ViewTongQuan({email,isAdmin,picked,onPick,students=[],serverBody=null,serverWorkouts=null,reloadData}){ const body=(serverBody||[]).sort((a,b)=>D(a.date)-D(b.date)); const workoutList=serverWorkouts||[]; const st=students.find(s=>s.email===(picked||email))||{}; const last=body[body.length-1]; const measureDates=body.map(b=>b.date); const [r1,setR1]=useState(measureDates[0]||TODAY); const [r2,setR2]=useState(measureDates[measureDates.length-1]||TODAY); const [viewDetail,setViewDetail]=useState(null); const [editRecord,setEditRecord]=useState(null); useEffect(()=>{ setR1(measureDates[0]||TODAY); setR2(measureDates[measureDates.length-1]||TODAY); setViewDetail(null); setEditRecord(null); },[email]); // Dữ liệu thật cho "Tình trạng trao đổi chất": user id thật + goal thật + lịch sử ăn uống thật (gộp từ /meals + bước chân) const [wpUserId,setWpUserId]=useState(0); useEffect(()=>{ fetch(window.location.origin+"/wp-json/wp/v2/users/me",{credentials:"include",headers:{"X-WP-Nonce":window.wpApiSettings?.nonce||""}}) .then(r=>r.ok?r.json():Promise.reject()).then(u=>setWpUserId(u.id||0)).catch(()=>setWpUserId(0)); },[]); const targetUserId = st.id || wpUserId; const [realGoal,setRealGoal]=useState(""); const [dynamicProfile,setDynamicProfile]=useState(null); useEffect(()=>{ let cancelled=false; if(!targetUserId){ setRealGoal(""); setDynamicProfile(null); return; } getStudentGoal(targetUserId).then(g=>{ if(!cancelled) setRealGoal(g); }); apiFetch("student/"+targetUserId).then(p=>{ if(!cancelled) setDynamicProfile(p); }).catch(()=>{}); return ()=>{cancelled=true;}; },[targetUserId]); const stGoal = realGoal || "Giảm mỡ"; const currentPlan = dynamicProfile ? (dynamicProfile.plan || dynamicProfile.ctg_plan || "") : (st.plan || ""); const currentGiaoAn = dynamicProfile ? (dynamicProfile.giaoAn || dynamicProfile.giao_an || dynamicProfile.ctg_giao_an || "") : (st.giaoAn || ""); const currentStart = dynamicProfile ? (dynamicProfile.start || dynamicProfile.ctg_start || "") : (st.start || ""); const currentEnd = dynamicProfile ? (dynamicProfile.end || dynamicProfile.ctg_end || "") : (st.end || ""); const [serverMeals,setServerMeals]=useState([]); const [checkinHistory,setCheckinHistory]=useState([]); useEffect(()=>{ let cancelled=false; if(!targetUserId){ setServerMeals([]); setCheckinHistory([]); return; } apiFetch("meals?user_id="+targetUserId).then(d=>{ if(!cancelled) setServerMeals(Array.isArray(d)?d:[]); }).catch(()=>{}); getDailyCheckinHistory(targetUserId).then(r=>{ if(!cancelled) setCheckinHistory(r); }); return ()=>{cancelled=true;}; },[targetUserId]); const dailyHistory=useMemo(()=>buildDailyHistory(email,serverMeals,checkinHistory,stGoal),[email,serverMeals,checkinHistory,stGoal]); const [sessTarget,setSessTarget]=useState(null); useEffect(()=>{ let cancelled=false; if(!targetUserId){ setSessTarget(null); return; } getSessionsTarget(targetUserId).then(n=>{ if(!cancelled) setSessTarget(n); }); return ()=>{cancelled=true;}; },[targetUserId]); async function saveSessTarget(n){ setSessTarget(n); try{ await postSessionsTarget(targetUserId,n); }catch(e){} } const tqR1cal = iso(addDays(D(TODAY),-6)), tqR2cal = TODAY; const tqRate = sessionsPerWeekIn(email, tqR1cal, tqR2cal, workoutList); const tqTraining = classifyTraining(tqRate.perWeek, sessTarget); return <> {isAdmin &&
}
📅 Buổi tập/tuần
{tqRate.perWeek.toFixed(1)}
/ mục tiêu
{const n=parseFloat(e.target.value); if(!isNaN(n)&&n>=0) saveSessTarget(n);}} style={{width:56,fontSize:13,padding:"4px 6px",border:"1px solid #d1d5db",borderRadius:6}} /> {tqTraining.label}
setEditRecord(true)}>+ Thêm}> {!body.length&&}
{[...body].reverse().map((b,i)=>(
setViewDetail(b)} style={{padding:8,cursor:"pointer",border:"1px solid #cbd5e1",borderRadius:10,transition:"border-color 0.2s",boxShadow:"0 1px 2px rgba(15,23,42,.03)"}}>
{fmtD(b.date)}
{b.method}
{(b.img1||b.img2||b.img3) ?
{[b.img1,b.img2,b.img3].filter(Boolean).map((im,ix)=>hình thể)}
:
🖼️ Chưa có ảnh
}
⚖️ {b.weight}kg · 💧{b.bodyfat}%
💪 LBM: {lbmOf(b)}kg
))}
{viewDetail && setViewDetail(null)}>
🧬 Chi tiết hình thể
Ngày đo {fmtDMY(viewDetail.date)}
Phương pháp {viewDetail.method}
Cân nặng {viewDetail.weight} kg
Tỷ lệ mỡ {viewDetail.bodyfat}%
LBM (Cơ nạc) {lbmOf(viewDetail)} kg
😴 Giấc ngủ: {viewDetail.sleep}
😰 Stress: {viewDetail.stress}
🏋️ Tăng tiến tạ: {viewDetail.tang}
{viewDetail.problem &&
⚠️ Vấn đề khác: {viewDetail.problem}
}
🖼️ Ảnh so sánh hình thể
{viewDetail.img1 ? Mặt trước :
Không ảnh trước
} {viewDetail.img2 ? Mặt ngang :
Không ảnh ngang
} {viewDetail.img3 ? Mặt sau :
Không ảnh sau
}
} {editRecord && setEditRecord(null)}>
setEditRecord(null)} onSaved={()=>{reloadData&&reloadData();setEditRecord(null);setViewDetail(null);}}/>
}
{body.length>1 ? fmtD(b.date))} series={[{name:"Cân nặng",data:body.map(b=>b.weight),color:"#14224d",axis:"y"}, {name:"% Mỡ",data:body.map(b=>b.bodyfat),color:"#3b82f6",axis:"y1"}, {name:"LBM",data:body.map(b=>lbmOf(b)),color:"#ea580c",axis:"y"}]}/> : }
{currentPlan ? ( (function() { const parsed = parsePlanText(currentPlan); if (!parsed) return

{currentPlan}

; const pNum = parseFloat(parsed.protein) || 0; const cNum = parseFloat(parsed.carb) || 0; const fNum = parseFloat(parsed.fat) || 0; const totalCal = (pNum * 4) + (cNum * 4) + (fNum * 9); const pPct = totalCal ? ((pNum * 4) / totalCal) * 100 : 33.3; const cPct = totalCal ? ((cNum * 4) / totalCal) * 100 : 33.3; const fPct = totalCal ? ((fNum * 9) / totalCal) * 100 : 33.4; const bmrVal = parseFloat(parsed.bmr) || 0; const tdeeVal = parseFloat(parsed.tdee) || 0; const goalVal = parseFloat(parsed.caloGoal) || 0; const minVal = Math.min(bmrVal, goalVal, tdeeVal) * 0.95 || 1000; const maxVal = Math.max(bmrVal, goalVal, tdeeVal) * 1.05 || 3000; const range = maxVal - minVal; const bmrPos = range ? ((bmrVal - minVal) / range) * 100 : 15; const goalPos = range ? ((goalVal - minVal) / range) * 100 : 50; const tdeePos = range ? ((tdeeVal - minVal) / range) * 100 : 85; const clamp = (val) => Math.max(10, Math.min(90, val)); return (
{parsed.targetDesc && (
🎯 {parsed.targetDesc}
)}
Calo mục tiêu {parsed.caloGoal || '?'} kcal {parsed.calType && {parsed.calType}: {parsed.calVal}}
Chu kỳ lộ trình {parsed.chuKy || '?'}
{(parsed.bmr || parsed.tdee) && (
Đối chiếu năng lượng (kcal)
BMR: {parsed.bmr}
MT: {parsed.caloGoal}
TDEE: {parsed.tdee}
)}
Tỷ lệ calo đa lượng (Macros)
🥩 Protein: {parsed.protein} ({Math.round(pPct)}%) 🍞 Carb: {parsed.carb} ({Math.round(cPct)}%) 🥑 Fat: {parsed.fat} ({Math.round(fPct)}%)
🏋️
Lịch tập {parsed.trainDays || '?'}
👟
Vận động {parsed.steps || '0'} bước/ngày
Chỉ số cơ thể mục tiêu
⚖️ Cân nặng: {parsed.canNang || '?'} kg
💧 %Mỡ: {parsed.mo || '?'}% | 💪 LBM: {parsed.lbm || '?'} kg | 📐 FFMI: {parsed.ffmi || '?'}
); })() ) : } {currentGiaoAn&&🔗 Mở giáo án}
{currentStart&¤tEnd?<>Chu kỳ: {fmtDMY(currentStart)} → {fmtDMY(currentEnd)} · :null}Mục tiêu: {stGoal}
{measureDates.length?
: } {measureDates.length>0 && }
; } /* ============== AI MEAL COMPONENTS ============== */ function AiOnboarding({onSave}){ const [step,setStep]=useState(0); // 0=landing, 1=guide, 2=input const [k,setK]=useState(""); const [err,setErr]=useState(""); const [saving,setSaving]=useState(false); if(step===0) return (
🤖
Kết nối AI phân tích bữa ăn
Chụp ảnh bữa ăn → AI tự tính macro, nhận xét chế độ ăn và gợi ý cho bữa tiếp theo.
Sử dụng Google Gemini AI — 1 key dùng chung cho cả phòng tập, học viên không cần tự tạo key.
); if(step===1) return (
📋 Lấy API key miễn phí (3 bước)
{[ {n:"1",icon:"🌐",title:"Vào trang lấy key",desc:"Mở link bên dưới (đăng nhập Google nếu chưa đăng nhập)",action:Mở Google AI Studio ↗}, {n:"2",icon:"➕",title:'Bấm "Create API key"',desc:'Bấm nút "Create API key" màu xanh → chọn "Create API key in new project" → Copy key vừa tạo'}, {n:"3",icon:"📋",title:"Dán key vào đây",desc:"Quay lại trang này và bấm Tiếp tục để nhập key"}, ].map(s=>
{s.n}
{s.icon} {s.title}
{s.desc}
{s.action&&s.action}
)}
); return (
🔑 Dán API key vào đây
Key có dạng AIza... hoặc AQ.... — lưu 1 lần, dùng mãi mãi
{setK(e.target.value);setErr("");}} style={{marginBottom:8}} autoFocus/> {err&&
⚠️ {err}
}
); } function MacroAdvice({tg,acc,nextMeal}){ if(!tg||!acc) return null; const rem={ pro:Math.round(tg.pro-acc.pro), carb:Math.round(tg.carb-acc.carb), fat:Math.round(tg.fat-acc.fat) }; const items=[]; // Protein if(tg.pro>0){ if(acc.pro>tg.pro*1.15){ items.push({t:"🍗 Đạm vượt "+Math.round(acc.pro-tg.pro)+"g — bữa sau giảm thịt/cá/trứng",c:"#dc2626"}); } else if(rem.pro>tg.pro*0.35){ const chicken=Math.round(rem.pro/0.31); const eggs=Math.round(rem.pro/6); items.push({t:`🍗 Còn thiếu ${rem.pro}g đạm — cần thêm ~${chicken}g ức gà hoặc ${eggs} quả trứng bữa sau`,c:"#d97706"}); } else if(rem.pro>0&&rem.pro<=tg.pro*0.35){ items.push({t:`🍗 Đạm gần đủ (còn ${rem.pro}g) — duy trì tốt!`,c:"#16a34a"}); } } // Carb if(tg.carb>0){ if(acc.carb>tg.carb*1.2){ items.push({t:`🍚 Carb vượt ${Math.round(acc.carb-tg.carb)}g — bữa sau tránh cơm/bánh mì/mì`,c:"#dc2626"}); } else if(rem.carb>tg.carb*0.4){ const rice=Math.round(rem.carb/0.28); items.push({t:`🍚 Carb còn thiếu ${rem.carb}g — có thể thêm ~${rice}g cơm hoặc khoai`,c:"#6b7280"}); } else if(rem.carb>0&&rem.carb<=tg.carb*0.15){ items.push({t:`🍚 Carb gần đủ (còn ${rem.carb}g) — kiểm soát khẩu phần tinh bột`,c:"#d97706"}); } } // Fat if(tg.fat>0){ if(acc.fat>tg.fat*1.15){ items.push({t:`🥑 Fat vượt ${Math.round(acc.fat-tg.fat)}g — tránh dầu mỡ, đồ chiên bữa sau`,c:"#dc2626"}); } else if(rem.fat>tg.fat*0.35){ const avo=Math.round(rem.fat/0.15); items.push({t:`🥑 Fat còn dư ${rem.fat}g — thêm bơ (~${avo}g), hạt hoặc cá hồi`,c:"#16a34a"}); } } // Tổng thể const caloAcc=acc.pro*4+acc.carb*4+acc.fat*9; const caloTg=tg.pro*4+tg.carb*4+tg.fat*9; if(!items.length&&caloAcc>=caloTg*0.88){ items.push({t:"✅ Macro hôm nay cân đối — tiếp tục duy trì!",c:"#16a34a"}); } if(!items.length) return null; return
{nextMeal&&
💡 Gợi ý bữa tiếp theo:
} {items.map((x,i)=>
{x.t}
)}
; } /* ── Thanh trượt mục tiêu ── */ const GOAL_SLIDER_ORDER=["Mini cutting","Giảm mỡ","Tăng cơ + giảm mỡ","Giữ cân","Tăng cơ"]; const GOAL_SLIDER_META={ "Mini cutting": {emoji:"🔥",short:"Mini cut", color:"#ef4444"}, "Giảm mỡ": {emoji:"📉",short:"Giảm mỡ", color:"#f97316"}, "Tăng cơ + giảm mỡ":{emoji:"🔄",short:"Recomp", color:"#3b82f6"}, "Giữ cân": {emoji:"⚖️",short:"Giữ cân", color:"#22c55e"}, "Tăng cơ": {emoji:"💪",short:"Tăng cơ", color:"#8b5cf6"}, }; function GoalSaveCard({selGoal, stGoal, saveGoal, savingCheckin, tg}){ const current = selGoal||stGoal||"Giảm mỡ"; const curIdx = Math.max(0, GOAL_SLIDER_ORDER.indexOf(current)); const [sliderIdx, setSliderIdx] = useState(curIdx); useEffect(()=>{ setSliderIdx(Math.max(0, GOAL_SLIDER_ORDER.indexOf(current))); }, [current]); const localGoal = GOAL_SLIDER_ORDER[sliderIdx]; const goalChanged = localGoal !== current; const m = GOAL_SLIDER_META[localGoal]||{}; const baseTdee = tg ? tg.tdee : 0; const lbm = tg ? (tg.lbm||0) : 0; const pCalo = baseTdee>0 ? Math.round(baseTdee*(CALO_FACTOR[localGoal]??1)) : null; const pPro = lbm>0 ? Math.round(lbm*(PRO_FACTOR[localGoal]??2)) : null; const pFat = lbm>0 ? Math.round(lbm*(FAT_FACTOR[localGoal]??1)) : null; const pCarb = (pCalo&&pPro!=null&&pFat!=null) ? Math.max(0,Math.round((pCalo-pPro*4-pFat*9)/4)) : null; const trackGrad="linear-gradient(to right,#ef4444 0%,#f97316 25%,#3b82f6 50%,#22c55e 75%,#8b5cf6 100%)"; const bmrVal = lbm > 0 ? (370 + 21.6 * lbm) : (tg && tg.bmr ? tg.bmr : 1200); const belowBmr = pCalo != null && pCalo < bmrVal; return (
🎯 Mục tiêu hôm nay
Kéo thanh hoặc bấm biểu tượng để chọn
{m.emoji} {localGoal}
{GOAL_SLIDER_ORDER.map((g,i)=>{ const gm=GOAL_SLIDER_META[g]||{}; const active=sliderIdx===i; return (
setSliderIdx(i)} style={{flex:1,textAlign:"center",cursor:"pointer",transition:"all .18s",opacity:active?1:0.45}}>
{gm.emoji}
{gm.short}
); })}
setSliderIdx(+e.target.value)} style={{width:"100%",position:"relative",zIndex:1}}/>
{belowBmr && (
⚠️ Mục tiêu calo ({pCalo} kcal) đang thấp hơn mức chuyển hóa cơ bản BMR ({Math.round(bmrVal)} kcal). Hãy cân nhắc tăng vận động hoặc bước chân để có mức thâm hụt an toàn hơn!
)} {pCalo!=null&&(
{[["🔥","Calo",pCalo,"kcal","#f97316"],["🥩","Pro",pPro,"g","#ef4444"],["🍚","Carb",pCarb,"g","#3b82f6"],["🥑","Fat",pFat,"g","#f59e0b"]].map(([ico,lbl,val,u,c])=>(
{ico} {lbl}
{val??"—"}
{u}
))}
)}
); } function GoalIcon({goal}){ let p=""; let c="#14224d"; const g = (goal||"").toLowerCase(); if(g.includes("tăng cơ") && g.includes("giảm mỡ")) { c="#d946ef"; p=; } else if(g.includes("tăng cơ")) { c="#2563eb"; p=; } else if(g.includes("giảm mỡ")) { c="#ef4444"; p=; } else if(g.includes("cutting")) { c="#9333ea"; p=; } else if(g.includes("giữ cân")) { c="#16a34a"; p=; } else p=; return {p}; } function Dice5DotRow({dots, size, goal}){ const s = size||15; const g = 12; return
{dots.slice(0,4).map((d,i)=>
)} {goal &&
}
; } function TargetDropdownOverlay({k, selDate, hasData, props}){ const [open, setOpen] = useState(false); if(k !== selDate) return null; return
e.stopPropagation()}> {open && {e?.stopPropagation();setOpen(false);}}>
}
; } /* Ô mẹ "Mục tiêu ngày": nhập bước chân thật (thay vì hardcode) + hiển thị mục tiêu tự tính, coach/admin sửa tay được nếu cần */ function DailyTargetCard({goal,onGoalChange,stepsInput,onStepsChange,onStepsBlur,tg,overridden,canEdit,onSaveOverride,onClearOverride,saving}){ const [editing,setEditing]=useState(false); const [showSteps,setShowSteps]=useState(false); const [localGoal, setLocalGoal] = useState(goal); useEffect(()=>{ setLocalGoal(goal); }, [goal]); const [ov,setOv]=useState({calo:Math.round(tg.calo)||0,pro:tg.pro||0,carb:tg.carb||0,fat:tg.fat||0}); useEffect(()=>{ if(!editing) setOv({calo:Math.round(tg.calo)||0,pro:tg.pro||0,carb:tg.carb||0,fat:tg.fat||0}); },[tg.calo,tg.pro,tg.carb,tg.fat,editing]); return
{tg.belowBmr&&
⚠️ Mục tiêu tính ra ({tg.rawCalo} kcal) đang thấp hơn BMR ({Math.round(tg.bmr)} kcal). Đã đặt tối thiểu = BMR để tránh nạp dưới mức chuyển hóa cơ bản. Nên tăng vận động / bước chân để nâng mục tiêu an toàn.
} {/* Vùng 1: Bước chân */}
{showSteps &&
Nhập số bước chân thực tế của bạn hôm nay:
onStepsChange(e.target.value)} onBlur={()=>onStepsBlur(stepsInput)} disabled={saving} style={{flex:1, width:"100%", border:"none", background:"transparent", padding:"10px 8px", fontSize:16, fontWeight:700, color:"#f8fafc", outline:"none"}} placeholder="Ví dụ: 8000" /> bước
}
{/* Vùng 2: Mục tiêu & Dinh dưỡng */}
Mục tiêu {overridden&&(Coach)}
{GOALS.map(o=>)}
{localGoal !== goal && }
{[["🔥 Calo",Math.round(tg.calo||0),"kcal"],["🍗 Pro",tg.pro||0,"g"],["🍚 Carb",tg.carb||0,"g"],["🥑 Fat",tg.fat||0,"g"]].map(([l,v,u])=>
{l}
{v}
)}
{saving&&
⏳ Đang lưu...
} {canEdit&&!editing&&
{overridden&&}
} {canEdit&&editing&&
{Math.round((+ov.pro||0)*4+(+ov.carb||0)*4+(+ov.fat||0)*9)}
{[["Protein","pro"],["Carb","carb"],["Fat","fat"]].map(([l,k])=>
{const v=e.target.value;setOv(o=>{const n={...o,[k]:v===""?"":Math.max(0,+v||0)};n.calo=Math.round((+n.pro||0)*4+(+n.carb||0)*4+(+n.fat||0)*9);return n;});}} style={{padding:"4px 6px",fontSize:13,textAlign:"center",background:"#0d1838",color:"#f5f8ff",border:"1px solid #26356b",borderRadius:7}}/>
)}
}
; } /* Ô "Check in với AI" — độc lập, chỉ 2 mode con: ảnh / mô tả. tg (mục tiêu ngày) do ViewAnUong tính & truyền vào (gồm bước chân thật + override coach nếu có) */ function AiMealCheckIn({email,date,goal,tg,onAdd,existingMeals}){ const uploadRef=useRef(null); const cameraRef=useRef(null); const [images,setImages]=useState([]); const [textDesc,setTextDesc]=useState(""); const [loading,setLoading]=useState(false); const [err,setErr]=useState(""); const [result,setResult]=useState(null); const [macro,setMacro]=useState({pro:0,carb:0,fat:0,calo:0}); const [mealName,setMealName]=useState("Bữa "+((existingMeals||[]).length+1)); const [savedResult,setSavedResult]=useState(null); const [fixOpen,setFixOpen]=useState(false); // {macro, aiResult, accAfter} after save const acc=useMemo(()=>{ const m=existingMeals||[]; return {pro:m.reduce((s,x)=>s+(x.pro||0),0),carb:m.reduce((s,x)=>s+(x.carb||0),0),fat:m.reduce((s,x)=>s+(x.fat||0),0)}; },[existingMeals]); const accAfter={pro:acc.pro+(+macro.pro||0),carb:acc.carb+(+macro.carb||0),fat:acc.fat+(+macro.fat||0)}; const canSave=(+macro.pro||0)+(+macro.carb||0)+(+macro.fat||0)>0; const resetForm=()=>{setImages([]);setTextDesc("");setResult(null);setMacro({pro:0,carb:0,fat:0,calo:0});setErr("");}; // ─── AI helpers ─── const addFiles=files=>{ const imgs=Array.from(files).filter(f=>!f.type || f.type.startsWith("image/")); if(!imgs.length) return setErr("Chỉ chấp nhận file ảnh"); setErr(""); setResult(null); setMacro({pro:0,carb:0,fat:0,calo:0}); const readOne=file=>new Promise(resolve=>{ resizeImageFile(file).catch(()=>file).then(blob=>{ const reader=new FileReader(); reader.onload=e=>{ const d=e.target.result; resolve({url:d,base64:d.split(",")[1],mime:file.type||"image/jpeg"}); }; reader.readAsDataURL(blob); }); }); Promise.all(imgs.map(readOne)).then(newImgs=>setImages(prev=>[...prev,...newImgs])); }; const removeImg=idx=>setImages(prev=>prev.filter((_,i)=>i!==idx)); const runAI_combined=async()=>{ if(!images.length && !textDesc.trim()) return setErr("Hãy cung cấp ảnh hoặc mô tả bữa ăn"); setLoading(true); setErr(""); setResult(null); console.log("[AI LOG] Gửi yêu cầu phân tích ăn uống...", { imagesCount: images.length, textDesc }); const timer = setTimeout(() => { setErr("⚠️ Hệ thống đang phản hồi lâu hơn dự kiến... Vui lòng kiên nhẫn chờ hoặc kiểm tra Network/Console."); }, 15000); try{ const r=await analyzeFood(images.map(({base64,mime})=>({base64,mime})),textDesc,tg,acc); clearTimeout(timer); console.log("[AI LOG] Đã nhận phản hồi từ AI:", r); if(!r) throw new Error("Máy chủ không trả về dữ liệu."); const m={pro:r.macros?.pro||0,carb:r.macros?.carb||0,fat:r.macros?.fat||0,calo:Math.round((r.macros?.pro||0)*4+(r.macros?.carb||0)*4+(r.macros?.fat||0)*9)}; setMacro(m); setResult(r); const fn1=(r&&Array.isArray(r.foods)&&r.foods.length)?r.foods.map(String).join(", ").slice(0,80):""; if(fn1) setMealName(fn1); setErr(""); }catch(e){ clearTimeout(timer); console.error("[AI LOG] Lỗi phân tích ăn uống:", e); setErr(e.message); } setLoading(false); }; // ─── Save meal to server ─── const save=async()=>{ try{ await onAdd({ meal_name:mealName, foods:result?.foods||[], pro:+macro.pro||0,carb:+macro.carb||0,fat:+macro.fat||0,calo:+macro.calo||0, ai_note:result?.nhanXet||"", type:"ai_combined", }); setSavedResult({macro:{...macro},aiResult:result,accAfter}); resetForm(); setMealName("Bữa "+((existingMeals||[]).length+2)); }catch(e){setErr("Lỗi lưu: "+e.message);} }; const renderResultCard=()=>
🍽️ {(result.foods||[]).join(" · ")||"(chưa nhận ra món)"}
{[["🍗","Pro",(+macro.pro)+"g","#dc2626"],["🍚","Carb",(+macro.carb)+"g","#d97706"],["🥑","Fat",(+macro.fat)+"g","#16a34a"],["🔥","Calo",(+macro.calo)+"kcal","#14224d"]].map(([ic,l,v,c])=>
{ic}
{v}
{l}
)}
{result.nhanXet&&
💬 {result.nhanXet}
} {(result.canhBao||[]).length>0&&
{result.canhBao.map((w,i)=>
⚠️ {w}
)}
}
📊 Tổng sau bữa này vs mục tiêu:
{[["🍗 Pro",accAfter.pro,tg.pro,"#dc2626"],["🍚 Carb",accAfter.carb,tg.carb,"#d97706"],["🥑 Fat",accAfter.fat,tg.fat,"#16a34a"]].map(([l,a,t,c])=>{ const pct=t?Math.min(Math.round(a/t*100),200):0; const over=a>t*1.1; return
{l}
=85?"#16a34a":"#d97706",transition:"width .4s"}}>
=85?"#16a34a":"#d97706"}}>{Math.round(a)}g
/ {Math.round(t)}g
; })}
setFixOpen(o=>!o)} style={{fontSize:12,color:"#9aa6b6",cursor:"pointer",userSelect:"none"}}>{fixOpen?"▾ ":"▸ "}✏️ Chỉnh macro nếu AI tính sai
{fixOpen?
{[["Pro(g)","pro"],["Carb(g)","carb"],["Fat(g)","fat"]].map(([l,k])=>
{const v=e.target.value;setMacro(m=>{const n={...m,[k]:v===""?"":Math.max(0,+v||0)};n.calo=Math.round((+n.pro||0)*4+(+n.carb||0)*4+(+n.fat||0)*9);return n;});}}/>
)}
{Math.round((+macro.pro||0)*4+(+macro.carb||0)*4+(+macro.fat||0)*9)}
:null}
setMealName(e.target.value)} style={{padding:"6px 10px",fontSize:13}}/>
; return
{/* Kết quả sau khi lưu */} {savedResult&&
✅ Đã lưu bữa ăn thành công!
{[["🍗","Pro",(+savedResult.macro.pro)+"g","#dc2626"],["🍚","Carb",(+savedResult.macro.carb)+"g","#d97706"],["🥑","Fat",(+savedResult.macro.fat)+"g","#16a34a"],["🔥","Calo",(+savedResult.macro.calo)+"kcal","#14224d"]].map(([ic,l,v,c])=>
{ic}
{v}
{l}
)}
{savedResult.aiResult?.nhanXet&&
💬 {savedResult.aiResult.nhanXet}
}
💡 Lời khuyên cho bữa tiếp theo:
} {/* Form nhập — ẩn khi đang hiện savedResult */} {!savedResult&&
e.preventDefault()} onDrop={e=>{e.preventDefault(); if(e.dataTransfer.files&&e.dataTransfer.files.length) addFiles(e.dataTransfer.files);}}>
addFiles(e.target.files)}/> addFiles(e.target.files)}/> {images.length>0&&
{images.map((img,i)=>
{"img"+i}/
)}
uploadRef.current?.click()} style={{width:72,height:72,borderRadius:8,border:"2px dashed #c7d2fe",display:"flex",alignItems:"center",justifyContent:"center",cursor:"pointer",color:"#9aa6b6",fontSize:22}}>+
}