// 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[] = [
`
',
];
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();
}
},
};
}