/* AAU CRM — Tài chính / P&L (module đầy đủ) Tabs: Hợp nhất · P&L theo khóa · Doanh thu · So sánh · Nguồn dữ liệu Mọi số liệu lấy từ AAU.finance (xem finance_data.js) → truy vết được nguồn. */ const FIN_GROUP_COLOR = { brand: '#7c3aed', ops: '#0a9e6e', office: '#d97706', expand: '#0084ff' }; const fM = (n) => AAU.fmtVNDm(n); const fV = (n) => AAU.fmtVND(n); const fPct = (n, d = 1) => (n * 100).toFixed(d) + '%'; /* ---- 1 dòng trong báo cáo P&L ---- */ function FinLine({ label, amount, base, bold, indent, tone, sub, accent, lock }) { return (
{label}{lock && }{sub && {sub}}
{fV(amount)} {base ? fPct(amount / base) : ''}
); } /* ---- thanh hòa vốn (break-even thermometer) ---- */ function FinBepBar({ current, bep, target, max, fmt }) { const cap = max || Math.max(current, bep, target || 0) * 1.1; const p = (v) => Math.min(100, (v / cap) * 100); const reached = current >= bep; return (
{bep != null && bep !== Infinity && (
BEP
)} {target != null && target !== current && (
)}
{fmt &&
0{fmt(cap)}
}
); } /* ---- mini bar (so sánh doanh thu vs chi phí theo kỳ) ---- */ function FinGroupedBars({ data, height = 230 }) { const w = 660, pad = { l: 46, r: 12, t: 14, b: 30 }; const max = Math.max(...data.map(d => Math.max(d.revenue, d.cost))) * 1.15 || 1; const iw = w - pad.l - pad.r, ih = height - pad.t - pad.b; const slot = iw / data.length, bw = Math.min(26, slot * 0.32); const y = v => pad.t + ih - (v / max) * ih; return ( {[0, 0.25, 0.5, 0.75, 1].map((t, i) => { const yy = pad.t + ih - t * ih; return {fM(max * t)}; })} {data.map((d, i) => { const cx = pad.l + slot * (i + 0.5); return ( Doanh thu {fM(d.revenue)} Chi phí {fM(d.cost)} {d.l} = 0 ? '#0e7c4a' : '#c4320a'}>{(d.profit >= 0 ? '+' : '') + fM(d.profit)} ); })} ); } /* ====================== TAB: HỢP NHẤT ====================== */ function FinConsolidated({ onOpenCohort }) { const C = AAU.finance.consolidated; const cr = C.costRollup; const below = AAU.finance.cohorts.filter(c => c.belowBep); const costDonut = [ { l: 'Giảng viên', v: cr.instructor, color: '#7c3aed' }, { l: 'Quảng cáo', v: cr.ads, color: '#0084ff' }, { l: 'Mặt bằng lớp', v: cr.venue, color: '#0a9e6e' }, { l: 'In ấn + ăn uống', v: cr.materials + cr.catering, color: '#d97706' }, { l: 'Chi phí chung', v: C.overheadTotal, color: '#64748b' }, ]; return (
Xuất Excel}>
− CHI PHÍ TRỰC TIẾP THEO KHÓA (COGS)
− CHI PHÍ CHUNG (OPEX · không gắn khóa)
{C.overhead.map((o, i) => )}
Dòng có khóa = tự động / định mức, sửa ở module nguồn. Chi phí chung nhập tay ở tab navigate('/finance/sources')}>Nguồn & Nhập liệu.
Doanh thu thực tế {fM(C.revenue)}
Doanh thu hòa vốn {fM(C.bepRevenue)}
Vượt hòa vốn {fM(C.revenue - C.bepRevenue)} · cứ thêm 100tr doanh thu → +{fPct(C.contributionRatio, 0)} chảy về lãi.
Doanh thu dự báo
{fM(C.forecastRevenue)}
+{fM(C.forecastRevenue - C.revenue)}
Đã ghi nhận {fPct(C.revenue / C.forecastRevenue, 0)} · phần còn lại phụ thuộc lấp đầy 3 lớp đang tuyển.
{costDonut.map((d, i) => (
{d.l} {fM(d.v)}
))}
{below.length > 0 && (
{below.length} lớp dưới điểm hòa vốn. {below.map(c => c.courseCode + ' ' + c.batch).join(', ')} — chưa đủ học viên để bù định phí. Cần đẩy tuyển sinh hoặc gộp lớp.
)}
); } /* ====================== TAB: P&L THEO KHÓA ====================== */ function FinCohorts({ onOpenCohort }) { const [grp, setGrp] = useState('all'); const [status, setStatus] = useState('all'); const [alloc, setAlloc] = useState(false); const sm = AAU.finance.statusMeta; const con = AAU.finance.consolidated; // phân bổ chi phí chung xuống khóa theo % doanh thu const allocOf = (c) => Math.round(con.overheadTotal * (c.revenue / con.revenue)); const netOf = (c) => c.profit - allocOf(c); let rows = AAU.finance.cohorts; if (grp !== 'all') rows = rows.filter(c => c.group === grp); if (status !== 'all') rows = rows.filter(c => c.status === status); const tot = { revenue: rows.reduce((s, c) => s + c.revenue, 0), direct: rows.reduce((s, c) => s + c.direct, 0), profit: rows.reduce((s, c) => s + c.profit, 0), alloc: rows.reduce((s, c) => s + allocOf(c), 0), net: rows.reduce((s, c) => s + netOf(c), 0), students: rows.reduce((s, c) => s + c.enrolled, 0), }; const cols = [ { key: 'courseName', label: 'Khóa', render: c => (
{c.courseName}
{c.courseCode} · {c.batch} · {c.region} · {c.sessions} buổi
), csv: c => c.courseName + ' ' + c.batch }, { key: 'status', label: 'Trạng thái', render: c => {sm[c.status].label} }, { key: 'enrolled', label: 'Học viên', num: true, render: c =>
{c.enrolled}/{c.target}
= 0.8 ? '#0a9e6e' : c.fill < 0.5 ? '#c4320a' : '#d97706'} />
}, { key: 'revenue', label: 'Doanh thu', num: true, render: c => {fM(c.revenue)} }, { key: 'direct', label: 'CP trực tiếp', num: true, render: c => {fM(c.direct)} }, { key: 'profit', label: 'Lãi gộp', num: true, render: c => = 0 ? 'sla-ok' : 'sla-bad')}>{(c.profit >= 0 ? '+' : '') + fM(c.profit)}
{fPct(c.margin, 0)}
}, ...(alloc ? [ { key: 'alloc', label: 'CP chung PB', num: true, render: c => −{fM(allocOf(c))} }, { key: 'net', label: 'Lãi ròng khóa', num: true, render: c => { const n = netOf(c); return = 0 ? 'sla-ok' : 'sla-bad')}>{(n >= 0 ? '+' : '') + fM(n)}
{fPct(c.revenue ? n / c.revenue : 0, 0)}
; } }, ] : [ { key: 'bepStudents', label: 'Hòa vốn', num: true, render: c => {c.bepStudents} HV{c.belowBep &&
thiếu {c.bepStudents - c.enrolled}
}
}, { key: 'roas', label: 'ROAS', num: true, render: c => {c.roas.toFixed(1)}× }, ]), ]; return (
{alloc ? : }
({ value: g.id, label: g.label }))]} />
Phân bổ chi phí chung
c.id} searchKeys={['courseName', 'courseCode', 'batch']} onRowClick={c => onOpenCohort(c)} exportName="pnl-theo-khoa" pageSize={10} />
{alloc ? <>Đang phân bổ chi phí chung ({fM(con.overheadTotal)}/kỳ) xuống từng khóa theo % doanh thu → ra lãi ròng thực của mỗi khóa. : <>Bấm vào 1 khóa để xem P&L chi tiết + hòa vốn. Bật "Phân bổ chi phí chung" để thấy lãi ròng thực của từng khóa.}
); } /* ====================== TAB: DOANH THU ====================== */ function FinRevenue() { const cohorts = [...AAU.finance.cohorts].sort((a, b) => b.revenue - a.revenue); const C = AAU.finance.consolidated; const byGroup = AAU.finance.byGroup; const bySrc = AAU.finance.bySource; const maxSrc = Math.max(...bySrc.map(s => s.revenue)); const barData = cohorts.map(c => ({ l: c.courseCode, v: c.revenue, color: FIN_GROUP_COLOR[c.group] })); const avgFill = cohorts.reduce((s, c) => s + c.fill, 0) / cohorts.length; return (
({ l: g.label, color: FIN_GROUP_COLOR[g.id] }))} />
({ l: g.label, v: g.revenue, color: FIN_GROUP_COLOR[g.id] }))} size={156} thickness={28} center={{ v: byGroup.length, l: 'nhóm' }} />
{byGroup.map(g => (
{g.label}{fM(g.revenue)}
{g.count} khóa · biên {fPct(g.margin, 0)}{fPct(g.revenue / C.revenue, 0)}
))}
{bySrc.map(s => (
{s.label}{fM(s.revenue)} · {fPct(s.revenue / C.revenue, 0)}
))}
Hơn nửa doanh thu đến từ quảng cáo trả phí → theo dõi CAC/ROAS ở tab "P&L theo khóa".
{cohorts.map(c => ( ))}
KhóaHọc viênHọc phí/HVDoanh thu gộpThực thu% tổng
{c.courseName}
{c.courseCode} · {c.batch}
{c.enrolled} {fM(c.price)} {fM(c.price * c.enrolled)} {fM(c.revenue)} {fPct(c.revenue / C.revenue, 0)}
Tổng cộng{C.studentCount}{fM(C.revenue)}100%
); } /* ====================== TAB: SO SÁNH ====================== */ function FinCompare() { const [dim, setDimRaw] = useState('months'); const [endOff, setEndOff] = useState(0); // 0 = latest period; negative = look back const [open, setOpen] = useState(false); const popRef = useRef(null); useEffect(() => { if (!open) return; const h = e => { if (popRef.current && !popRef.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, [open]); const setDim = d => { setDimRaw(d); setEndOff(0); }; const F = AAU.finance; const full = dim === 'weeks' ? F.weeks : dim === 'terms' ? F.terms : F.months; const endIdx = Math.min(full.length - 1, Math.max(1, full.length - 1 + endOff)); const series = full.slice(0, endIdx + 1); // data as of the selected period const last = series[series.length - 1], prev = series[series.length - 2]; const dRev = (last.revenue - prev.revenue) / prev.revenue; const dProf = (last.profit - prev.profit) / prev.profit; const dimLabel = { weeks: 'tuần', months: 'tháng', terms: 'kỳ' }[dim]; const cohortsRank = [...F.cohorts].sort((a, b) => b.profit - a.profit); const maxAbs = Math.max(...cohortsRank.map(c => Math.abs(c.profit))); const atNow = endIdx === full.length - 1; return (
{open && (
Tra cứu lịch sử · {dimLabel}
{full.slice(1).reverse().map((s, ri) => { const idx = full.length - 1 - ri; return ( ); })}
)}
{!atNow && } So với {dimLabel} liền trước
{series.map((s, i) => { const d = i > 0 ? (s.profit - series[i - 1].profit) / Math.abs(series[i - 1].profit || 1) : 0; return ( ); })}
{dimLabel === 'tuần' ? 'Tuần' : dimLabel === 'kỳ' ? 'Kỳ' : 'Tháng'}Doanh thuChi phíLợi nhuậnBiên
{s.l} {fM(s.revenue)} {fM(s.cost)} = 0 ? 'sla-ok' : 'sla-bad')}>{fM(s.profit)} {i > 0 && ({d >= 0 ? '+' : ''}{Math.round(d * 100)}%)} {fPct(s.profit / s.revenue, 0)}
{cohortsRank.map(c => (
{c.courseCode} {c.batch}
= 0 ? '#0a9e6e' : '#c4320a', left: c.profit >= 0 ? '50%' : (50 - Math.abs(c.profit) / maxAbs * 50) + '%', width: (Math.abs(c.profit) / maxAbs * 50) + '%' }} />
= 0 ? 'sla-ok' : 'sla-bad')} style={{ width: 64, textAlign: 'right' }}>{(c.profit >= 0 ? '+' : '') + fM(c.profit)}
))}
); } /* ====================== TAB: NGUỒN DỮ LIỆU ====================== */ const FIN_SOURCES = [ { line: 'Doanh thu', from: 'Module Ghi danh', detail: 'Số HV × học phí thực thu (sau ưu đãi)', mode: 'auto', icon: 'graduation' }, { line: 'CP Giảng viên', from: 'Module Giảng viên + Lớp', detail: 'Phí/buổi của GV × số buổi lớp', mode: 'auto', icon: 'user' }, { line: 'CP Mặt bằng lớp', from: 'Cấu hình định mức', detail: '1.000.000₫ × số buổi (tính trực tiếp vào khóa)', mode: 'config', icon: 'building' }, { line: 'CP Quảng cáo', from: 'Module Marketing Ads', detail: 'Chi tiêu campaign phân bổ theo khóa', mode: 'auto', icon: 'megaphone' }, { line: 'CP In ấn / Ăn uống', from: 'Cấu hình định mức', detail: '180k/HV + 250k/buổi', mode: 'config', icon: 'layers' }, { line: 'Chi phí chung', from: 'Nhập tay (kế toán)', detail: 'Mặt bằng VP, lương, điện nước, AI… — chỉ ở hợp nhất', mode: 'manual', icon: 'building' }, ]; const FIN_MODE_META = { auto: { label: 'Tự động', tone: 'success' }, config: { label: 'Định mức', tone: 'info' }, manual: { label: 'Nhập tay', tone: 'warning' } }; function FinSources({ ledger, rates, onAddEntry, onEditEntry, onDeleteEntry, onEditRates }) { const R = rates || AAU.finance.RATES; return (
{FIN_SOURCES.map((s, i) => { const m = FIN_MODE_META[s.mode]; return (
{s.line}
{s.from}
{s.detail}
{m.label}
); })}
Tự động = hệ thống tự tổng hợp từ module khác. Định mức = công thức cấu hình sẵn. Nhập tay = kế toán nhập định kỳ.
Mặt bằng / buổi{fV(R.venuePerSession)}
In ấn / học viên{fV(R.materialPerStudent)}
Ăn uống / buổi{fV(R.cateringPerSession)}
Thuế TNDN{R.taxRate * 100}%
Nhập giao dịch} pad={false}>
{ledger.map(t => { const co = t.cohort ? AAU.finance.cohortById(t.cohort) : null; return ( ); })}
NgàyHạng mụcKhóaSố tiềnNguồn
{AAU.fmtDate(t.date)}
{t.category}
{t.ref} · {t.method}
{co ? co.courseCode + ' ' + co.batch : '—'} {(t.type === 'in' ? '+' : '−') + fM(t.amount)} {t.src === 'auto' ? 'Tự động' : 'Nhập tay'} {t.src === 'manual' ? onEditEntry(t)} title="Sửa" /> onDeleteEntry(t.id)} title="Xóa" /> : }
{AAU.finance.receivables.map((r, i) => ( ))}
Học viênKhóaĐợtSố tiềnHạnTrạng thái
{r.who} {r.course} {r.name} {fV(r.amount)} {AAU.fmtDate(r.due)} {r.status === 'overdue' ? 'Quá hạn' : 'Đến hạn'}
); } /* ====================== DRAWER: P&L 1 KHÓA ====================== */ function FinCohortDrawer({ c, onClose }) { const sm = AAU.finance.statusMeta[c.status]; const ins = AAU.instructors.find(i => i.id === c.instr); const rows = [ { l: 'Chi phí giảng viên', v: c.costs.instructor, note: (ins ? ins.name : '') + ' · ' + fV(ins ? ins.feePerSession : 0) + '×' + c.sessions }, { l: 'Mặt bằng lớp', v: c.costs.venue, note: '1tr × ' + c.sessions + ' buổi' }, { l: 'Quảng cáo', v: c.costs.ads, note: 'phân bổ từ campaign' }, { l: 'In ấn tài liệu', v: c.costs.materials, note: '180k × ' + c.enrolled + ' HV' }, { l: 'Ăn uống, logistics', v: c.costs.catering, note: '250k × ' + c.sessions + ' buổi' }, ]; return ( }>
{c.courseCode} · {c.region}
{c.sessions} buổi · học phí {fM(c.price)} · GV {ins ? ins.name : '—'}
{sm.label}
Doanh thu
{fM(c.revenue)}
Lãi gộp
= 0 ? '#0e7c4a' : '#c4320a' }}>{(c.profit >= 0 ? '+' : '') + fM(c.profit)}
biên {fPct(c.margin)}
ROAS
{c.roas.toFixed(1)}×
CAC {fM(c.cacPerStudent)}/HV
− CHI PHÍ TRỰC TIẾP
{rows.map((r, i) => )} = 0 ? '#0e7c4a' : '#c4320a'} />
Đã tuyển{c.enrolled} HV
Cần để hòa vốn{c.bepStudents} HV
0 HVchỉ tiêu {c.target} HV
{c.belowBep ?
Còn thiếu {c.bepStudents - c.enrolled} HV mới hòa vốn. Định phí {fM(c.directFixed)} chưa được bù.
:
Đã vượt hòa vốn {c.enrolled - c.bepStudents} HV · biên an toàn {fPct(c.marginOfSafety, 0)}.
}
Công thức: định phí khóa ({fM(c.directFixed)}) ÷ lãi gộp mỗi HV ({fM(c.cmPerStudent)}) = {c.bepStudents} HV.
); } /* ====================== MODAL: NHẬP GIAO DỊCH ====================== */ function FinEntryModal({ onClose, onSave, initial }) { const [type, setType] = useState(initial ? initial.type : 'out'); const [category, setCategory] = useState(initial ? initial.category : 'Chi phí chung'); const [cohort, setCohort] = useState(initial ? (initial.cohort || '') : ''); const [amount, setAmount] = useState(initial ? String(initial.amount) : ''); const [date, setDate] = useState(initial ? initial.date : '2026-05-20'); const [method, setMethod] = useState(initial ? initial.method : 'CK'); const [ref, setRef] = useState(initial ? (initial.ref || '') : ''); const cats = type === 'in' ? ['Học phí', 'Thu khác'] : ['Giảng viên', 'Mặt bằng', 'Quảng cáo', 'In ấn', 'Điện nước', 'Hệ thống & AI', 'Lương', 'Chi phí chung']; return ( }>
{ setType(v); setCategory(v === 'in' ? 'Học phí' : 'Chi phí chung'); }} options={[{ value: 'out', label: 'Chi' }, { value: 'in', label: 'Thu' }]} />
({ value: c.id, label: c.courseCode + ' ' + c.batch }))]} />
Giao dịch nhập tay được đánh dấu Nhập tay và cộng vào P&L hợp nhất theo hạng mục.
); } /* ====================== MÀN HÌNH CHÍNH ====================== */ const FIN_TAB_PATH = { consolidated: '/finance', cohorts: '/finance/cohorts', revenue: '/finance/revenue', compare: '/finance/compare', sources: '/finance/sources' }; const FIN_PATH_TAB = { '/finance': 'consolidated', '/finance/cohorts': 'cohorts', '/finance/revenue': 'revenue', '/finance/compare': 'compare', '/finance/sources': 'sources' }; function FinRatesModal({ rates, onClose, onSave }) { const [r, setR] = useState({ ...rates }); const set = (k, v) => setR(p => ({ ...p, [k]: Number(String(v).replace(/[^\d.]/g, '')) || 0 })); return ( }>
set('venuePerSession', v)} /> set('materialPerStudent', v)} /> set('cateringPerSession', v)} /> set('taxRate', (Number(String(v).replace(/[^\d.]/g, '')) || 0) / 100)} />
Định mức là công thức chung cho mọi khóa. Đổi xong, P&L sẽ tính lại ở lần đồng bộ kế tiếp.
); } function FinancePnL() { const path = (location.hash || '#/finance').slice(1); const tab = FIN_PATH_TAB[path] || 'consolidated'; const [cohort, setCohort] = useState(null); const [entry, setEntry] = useState(null); const [ratesOpen, setRatesOpen] = useState(false); const [rates, setRates] = useState(AAU.finance.RATES); const [ledger, setLedger] = useState(AAU.finance.ledger); const C = AAU.finance.consolidated; function saveEntry(e) { setLedger(l => e.id ? l.map(x => x.id === e.id ? { ...x, ...e } : x) : [{ id: 'fm' + Date.now(), ...e, src: 'manual' }, ...l]); } function deleteEntry(id) { setLedger(l => l.filter(x => x.id !== id)); } const tabs = [ { value: 'consolidated', label: 'Hợp nhất' }, { value: 'cohorts', label: 'P&L theo khóa' }, { value: 'revenue', label: 'Doanh thu' }, { value: 'compare', label: 'So sánh' }, { value: 'sources', label: 'Nguồn dữ liệu' }, ]; const subtitle = { consolidated: C.term + ' (' + C.termRange + ') · hợp nhất từ Ghi danh + Lớp + Marketing · ' + C.cohortCount + ' khóa', cohorts: 'P&L từng lớp khai giảng · doanh thu − chi phí trực tiếp − (tùy chọn) chi phí chung phân bổ', revenue: 'Doanh thu theo khóa, nhóm khóa và nguồn acquisition · ' + C.term, compare: 'So sánh doanh thu / chi phí / lợi nhuận theo tuần · tháng · kỳ', sources: 'Mỗi dòng P&L lấy từ đâu · sổ giao dịch · công nợ phải thu · định mức', }[tab]; return (