/* 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 (
setEndOff(o => o - 1)}>
setOpen(o => !o)} title="Chọn mốc">
{last.l}
setEndOff(o => Math.min(0, o + 1))}>
{open && (
Tra cứu lịch sử · {dimLabel}
{full.slice(1).reverse().map((s, ri) => { const idx = full.length - 1 - ri; return (
{ setEndOff(idx - (full.length - 1)); setOpen(false); }}>
{s.l}
{idx === full.length - 1 && mới nhất }
{idx === endIdx && idx !== full.length - 1 && }
); })}
)}
{!atNow &&
setEndOff(0)}> Mới nhất }
So với {dimLabel} liền trước
{dimLabel === 'tuần' ? 'Tuần' : dimLabel === 'kỳ' ? 'Kỳ' : 'Tháng'} Doanh thu Chi phí Lợi nhuận Biên
{series.map((s, i) => {
const d = i > 0 ? (s.profit - series[i - 1].profit) / Math.abs(series[i - 1].profit || 1) : 0;
return (
{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 (
);
})}
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}%
Sửa định mức
Nhập giao dịch} pad={false}>
Ngày Hạng mục Khóa Số tiền Nguồn
{ledger.map(t => {
const co = t.cohort ? AAU.finance.cohortById(t.cohort) : null;
return (
{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" />
: }
);
})}
Học viên Khóa Đợt Số tiền Hạn Trạng thái
{AAU.finance.receivables.map((r, 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 (
Xuất P&L khóa { onClose(); navigate('/classes'); }}>Mở lớp học >}>
{c.courseCode} · {c.region}
{c.sessions} buổi · học phí {fM(c.price)} · GV {ins ? ins.name : '—'}
{sm.label}
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 HV chỉ 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 (
Hủy { onSave({ id: initial ? initial.id : undefined, type, category, cohort, amount: Number(String(amount).replace(/\D/g, '')) || 0, date, method, ref }); onClose(); }}>{initial ? 'Lưu thay đổi' : 'Ghi nhận'} >}>
);
}
/* ====================== 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 (
Hủy onSave(r)}>Lưu định mức >}>
);
}
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 (
{}} options={[{ value: 'q2-2026', label: 'Kỳ 2 · 2026' }, { value: 'q1-2026', label: 'Kỳ 1 · 2026' }]} style={{ width: 140 }} />
setEntry('new')}>Nhập giao dịch
Xuất Excel
>} />
navigate(FIN_TAB_PATH[v])} />
{tab === 'consolidated' && setCohort(c)} />}
{tab === 'cohorts' && setCohort(c)} />}
{tab === 'revenue' && }
{tab === 'compare' && }
{tab === 'sources' && setEntry('new')} onEditEntry={e => setEntry(e)} onDeleteEntry={deleteEntry} onEditRates={() => setRatesOpen(true)} />}
{cohort && setCohort(null)} />}
{entry !== null && setEntry(null)} onSave={saveEntry} />}
{ratesOpen && setRatesOpen(false)} onSave={r => { setRates(r); setRatesOpen(false); if (window.API && window.API.enabled) window.API.put('/finance/cost-rates', r).catch(() => {}); }} />}
);
}
window.FinancePnL = FinancePnL;