/* ============================================================ Lập kế hoạch — bottom-up: mỗi khóa có phễu riêng, tổng cộng dồn. Mọi số đều chỉnh được · thêm/bớt khóa & hạng mục · lưu theo tháng. ============================================================ */ const PLAN_DRAFT_KEY = 'aau.draft.v2'; const uid = (p) => p + Math.random().toString(36).slice(2, 8); function loadDrafts() { try { return JSON.parse(localStorage.getItem(PLAN_DRAFT_KEY)) || {}; } catch (e) { return {}; } } // stepper nhỏ +/- cho số có biên function PlanStepper({ value, onChange, step = 1, min = 0, max = Infinity, fmt = (v) => v, w = 56 }) { const set = (v) => onChange(Math.max(min, Math.min(max, Math.round(v * 1000) / 1000))); return (
{fmt(value)}
); } // ô nhập số tự do function NumInput({ value, onChange, w = 78 }) { return onChange(e.target.value === '' ? 0 : Number(e.target.value))} style={{ width: w }} />; } // danh sách hạng mục edit được (label + value) + thêm/xóa function EditableList({ items, onChange, accent }) { const upd = (id, patch) => onChange(items.map(x => x.id === id ? { ...x, ...patch } : x)); const add = () => onChange([...items, { id: uid('it'), label: 'Hạng mục mới', value: 0 }]); const del = (id) => onChange(items.filter(x => x.id !== id)); return (
{items.map(x => (
upd(x.id, { label: e.target.value })} /> upd(x.id, { value: v })} w={64} />
))}
); } function PlanScreen() { const P = AAU.planning; const months = P.planMonths; const [month, setMonth] = useState('m6-2026'); const [archive, setArchive] = useState(() => P.loadArchive()); const [expanded, setExpanded] = useState({}); const [savedFlash, setSavedFlash] = useState(false); // Bù field thiếu cho kế hoạch cũ (schema trước đợt chuẩn hóa) để không vỡ render. function normalizePlan(raw, mk) { const base = P.blankPlan(mk); if (!raw || typeof raw !== 'object') return base; return { ...base, ...raw, month: raw.month || mk, defaults: { ...base.defaults, ...(raw.defaults || {}) }, cohorts: Array.isArray(raw.cohorts) ? raw.cohorts : base.cohorts, mkt: Array.isArray(raw.mkt) ? raw.mkt : base.mkt, sales: Array.isArray(raw.sales) ? raw.sales : base.sales, ld: Array.isArray(raw.ld) ? raw.ld : base.ld, }; } function loadPlanFor(mk) { const drafts = loadDrafts(); if (drafts[mk]) return normalizePlan(drafts[mk], mk); const arch = P.loadArchive(); if (arch[mk]) return normalizePlan({ ...JSON.parse(JSON.stringify(arch[mk])), savedAt: null }, mk); return P.blankPlan(mk); } const [plan, setPlan] = useState(() => loadPlanFor('m6-2026')); // đổi tháng -> nạp kế hoạch tương ứng const switchMonth = (mk) => { setMonth(mk); setPlan(loadPlanFor(mk)); }; // tự lưu nháp khi chỉnh useEffect(() => { const drafts = loadDrafts(); drafts[plan.month] = plan; try { localStorage.setItem(PLAN_DRAFT_KEY, JSON.stringify(drafts)); } catch (e) {} }, [plan]); // ----- mutators ----- const patchPlan = (patch) => setPlan(p => ({ ...p, ...patch })); const patchCohort = (id, patch) => setPlan(p => ({ ...p, cohorts: p.cohorts.map(c => c.id === id ? { ...c, ...patch } : c) })); const patchOver = (id, k, v) => setPlan(p => ({ ...p, cohorts: p.cohorts.map(c => c.id === id ? { ...c, over: { ...c.over, [k]: v } } : c) })); const resetOver = (id, k) => setPlan(p => ({ ...p, cohorts: p.cohorts.map(c => { if (c.id !== id) return c; const o = { ...c.over }; delete o[k]; return { ...c, over: o }; }) })); const removeCohort = (id) => setPlan(p => ({ ...p, cohorts: p.cohorts.filter(c => c.id !== id) })); const addCohort = (courseId) => setPlan(p => ({ ...p, cohorts: [...p.cohorts, { id: uid('co'), course: courseId, students: 14, on: true, over: {} }] })); const patchDefault = (k, v) => setPlan(p => ({ ...p, defaults: { ...p.defaults, [k]: v } })); const t = P.rollup(plan); const d = plan.defaults; const gap = t.revenue - plan.target; const growth = (plan.target - P.lastMonth) / P.lastMonth * 100; // tỉ lệ hiệu dụng (weighted) suy từ tổng — dùng cho công thức trực quan const pctOf = (n, d) => (d ? Math.round(n / d * 100) : 0) + '%'; const effCpl = t.paidLeads ? t.budget / t.paidLeads : 0; // các khóa chưa được thêm const usedCourses = new Set(plan.cohorts.map(c => c.course)); const addable = AAU.courses.filter(c => c.status === 'active' && c.price && !usedCourses.has(c.id)); const [addSel, setAddSel] = useState(''); // per-course funnel const rowFunnel = (c) => { // Kế hoạch đã lưu (localStorage/backend) có thể trỏ tới khóa đã bị xóa/đổi id // sau đợt chuẩn hóa dữ liệu (CLAUDE.md §5). Dùng placeholder để hàng vẫn render // được — người dùng thấy & bấm xóa, thay vì cả màn trắng. const course = AAU.courseById(c.course) || { id: c.course, name: '(khóa không còn tồn tại)', code: c.course || '—', price: 0, group: 'ops' }; const r = { ...d, ...(c.over || {}) }; return { course, r, ...P.cohortFunnel(c.students, r, course.price || 0) }; }; const saveMonth = () => { const arch = P.savePlanToMonth(plan); setArchive(arch); setSavedFlash(true); setTimeout(() => setSavedFlash(false), 2200); }; const recalcTeams = () => setPlan(p => { const np = JSON.parse(JSON.stringify(p)); P.seedTeams(np); return np; }); const monthLabel = (months.find(m => m.key === month) || {}).label || month; const savedMonths = Object.values(archive).sort((a, b) => a.month.localeCompare(b.month)); return (
patchPlan({ target: Number(e.target.value) })} className="plan-range" />
{P.presets.map(v => ( ))}
{/* ---------- Per-course editable plan table ---------- */} = 0 ? 'success' : 'critical')}>{gap >= 0 ? 'Vượt' : 'Thiếu'} {AAU.fmtVNDm(Math.abs(gap))}}>
{plan.cohorts.map(c => { const f = rowFunnel(c); const ex = expanded[c.id]; const ovr = (k) => c.over && c.over[k] != null; return ( {ex && ( )} ); })}
KhóaHọc phíHV / Won Tỉ lệ chốtCPLRaw lead Ngân sáchDoanh thu
patchCohort(c.id, { on: !c.on })} />
{f.course.name}
{f.course.code} · {AAU.courseGroupLabels[f.course.group]}
{AAU.fmtVNDm(f.course.price)}
patchCohort(c.id, { students: v })} min={0} max={80} w={28} />
patchOver(c.id, 'dealWonRate', v / 100)} min={0} max={99} w={30} fmt={v => v + '%'} />{ovr('dealWonRate') && }
patchOver(c.id, 'cpl', v)} w={84} />{ovr('cpl') && }
{Math.round(f.leads)} {AAU.fmtVNDm(f.budget)} {AAU.fmtVNDm(f.revenue)}
Phễu riêng khóa này {[ { k: 'qualDealRate', label: 'Qualified → Deal' }, { k: 'leadQualRate', label: 'Raw → Qualified' }, { k: 'paidShare', label: '% lead trả phí' }, ].map(fld => (
{fld.label} patchOver(c.id, fld.k, v / 100)} min={0} max={99} w={36} fmt={v => v + '%'} /> {ovr(fld.k) && }
))} Deal {Math.round(f.deals)} · Qualified {Math.round(f.qualified)} · Trả phí {Math.round(f.paidLeads)} lead
Tổng {t.cohorts} khóa{Math.round(t.won)} HV {Math.round(t.leads)}{AAU.fmtVNDm(t.budget)} = 0 ? '#0a7d4f' : '#c4320a' }}>{AAU.fmtVNDm(t.revenue)}
patchPlan({ mkt: plan.mkt.map(x => x.id === ch.id ? { ...x, label: e.target.value } : x) })} /> {AAU.fmtVNDm(budget)}
% NS patchPlan({ mkt: plan.mkt.map(x => x.id === ch.id ? { ...x, share: v / 100 } : x) })} min={0} max={100} w={30} fmt={v => v + '%'} /> CPL patchPlan({ mkt: plan.mkt.map(x => x.id === ch.id ? { ...x, cpl: v } : x) })} w={74} /> → {leads} lead
); })}
Tổng phân bổ: {Math.round(plan.mkt.reduce((s, x) => s + x.share, 0) * 100)}% {Math.round(plan.mkt.reduce((s, x) => s + x.share, 0) * 100) !== 100 && '⚠ nên = 100%'}
Sales} subtitle="Hoạt động cần làm"> patchPlan({ sales: v })} /> L&D / Enablement} subtitle="Deliverable cần chuẩn bị"> patchPlan({ ld: v })} /> {/* ---------- Saved archive ---------- */} {savedMonths.length === 0 ? (
Chưa có tháng nào được lưu. Bấm Lưu kế hoạch tháng ở trên để chốt bản KPI tháng {monthLabel}.
) : ( {savedMonths.map(pl => { const tr = P.rollup(pl); const ml = (months.find(m => m.key === pl.month) || {}).label || pl.month; return ( ); })}
ThángMục tiêuKế hoạchSố khóaLưu lúc
{ml}{pl.month === month && đang mở} {AAU.fmtVNDm(pl.target)} {AAU.fmtVNDm(tr.revenue)} {tr.cohorts} {pl.savedAt ? AAU.fmtDate(pl.savedAt) : '—'}
)}
); } Object.assign(window, { PlanScreen });