/* 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 [period, setPeriodRaw] = useState(init);
const [offset, setOffset] = useState(0);
const [from, setFrom] = useState('2026-05-04');
const [to, setTo] = useState('2026-06-02');
const [compare, setCompare] = useState(true);
const [cmpMode, setCmpMode] = useState('prev');
const cur = resolvePeriod(period, offset, { from, to });
const prev = resolvePeriod(period, offset - 1, { from, to });
const setPeriod = p => { setPeriodRaw(p); setOffset(0); };
return {
period, setPeriod, offset, setOffset, from, setFrom, to, setTo,
compare, setCompare, cmpMode, setCmpMode, cur, prev,
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 atNow = t.offset === 0;
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)} />
) : (
{open && (
Tra cứu lịch sử · {t.cur.kind}
{jumpList.map(j => (
))}
)}
)}
{!isCustom && !atNow &&
}
);
}
// 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 (
{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 (
| {dim === 'course' ? 'Khóa học' : dim === 'channel' ? 'Kênh' : dim === 'campaign' ? 'Campaign' : 'Lớp khai giảng'} |
Ngân sách | Msg |
Lead (org/ads) | Σ Lead |
CPL | Cost/Msg | Won | CAC |
{rows.map(cell)}{cell(total)}
);
}
// 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 });