/* ============================================================ Theo dõi KPI — Plan vs Actual (đối chiếu từng mục của tháp phân rã) Đọc kế hoạch đã lưu (localStorage) + snapshot thực tế month-to-date. ============================================================ */ // thanh tiến độ: fill = thực tế / kế hoạch cả tháng; vạch = % thời gian đã trôi function PaceBar({ pct, pace, tone }) { const color = tone === 'success' ? '#0a7d4f' : tone === 'warning' ? '#b06f00' : tone === 'critical' ? '#c4320a' : '#0e6b8a'; return (
); } function TrackRow({ ic, color, label, planFull, paceTarget, actual, fmt, inverse }) { const f = fmt || (v => AAU.fmtNum(Math.round(v))); const r = paceTarget ? actual / paceTarget : 1; let tone; if (inverse) tone = r <= 1.05 ? 'success' : r <= 1.2 ? 'warning' : 'critical'; else tone = r >= 0.98 ? 'success' : r >= 0.88 ? 'warning' : 'critical'; const deltaPct = paceTarget ? Math.round((r - 1) * 100) : 0; const pct = planFull ? actual / planFull * 100 : 0; const pace = planFull ? paceTarget / planFull * 100 : 0; const good = tone === 'success'; return (
{label}
{f(paceTarget)} {f(actual)} = 0 ? 'arrowUp' : 'arrowDown')} size={11} /> {deltaPct >= 0 ? '+' : ''}{deltaPct}% ); } function PlanTrackScreen() { const P = AAU.planning; const act = P.periodActual; const months = P.planMonths; const archive = P.loadArchive(); const monthOpts = (() => { const keys = new Set([act.month, ...Object.keys(archive)]); return [...keys].sort().map(k => ({ value: k, label: (months.find(m => m.key === k) || {}).label || k })); })(); const [month, setMonth] = useState(act.month); const savedPlan = archive[month]; // Bù field thiếu cho kế hoạch cũ (thiếu defaults/cohorts) để rollup + render không vỡ. const base = P.blankPlan(month); const plan = savedPlan ? { ...base, ...savedPlan, defaults: { ...base.defaults, ...(savedPlan.defaults || {}) }, cohorts: Array.isArray(savedPlan.cohorts) ? savedPlan.cohorts : base.cohorts } : base; const t = P.rollup(plan); const a = plan.defaults; const monthLabel = (months.find(m => m.key === month) || {}).label || month; const paceFrac = act.daysElapsed / act.daysTotal; const pace = (v) => v * paceFrac; // revenue pacing + dự phóng cuối tháng theo run-rate const revPct = t.revenue ? act.revenue / t.revenue * 100 : 0; const projected = paceFrac ? act.revenue / paceFrac : 0; const projPct = t.revenue ? projected / t.revenue * 100 : 0; const onTrack = act.revenue >= pace(t.revenue) * 0.98; const paceGapPct = Math.round((act.revenue / (pace(t.revenue) || 1) - 1) * 100); // actual CPL vs plan const actCpl = act.leads ? act.adSpend / act.leads : 0; const rungs = [ { ic: 'dollar', color: '#0a7d4f', label: 'Doanh thu', planFull: t.revenue, actual: act.revenue, fmt: AAU.fmtVNDm }, { ic: 'graduation', color: '#1f5199', label: 'Khóa khai giảng', planFull: t.cohorts, actual: act.cohorts, fmt: v => Math.round(v) + ' khóa' }, { ic: 'checkCircle', color: '#0a7d4f', label: 'Học viên ghi danh (Won)', planFull: t.won, actual: act.won, fmt: v => Math.round(v) + ' HV' }, { ic: 'layers', color: '#b45309', label: 'Deal đang chạy', planFull: t.deals, actual: act.deals }, { ic: 'check', color: '#0e6b8a', label: 'Lead qualified', planFull: t.qualified, actual: act.qualified }, { ic: 'inbox', color: '#6366f1', label: 'Raw lead thu về', planFull: t.leads, actual: act.leads }, { ic: 'dollar', color: '#c4320a', label: 'Ngân sách Ads đã chi', planFull: t.budget, actual: act.adSpend, fmt: AAU.fmtVNDm, inverse: true }, ]; // ----- per-cohort actual ----- // Kế hoạch đã lưu có thể trỏ tới khóa đã bị xóa/đổi id (CLAUDE.md §5) → courseById // trả undefined. Dùng placeholder để màn vẫn render thay vì trắng. const cohortRows = (plan.cohorts || []).filter(x => x.on).map(row => { const c = AAU.courseById(row.course) || { id: row.course, name: '(khóa không còn tồn tại)', code: row.course || '—', price: 0 }; const enrolled = (act.cohortActual && act.cohortActual[row.course]) || 0; const planRev = (c.price || 0) * row.students; const actRev = (c.price || 0) * enrolled; return { c, target: row.students, enrolled, planRev, actRev }; }); // ----- team actuals ----- const teamMkt = [ { t: 'Lead thu về', plan: t.leads, actual: act.leads, fmt: v => AAU.fmtNum(Math.round(v)) }, { t: 'Ngân sách chi', plan: t.budget, actual: act.adSpend, fmt: AAU.fmtVNDm, inverse: true }, { t: 'CPL thực tế', plan: a.cpl, actual: actCpl, fmt: AAU.fmtVND, inverse: true }, { t: 'Bài content', plan: t.posts, actual: act.posts, fmt: v => AAU.fmtNum(Math.round(v)) }, ]; const teamSales = [ { t: 'Won chốt', plan: t.won, actual: act.won, fmt: v => Math.round(v) }, { t: 'Deal xử lý', plan: t.deals, actual: act.deals, fmt: v => Math.round(v) }, { t: 'Cuộc gọi / liên hệ', plan: t.calls, actual: act.calls, fmt: v => AAU.fmtNum(Math.round(v)) }, ]; const statusOf = (actual, planFull, inverse) => { const r = pace(planFull) ? actual / pace(planFull) : 1; if (inverse) return r <= 1.05 ? 'success' : r <= 1.2 ? 'warning' : 'critical'; return r >= 0.98 ? 'success' : r >= 0.88 ? 'warning' : 'critical'; }; return (