// Custom date-range picker. The two native (#date-start / // #date-end) stay in the DOM (hidden) as the source of truth — all filter code // keeps reading/writing them. The calendar selects into a temporary state; the // filter is applied (and the popup closed) only when the user presses «Применить». // // Two endpoint pills «С» / «По» sit above the grid: one is active, and clicking a // day writes into the active endpoint, then focus auto-advances to the other. Each // pill has a × to clear just that endpoint, so an OPEN range is possible — «с X» // (no end) or «по Y» (no start). «Применить» is enabled once at least one endpoint // is set; «Сбросить» clears both. const MONTHS = ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь']; const WEEKDAYS = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс']; function parseISO(iso: string): Date | null { return iso ? new Date(iso + 'T00:00:00') : null; } function toISO(d: Date): string { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; } function fmt(iso: string): string { const p = iso.split('-'); // Short 2-digit year so the full range fits the compact "Период" field. return p.length === 3 ? `${p[2]}.${p[1]}.${p[0].slice(2)}` : iso; } export interface DateRange { /** Refresh the field text (+ open calendar) from the hidden inputs. */ sync(): void; } export function createDateRange(): DateRange { const startInput = document.getElementById('date-start') as HTMLInputElement; const endInput = document.getElementById('date-end') as HTMLInputElement; const field = document.getElementById('dr-field') as HTMLElement; const text = document.getElementById('dr-text') as HTMLElement; const clearBtn = document.getElementById('dr-clear') as HTMLElement; const cal = document.getElementById('dr-cal') as HTMLElement; let viewY = new Date().getFullYear(); let viewM = new Date().getMonth(); // In-progress selection (committed to the inputs on «Применить»). Either end may // stay empty → an open range. `activeEnd` is the endpoint the next day-click fills. let selStart = ''; let selEnd = ''; let activeEnd: 'start' | 'end' = 'start'; function updateText(): void { const s = startInput.value; const e = endInput.value; if (s && e) text.textContent = `${fmt(s)} — ${fmt(e)}`; else if (s) text.textContent = `с ${fmt(s)}`; else if (e) text.textContent = `по ${fmt(e)}`; else text.textContent = 'Период'; clearBtn.style.display = s || e ? '' : 'none'; field.classList.toggle('has-value', !!(s || e)); } function render(): void { const s = parseISO(selStart); const e = parseISO(selEnd); const endPill = (which: 'start' | 'end', label: string, val: string): string => ``; const parts: string[] = [ `
${endPill('start', 'С', selStart)}${endPill('end', 'По', selEnd)}
`, `
` + `${MONTHS[viewM]} ${viewY}` + `
`, '
', ]; for (const w of WEEKDAYS) parts.push(`${w}`); const lead = (new Date(viewY, viewM, 1).getDay() + 6) % 7; // Monday-first const days = new Date(viewY, viewM + 1, 0).getDate(); for (let i = 0; i < lead; i++) parts.push(''); for (let d = 1; d <= days; d++) { const cur = new Date(viewY, viewM, d); const iso = toISO(cur); let cls = 'dr-day'; if (iso === selStart) cls += ' s'; if (iso === selEnd) cls += ' e'; if (s && e && cur > s && cur < e) cls += ' rng'; parts.push(``); } parts.push('
'); const ready = !!(selStart || selEnd); // at least one endpoint → applyable (open range OK) parts.push( '
' + `` + `` + '
' ); cal.innerHTML = parts.join(''); } // Write the completed selection to the hidden inputs → triggers the filter reload. function applyRange(): void { startInput.value = selStart; endInput.value = selEnd; startInput.dispatchEvent(new Event('change', { bubbles: true })); endInput.dispatchEvent(new Event('change', { bubbles: true })); updateText(); } function pick(iso: string): void { if (activeEnd === 'start') { selStart = iso; // Крест начала за концом — конец больше не валиден, очищаем его. if (selEnd && selStart > selEnd) selEnd = ''; if (!selEnd) activeEnd = 'end'; // авто-переход к незаданному концу } else { selEnd = iso; if (selStart && selEnd < selStart) selStart = ''; if (!selStart) activeEnd = 'start'; } render(); } function open(): void { selStart = startInput.value; selEnd = endInput.value; // Start filling whichever endpoint is empty (start first). activeEnd = !selStart ? 'start' : !selEnd ? 'end' : 'start'; const base = parseISO(selStart) || parseISO(selEnd) || new Date(); viewY = base.getFullYear(); viewM = base.getMonth(); render(); cal.hidden = false; } function close(): void { cal.hidden = true; } field.addEventListener('click', (ev) => { if ((ev.target as HTMLElement).closest('.dr-clear')) return; if (cal.hidden) open(); else close(); }); field.addEventListener('keydown', (ev) => { if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); if (cal.hidden) open(); else close(); } }); clearBtn.addEventListener('click', (ev) => { ev.stopPropagation(); selStart = ''; selEnd = ''; startInput.value = ''; endInput.value = ''; startInput.dispatchEvent(new Event('change', { bubbles: true })); endInput.dispatchEvent(new Event('change', { bubbles: true })); updateText(); close(); }); cal.addEventListener('click', (ev) => { // Keep the click inside the calendar so re-rendering a clicked day doesn't // detach the target and trip the outside-click close below. ev.stopPropagation(); const target = ev.target as HTMLElement; const nav = target.closest('.dr-nav'); if (nav) { viewM += Number(nav.dataset.nav); if (viewM < 0) { viewM = 11; viewY--; } else if (viewM > 11) { viewM = 0; viewY++; } render(); return; } const act = target.closest('[data-act]'); if (act) { if (act.dataset.act === 'apply') { if (selStart || selEnd) { applyRange(); close(); } } else if (act.dataset.act === 'reset') { selStart = ''; selEnd = ''; activeEnd = 'start'; render(); } return; } // Clear one endpoint via its × (→ open range on that side). Checked before the // pill activate below, since the × lives inside the pill button. const clr = target.closest('[data-clear]'); if (clr) { const which = clr.dataset.clear as 'start' | 'end'; if (which === 'start') selStart = ''; else selEnd = ''; activeEnd = which; // focus the emptied end for the next pick render(); return; } const endBtn = target.closest('[data-end]'); if (endBtn) { activeEnd = endBtn.dataset.end as 'start' | 'end'; render(); return; } const day = target.closest('.dr-day'); if (day && day.dataset.iso) pick(day.dataset.iso); }); document.addEventListener('click', (ev) => { if (!(ev.target as HTMLElement).closest('#daterange')) close(); }); updateText(); return { sync(): void { updateText(); if (!cal.hidden) { selStart = startInput.value; selEnd = endInput.value; activeEnd = !selStart ? 'start' : !selEnd ? 'end' : 'start'; render(); } }, }; }