/* 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.compare && t.cmpMode === 'custom' && (
// mốc so sánh tùy chọn
so với
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 (
{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 });