2026-05-06 23:05:37 +03:30
|
|
|
'use client';
|
|
|
|
|
|
2026-05-17 16:48:42 +03:30
|
|
|
import { useEffect, useId, useRef, useState } from 'react';
|
|
|
|
|
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
2026-05-18 12:50:25 +03:30
|
|
|
import { addCalendarDays, startOfLocalDay } from '@/lib/appointmentTime';
|
2026-05-06 23:05:37 +03:30
|
|
|
|
|
|
|
|
interface ScheduleDayPickerProps {
|
|
|
|
|
value: Date;
|
|
|
|
|
onChange: (day: Date) => void;
|
|
|
|
|
label?: string;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-17 16:48:42 +03:30
|
|
|
const MONTH_LABELS = [
|
|
|
|
|
'January',
|
|
|
|
|
'February',
|
|
|
|
|
'March',
|
|
|
|
|
'April',
|
|
|
|
|
'May',
|
|
|
|
|
'June',
|
|
|
|
|
'July',
|
|
|
|
|
'August',
|
|
|
|
|
'September',
|
|
|
|
|
'October',
|
|
|
|
|
'November',
|
|
|
|
|
'December',
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
function daysInMonth(year: number, month: number): number {
|
|
|
|
|
return new Date(year, month + 1, 0).getDate();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildLocalDay(year: number, month: number, day: number): Date {
|
|
|
|
|
return new Date(year, month, day, 0, 0, 0, 0);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 12:50:25 +03:30
|
|
|
function yearRange(anchor: Date): number[] {
|
|
|
|
|
const anchorYear = anchor.getFullYear();
|
|
|
|
|
const startYear = anchorYear - 10;
|
|
|
|
|
const endYear = anchorYear + 2;
|
2026-05-17 16:48:42 +03:30
|
|
|
const years: number[] = [];
|
|
|
|
|
for (let y = startYear; y <= endYear; y += 1) {
|
|
|
|
|
years.push(y);
|
|
|
|
|
}
|
|
|
|
|
return years;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const selectClassName = `
|
|
|
|
|
w-full appearance-none rounded-[var(--radius-sm)] border border-border
|
|
|
|
|
bg-background-card/90 text-text-primary text-sm
|
|
|
|
|
pl-2 pr-7 py-1.5
|
|
|
|
|
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
|
|
|
|
|
`;
|
|
|
|
|
|
2026-05-18 12:50:25 +03:30
|
|
|
/**
|
|
|
|
|
* Calendar day navigator (arrows + year/month/day panel).
|
|
|
|
|
* Does not restrict past dates — parent pages enforce read-only vs editable for schedule grids/forms.
|
|
|
|
|
*/
|
|
|
|
|
export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) {
|
2026-05-17 16:48:42 +03:30
|
|
|
const panelId = useId();
|
|
|
|
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const [panelOpen, setPanelOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
const normalizedValue = startOfLocalDay(value);
|
|
|
|
|
|
|
|
|
|
const labelText = normalizedValue.toLocaleDateString(undefined, {
|
2026-05-06 23:05:37 +03:30
|
|
|
weekday: 'short',
|
|
|
|
|
month: 'short',
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-18 12:50:25 +03:30
|
|
|
const years = yearRange(normalizedValue);
|
2026-05-17 16:48:42 +03:30
|
|
|
const selectedYear = normalizedValue.getFullYear();
|
|
|
|
|
const selectedMonth = normalizedValue.getMonth();
|
|
|
|
|
const selectedDay = normalizedValue.getDate();
|
|
|
|
|
const dayCount = daysInMonth(selectedYear, selectedMonth);
|
|
|
|
|
|
|
|
|
|
function applyParts(year: number, month: number, day: number, closePanel = false) {
|
2026-05-18 12:50:25 +03:30
|
|
|
const maxDay = daysInMonth(year, month);
|
|
|
|
|
onChange(buildLocalDay(year, month, Math.min(Math.max(1, day), maxDay)));
|
2026-05-17 16:48:42 +03:30
|
|
|
if (closePanel) {
|
|
|
|
|
setPanelOpen(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!panelOpen) return;
|
|
|
|
|
|
|
|
|
|
function onPointerDown(event: MouseEvent) {
|
|
|
|
|
if (!rootRef.current?.contains(event.target as Node)) {
|
|
|
|
|
setPanelOpen(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function onKeyDown(event: KeyboardEvent) {
|
|
|
|
|
if (event.key === 'Escape') {
|
|
|
|
|
setPanelOpen(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
document.addEventListener('mousedown', onPointerDown);
|
|
|
|
|
document.addEventListener('keydown', onKeyDown);
|
|
|
|
|
return () => {
|
|
|
|
|
document.removeEventListener('mousedown', onPointerDown);
|
|
|
|
|
document.removeEventListener('keydown', onKeyDown);
|
|
|
|
|
};
|
|
|
|
|
}, [panelOpen]);
|
|
|
|
|
|
2026-05-06 23:05:37 +03:30
|
|
|
return (
|
2026-05-17 16:48:42 +03:30
|
|
|
<div ref={rootRef} className="relative w-full max-w-md">
|
2026-05-06 23:05:37 +03:30
|
|
|
<p className="text-sm font-medium text-text-secondary mb-2">{label}</p>
|
|
|
|
|
<div className="flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-05-18 12:50:25 +03:30
|
|
|
onClick={() => onChange(addCalendarDays(normalizedValue, -1))}
|
|
|
|
|
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
|
2026-05-06 23:05:37 +03:30
|
|
|
aria-label="Previous day"
|
|
|
|
|
>
|
|
|
|
|
<ChevronLeft className="h-4 w-4 icon-flat" />
|
|
|
|
|
</button>
|
2026-05-17 16:48:42 +03:30
|
|
|
|
2026-05-06 23:05:37 +03:30
|
|
|
<button
|
|
|
|
|
type="button"
|
2026-05-17 16:48:42 +03:30
|
|
|
onClick={() => setPanelOpen((open) => !open)}
|
|
|
|
|
aria-expanded={panelOpen}
|
|
|
|
|
aria-controls={panelId}
|
|
|
|
|
aria-haspopup="dialog"
|
|
|
|
|
className="flex flex-1 min-w-0 items-center justify-center gap-1 rounded-[var(--radius-sm)] px-2 py-1.5 text-sm font-medium text-text-primary tabular-nums hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
|
|
|
|
|
>
|
|
|
|
|
<span className="truncate">{labelText}</span>
|
|
|
|
|
<ChevronDown
|
|
|
|
|
className={`h-3.5 w-3.5 shrink-0 text-text-muted icon-flat transition-transform ${panelOpen ? 'rotate-180' : ''}`}
|
|
|
|
|
aria-hidden
|
|
|
|
|
/>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => onChange(addCalendarDays(normalizedValue, 1))}
|
2026-05-06 23:05:37 +03:30
|
|
|
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
|
|
|
|
|
aria-label="Next day"
|
|
|
|
|
>
|
|
|
|
|
<ChevronRight className="h-4 w-4 icon-flat" />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-05-17 16:48:42 +03:30
|
|
|
|
|
|
|
|
{panelOpen && (
|
|
|
|
|
<div
|
|
|
|
|
id={panelId}
|
|
|
|
|
role="dialog"
|
|
|
|
|
aria-label="Choose schedule date"
|
|
|
|
|
className="absolute left-0 right-0 top-full z-50 mt-2 rounded-[var(--radius-md)] border border-border bg-background-secondary p-3 shadow-lg"
|
|
|
|
|
>
|
|
|
|
|
<div className="grid grid-cols-3 gap-2">
|
|
|
|
|
<div>
|
|
|
|
|
<label
|
|
|
|
|
htmlFor={`${panelId}-year`}
|
|
|
|
|
className="mb-1 block text-xs font-medium text-text-muted"
|
|
|
|
|
>
|
|
|
|
|
Year
|
|
|
|
|
</label>
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<select
|
|
|
|
|
id={`${panelId}-year`}
|
|
|
|
|
value={selectedYear}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
applyParts(Number(e.target.value), selectedMonth, selectedDay)
|
|
|
|
|
}
|
|
|
|
|
className={selectClassName}
|
|
|
|
|
>
|
|
|
|
|
{years.map((year) => (
|
|
|
|
|
<option key={year} value={year}>
|
|
|
|
|
{year}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
<ChevronDown
|
|
|
|
|
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
|
|
|
|
|
aria-hidden
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label
|
|
|
|
|
htmlFor={`${panelId}-month`}
|
|
|
|
|
className="mb-1 block text-xs font-medium text-text-muted"
|
|
|
|
|
>
|
|
|
|
|
Month
|
|
|
|
|
</label>
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<select
|
|
|
|
|
id={`${panelId}-month`}
|
|
|
|
|
value={selectedMonth}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
applyParts(selectedYear, Number(e.target.value), selectedDay)
|
|
|
|
|
}
|
|
|
|
|
className={selectClassName}
|
|
|
|
|
>
|
2026-05-18 12:50:25 +03:30
|
|
|
{MONTH_LABELS.map((name, index) => (
|
|
|
|
|
<option key={name} value={index}>
|
|
|
|
|
{name}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
2026-05-17 16:48:42 +03:30
|
|
|
</select>
|
|
|
|
|
<ChevronDown
|
|
|
|
|
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
|
|
|
|
|
aria-hidden
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div>
|
|
|
|
|
<label
|
|
|
|
|
htmlFor={`${panelId}-day`}
|
|
|
|
|
className="mb-1 block text-xs font-medium text-text-muted"
|
|
|
|
|
>
|
|
|
|
|
Day
|
|
|
|
|
</label>
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<select
|
|
|
|
|
id={`${panelId}-day`}
|
|
|
|
|
value={selectedDay}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
applyParts(
|
|
|
|
|
selectedYear,
|
|
|
|
|
selectedMonth,
|
|
|
|
|
Number(e.target.value),
|
|
|
|
|
true,
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
className={selectClassName}
|
|
|
|
|
>
|
2026-05-18 12:50:25 +03:30
|
|
|
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => (
|
|
|
|
|
<option key={day} value={day}>
|
|
|
|
|
{day}
|
|
|
|
|
</option>
|
|
|
|
|
))}
|
2026-05-17 16:48:42 +03:30
|
|
|
</select>
|
|
|
|
|
<ChevronDown
|
|
|
|
|
className="pointer-events-none absolute right-1.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-text-muted icon-flat"
|
|
|
|
|
aria-hidden
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-05-06 23:05:37 +03:30
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|