/* AAU CRM — Shared flexible time-period navigator.
One control used everywhere a screen shows a "mốc thời gian":
- granularity (Ngày / Tuần / Tháng / Quý / Tùy chọn — configurable per screen)
- ◀ ▶ history stepper so the user can walk back through past periods
- a clickable label that opens a "jump to period" popover
- "Hiện tại" reset chip when not on the current period
- custom from→to range
- compare-with dropdown
Exposes usePeriod() + on window. */
// Anchor "today" — keep in sync with data.js TODAY (02/06/2026)
const NOW = new Date(2026, 5, 2);
const pad2 = n => ('0' + n).slice(-2);
const dmy = d => pad2(d.getDate()) + '/' + pad2(d.getMonth() + 1) + '/' + d.getFullYear();
const dm = d => pad2(d.getDate()) + '/' + pad2(d.getMonth() + 1);
function startOfWeek(d) { const x = new Date(d); const wd = (x.getDay() + 6) % 7; x.setDate(x.getDate() - wd); x.setHours(0, 0, 0, 0); return x; }
function isoWeek(d) {
const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
const day = (t.getUTCDay() + 6) % 7; t.setUTCDate(t.getUTCDate() - day + 3);
const first = new Date(Date.UTC(t.getUTCFullYear(), 0, 4));
return 1 + Math.round(((t - first) / 86400000 - 3 + ((first.getUTCDay() + 6) % 7)) / 7);
}
// Resolve a {gran, offset} pair (offset 0 = current, -1 = previous, …) into a concrete window + label.
function resolvePeriod(gran, offset, custom) {
if (gran === 'custom') {
const f = custom && custom.from ? new Date(custom.from) : NOW;
const t = custom && custom.to ? new Date(custom.to) : NOW;
return { gran, offset, from: f, to: t, kind: 'Tùy chọn', label: dm(f) + ' – ' + dm(t) + '/' + t.getFullYear(), short: dm(f) + '–' + dm(t) };
}
if (gran === 'today') {
const d = new Date(NOW); d.setDate(d.getDate() + offset);
return { gran, offset, from: d, to: d, kind: 'Ngày', label: (offset === 0 ? 'Hôm nay · ' : '') + dmy(d), short: dm(d) };
}
if (gran === '7d' || gran === '30d') {
const span = gran === '7d' ? 7 : 30;
const to = new Date(NOW); to.setDate(to.getDate() + offset * span);
const from = new Date(to); from.setDate(from.getDate() - (span - 1));
return { gran, offset, from, to, kind: span + ' ngày', label: dm(from) + ' – ' + dm(to) + '/' + to.getFullYear(), short: dm(from) + '–' + dm(to) };
}
if (gran === 'week') {
const s = startOfWeek(NOW); s.setDate(s.getDate() + offset * 7);
const e = new Date(s); e.setDate(e.getDate() + 6);
return { gran, offset, from: s, to: e, kind: 'Tuần', label: 'Tuần ' + isoWeek(s) + ' · ' + dm(s) + '–' + dm(e), short: 'Tuần ' + isoWeek(s) };
}
if (gran === 'quarter') {
const qIdx = Math.floor(NOW.getMonth() / 3) + offset;
const m0 = new Date(NOW.getFullYear(), qIdx * 3, 1);
const e = new Date(m0.getFullYear(), m0.getMonth() + 3, 0);
return { gran, offset, from: m0, to: e, kind: 'Quý', label: 'Quý ' + (Math.floor(m0.getMonth() / 3) + 1) + ', ' + m0.getFullYear(), short: 'Q' + (Math.floor(m0.getMonth() / 3) + 1) + '·' + String(m0.getFullYear()).slice(-2) };
}
// month (default)
const m0 = new Date(NOW.getFullYear(), NOW.getMonth() + offset, 1);
const e = new Date(m0.getFullYear(), m0.getMonth() + 1, 0);
return { gran: 'month', offset, from: m0, to: e, kind: 'Tháng', label: 'Tháng ' + (m0.getMonth() + 1) + ', ' + m0.getFullYear(), short: 'Th' + (m0.getMonth() + 1) + '/' + String(m0.getFullYear()).slice(-2) };
}
// Deterministic "history" multiplier — the academy grew over time, so older periods read smaller.
// Lets KPIs/charts actually shift when you look back, making the lookup feel real (not static).
function periodScale(gran, offset) {
if (offset === 0) return 1;
const base = gran === 'quarter' ? 0.86 : gran === 'week' ? 0.985 : (gran === 'today' || gran === '7d' || gran === '30d') ? 0.97 : 0.93;
const wig = 1 + 0.025 * Math.sin(offset * 1.7);
return Math.pow(base, -offset) * wig; // offset<0 ⇒ <1
}
const CMP_OPTS = [
{ value: 'off', label: 'Không so sánh' },
{ value: 'prev', label: 'vs Kỳ trước' },
{ value: 'year', label: 'vs Cùng kỳ năm trước' },
];
function usePeriod(initGran = 'month') {
const [gran, setGranRaw] = useState(initGran);
const [offset, setOffset] = useState(0);
const [custom, setCustom] = useState({ from: '2026-05-04', to: '2026-06-02' });
const [cmp, setCmp] = useState('prev');
const cur = resolvePeriod(gran, offset, custom);
const prev = resolvePeriod(gran, offset - 1, custom);
const setGran = g => { setGranRaw(g); setOffset(0); };
return {
gran, setGran, offset, setOffset, custom, setCustom, cmp, setCmp,
cur, prev, scale: periodScale(gran, offset),
step: d => setOffset(o => Math.min(0, o + d)), reset: () => setOffset(0),
cmpLabel: (CMP_OPTS.find(o => o.value === cmp) || {}).label || '',
compareOn: cmp !== 'off',
};
}
const DEFAULT_GRANS = [
{ value: 'week', label: 'Tuần' },
{ value: 'month', label: 'Tháng' },
{ value: 'quarter', label: 'Quý' },
];
function PeriodNav({ p, grans = DEFAULT_GRANS, compare = false, jump = 8 }) {
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 isCustom = p.gran === 'custom';
const atNow = p.offset === 0;
const jumpList = Array.from({ length: jump }).map((_, i) => resolvePeriod(p.gran, -i, p.custom));
return (
{isCustom ? (
p.setCustom({ ...p.custom, from: e.target.value })} />
→
p.setCustom({ ...p.custom, to: e.target.value })} />
) : (
{open && (
Tra cứu lịch sử · {p.cur.kind}
{jumpList.map(j => (
))}
)}
)}
{!isCustom && !atNow && (
)}
{compare && (
)}
);
}
Object.assign(window, { NOW, resolvePeriod, periodScale, usePeriod, PeriodNav, isoWeek, dmy, dm, CMP_OPTS });