/* AAU CRM — Marketing shared widgets: TimeControl, CompareLineChart, Heatmap, AlertBanner, SummaryTable, SourceBadge. Exposes on window. */ // ---- period state hook (per-page; consistent control everywhere) ---- // now backed by the shared resolvePeriod() so the user can step back through history function useTimeRange(init = '30d') { const isoOf = d => { const x = new Date(d); return x.getFullYear() + '-' + ('0' + (x.getMonth() + 1)).slice(-2) + '-' + ('0' + x.getDate()).slice(-2); }; const [period, setPeriodRaw] = useState(init); const [offset, setOffset] = useState(0); const [from, setFrom] = useState(isoOf(new Date(NOW.getFullYear(), NOW.getMonth(), NOW.getDate() - 29))); const [to, setTo] = useState(isoOf(NOW)); const [compare, setCompare] = useState(true); const [cmpMode, setCmpMode] = useState('prev'); // mốc so sánh tùy chọn (khi cmpMode === 'custom') const [cfrom, setCfrom] = useState(isoOf(new Date(NOW.getFullYear(), NOW.getMonth(), NOW.getDate() - 59))); const [cto, setCto] = useState(isoOf(new Date(NOW.getFullYear(), NOW.getMonth(), NOW.getDate() - 30))); const cur = resolvePeriod(period, offset, { from, to }); // kỳ so sánh phụ thuộc cmpMode: kỳ liền trước · cùng kỳ năm trước · mốc tùy chọn let prev; if (cmpMode === 'custom') { prev = resolvePeriod('custom', 0, { from: cfrom, to: cto }); } else if (cmpMode === 'year') { const yf = new Date(cur.from), yt = new Date(cur.to); yf.setFullYear(yf.getFullYear() - 1); yt.setFullYear(yt.getFullYear() - 1); prev = resolvePeriod('custom', 0, { from: yf, to: yt }); } else { prev = resolvePeriod(period, offset - 1, { from, to }); } const setPeriod = p => { setPeriodRaw(p); setOffset(0); }; // nhảy tới một ngày bất kỳ ở chế độ 'Hôm nay' (offset = số ngày lệch so với hôm nay, không quá 0) const goToDate = ds => { const d = new Date(ds); const days = Math.round((new Date(d.getFullYear(), d.getMonth(), d.getDate()) - NOW) / 86400000); setOffset(Math.min(0, days)); }; return { period, setPeriod, offset, setOffset, from, setFrom, to, setTo, compare, setCompare, cmpMode, setCmpMode, cfrom, setCfrom, cto, setCto, cur, prev, goToDate, curLabel: cur.label, scale: periodScale(period, offset), step: d => setOffset(o => Math.min(0, o + d)), reset: () => setOffset(0), }; } const PERIOD_LABEL = { today: 'Hôm nay', '7d': '7 ngày qua', '30d': '30 ngày qua', month: 'Tháng này', custom: 'Tùy chọn' }; const CMP_LABEL = { prev: 'Kỳ liền trước', year: 'Cùng kỳ năm trước', custom: 'Mốc tùy chọn' }; function TimeControl({ t }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { if (!open) return; const h = e => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', h); return () => document.removeEventListener('mousedown', h); }, [open]); const cmpVal = t.compare ? t.cmpMode : 'off'; const onCmp = (v) => { if (v === 'off') { t.setCompare(false); } else { t.setCompare(true); t.setCmpMode(v); } }; const isCustom = t.period === 'custom'; const isToday = t.period === 'today'; const atNow = t.offset === 0; const curISO = (() => { const d = t.cur.from; return d.getFullYear() + '-' + ('0' + (d.getMonth() + 1)).slice(-2) + '-' + ('0' + d.getDate()).slice(-2); })(); const todayISO = NOW.getFullYear() + '-' + ('0' + (NOW.getMonth() + 1)).slice(-2) + '-' + ('0' + NOW.getDate()).slice(-2); const jumpList = Array.from({ length: 6 }).map((_, i) => resolvePeriod(t.period, -i, { from: t.from, to: t.to })); return (
{isCustom ? (
t.setFrom(e.target.value)} /> t.setTo(e.target.value)} />
) : isToday ? ( // chế độ Ngày: lịch chọn bất kỳ ngày nào để xem ads của đúng ngày đó
) : (
{open && (
Tra cứu lịch sử · {t.cur.kind}
{jumpList.map(j => ( ))}
)}
)} {!isCustom && !atNow && } t.setCfrom(e.target.value)} /> t.setCto(e.target.value)} />
)} ); } // overlaid 2-line chart: A solid colour, B dashed grey function CompareLineChart({ data, height = 230, color = '#0084ff', fmt, labels, showB = true, labelA = 'Kỳ này', labelB = 'Kỳ trước' }) { const w = 660, h = height, pad = { l: 46, r: 16, t: 16, b: 30 }; const all = showB ? [...data.cur, ...data.prev] : data.cur; const max = Math.max(...all) * 1.12 || 1; const iw = w - pad.l - pad.r, ih = h - pad.t - pad.b; const n = data.cur.length; const x = i => pad.l + (i / (n - 1)) * iw; const y = v => pad.t + ih - (v / max) * ih; const line = arr => arr.map((v, i) => (i ? 'L' : 'M') + x(i) + ',' + y(v)).join(' '); const area = line(data.cur) + ` L${x(n - 1)},${pad.t + ih} L${x(0)},${pad.t + ih} Z`; const ticks = 4; return (
{Array.from({ length: ticks + 1 }).map((_, i) => { const v = max * (i / ticks); const yy = y(v); return {fmt ? fmt(v) : Math.round(v)}; })} {showB && } {(labels || []).map((l, i) => i % Math.ceil(n / 8) === 0 && {l})}
{labelA} {showB && {labelB}}
); } // posting-time heatmap function Heatmap({ days, slots, grid, color = '#0084ff' }) { const max = Math.max(...grid.flat()); return (
{slots.map(s =>
{s}
)} {days.map((d, r) => (
{d}
{grid[r].map((v, c) => { const a = 0.08 + 0.92 * (v / max); return
0.55 ? '#fff' : 'var(--p-text-secondary)' }} title={d + ' ' + slots[c] + 'h · chỉ số ' + v}>{v}
; })}
))}
); } function AlertBanner({ alerts }) { if (!alerts || !alerts.length) return null; return (
{alerts.map((a, i) => (
{a.title}{a.desc}
{a.cta && }
))}
); } function SourceBadge({ src, size = 22, showLabel }) { const c = AAU.mktChannelById(src); if (!c) return null; return {c.short[0]}{showLabel && {c.label}}; } // the cross-dimension Summary table (Course / Channel / Campaign / Class) function SummaryTable({ dim }) { const { rows, total } = AAU.mktSummary(dim); const cplAlert = AAU.mktBudget.cplAlert; const cell = (r) => ( {r.label} {r.spend ? AAU.fmtVNDm(r.spend) : '—'} {AAU.fmtNum(r.messages)} {r.leadsOrg} / {r.leadsAds} {r.leads} cplAlert ? '#c4320a' : undefined }}>{r.cpl ? AAU.fmtVND(r.cpl) : '—'} {r.costMsg ? AAU.fmtVND(r.costMsg) : '—'} {r.won} {r.cac ? AAU.fmtVNDm(r.cac) : '—'} ); return (
{rows.map(cell)}{cell(total)}
{dim === 'course' ? 'Khóa học' : dim === 'channel' ? 'Kênh' : dim === 'campaign' ? 'Campaign' : 'Lớp khai giảng'} Ngân sáchMsg Lead (org/ads)Σ Lead CPLCost/MsgWonCAC
); } // reusable Sync control — shows last-sync time, spins on click, supports realtime badge function SyncButton({ label = 'Đồng bộ', since = '12 phút trước', realtime = false, onSync = null }) { const [syncing, setSyncing] = useState(false); const [last, setLast] = useState(since); const go = async () => { if (syncing) return; setSyncing(true); try { if (onSync) await onSync(); // gọi API thật else await new Promise(r => setTimeout(r, 1100)); // nút demo setLast('vừa xong'); } catch (e) { setLast('lỗi: ' + (e && e.message || e)); } finally { setSyncing(false); } }; return ( ); } Object.assign(window, { useTimeRange, TimeControl, CompareLineChart, Heatmap, AlertBanner, SourceBadge, SummaryTable, SyncButton, PERIOD_LABEL, CMP_LABEL });