bugfix/demo-bugs-fixed #18
@@ -14,7 +14,8 @@ import { AppointmentScheduleGrid } from '@/components/ui/appointments/Appointmen
|
|||||||
import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch';
|
import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch';
|
||||||
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
|
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
|
||||||
import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker';
|
import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker';
|
||||||
import { Toast } from '@/components/ui/common/Toast';
|
import { ToastStack } from '@/components/ui/common/Toast';
|
||||||
|
import { useToast } from '@/lib/hooks/useToast';
|
||||||
import type { AppointmentPurpose } from '@/types/appointment';
|
import type { AppointmentPurpose } from '@/types/appointment';
|
||||||
import { formatApiErrorMessage } from '@/lib/formatApiError';
|
import { formatApiErrorMessage } from '@/lib/formatApiError';
|
||||||
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime';
|
import { compareLocalDayStart, getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime';
|
||||||
@@ -33,7 +34,7 @@ export default function AppointmentsPage() {
|
|||||||
const [providers, setProviders] = useState<AppointmentColumnProvider[]>([]);
|
const [providers, setProviders] = useState<AppointmentColumnProvider[]>([]);
|
||||||
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
|
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
|
||||||
const [loadingSchedule, setLoadingSchedule] = useState(false);
|
const [loadingSchedule, setLoadingSchedule] = useState(false);
|
||||||
const [scheduleError, setScheduleError] = useState('');
|
const toast = useToast();
|
||||||
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [patients, setPatients] = useState<Patient[]>([]);
|
const [patients, setPatients] = useState<Patient[]>([]);
|
||||||
@@ -51,9 +52,6 @@ export default function AppointmentsPage() {
|
|||||||
const [editingAppointmentId, setEditingAppointmentId] = useState<string | null>(null);
|
const [editingAppointmentId, setEditingAppointmentId] = useState<string | null>(null);
|
||||||
const [savingAppointment, setSavingAppointment] = useState(false);
|
const [savingAppointment, setSavingAppointment] = useState(false);
|
||||||
|
|
||||||
const [toastError, setToastError] = useState('');
|
|
||||||
const [toastSuccess, setToastSuccess] = useState('');
|
|
||||||
const [toastInfo, setToastInfo] = useState('');
|
|
||||||
|
|
||||||
const canManageAppointments = canEditAppointments(currentOrganization);
|
const canManageAppointments = canEditAppointments(currentOrganization);
|
||||||
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
|
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
|
||||||
@@ -84,7 +82,7 @@ export default function AppointmentsPage() {
|
|||||||
}
|
}
|
||||||
const gen = ++scheduleLoadGen.current;
|
const gen = ++scheduleLoadGen.current;
|
||||||
setLoadingSchedule(true);
|
setLoadingSchedule(true);
|
||||||
setScheduleError('');
|
toast.setError('');
|
||||||
try {
|
try {
|
||||||
const range = getLocalDayIsoRange(scheduleDate);
|
const range = getLocalDayIsoRange(scheduleDate);
|
||||||
const [pRes, aRes] = await Promise.all([
|
const [pRes, aRes] = await Promise.all([
|
||||||
@@ -100,7 +98,7 @@ export default function AppointmentsPage() {
|
|||||||
if (gen !== scheduleLoadGen.current) {
|
if (gen !== scheduleLoadGen.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setScheduleError(formatApiErrorMessage(err, 'Failed to load schedule.'));
|
toast.showError(formatApiErrorMessage(err, 'Failed to load schedule.'));
|
||||||
} finally {
|
} finally {
|
||||||
if (gen === scheduleLoadGen.current) {
|
if (gen === scheduleLoadGen.current) {
|
||||||
setLoadingSchedule(false);
|
setLoadingSchedule(false);
|
||||||
@@ -143,21 +141,20 @@ export default function AppointmentsPage() {
|
|||||||
|
|
||||||
async function handleCreatePatient() {
|
async function handleCreatePatient() {
|
||||||
setSavingPatient(true);
|
setSavingPatient(true);
|
||||||
setToastError('');
|
toast.setError('');
|
||||||
setToastSuccess('');
|
|
||||||
try {
|
try {
|
||||||
const response = await patientsApi.create(patientForm);
|
const response = await patientsApi.create(patientForm);
|
||||||
setIsCreateOpen(false);
|
setIsCreateOpen(false);
|
||||||
setPatientForm(EMPTY_PATIENT_FORM);
|
setPatientForm(EMPTY_PATIENT_FORM);
|
||||||
await loadPatientsSearch(search);
|
await loadPatientsSearch(search);
|
||||||
setSelectedPatient(response.data);
|
setSelectedPatient(response.data);
|
||||||
setToastSuccess(`Patient ${response.data.firstName} ${response.data.lastName} was saved.`);
|
toast.showSuccess(`Patient ${response.data.firstName} ${response.data.lastName} was saved.`);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message =
|
const message =
|
||||||
err && typeof err === 'object' && 'message' in err
|
err && typeof err === 'object' && 'message' in err
|
||||||
? String((err as { message: unknown }).message)
|
? String((err as { message: unknown }).message)
|
||||||
: 'Failed to save patient.';
|
: 'Failed to save patient.';
|
||||||
setToastError(message);
|
toast.showError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingPatient(false);
|
setSavingPatient(false);
|
||||||
}
|
}
|
||||||
@@ -165,15 +162,11 @@ export default function AppointmentsPage() {
|
|||||||
|
|
||||||
function handleSlotClick(hour: number, providerUserId: string, providerName: string) {
|
function handleSlotClick(hour: number, providerUserId: string, providerName: string) {
|
||||||
if (isViewingPastDay) {
|
if (isViewingPastDay) {
|
||||||
setToastSuccess('');
|
toast.showInfo('Past appointments are view-only.');
|
||||||
setToastError('');
|
|
||||||
setToastInfo('Past appointments are view-only.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!selectedPatient) {
|
if (!selectedPatient) {
|
||||||
setToastSuccess('');
|
toast.showInfo('Select a patient before booking.');
|
||||||
setToastError('');
|
|
||||||
setToastInfo('Select a patient before booking.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setBookingHour(hour);
|
setBookingHour(hour);
|
||||||
@@ -185,9 +178,7 @@ export default function AppointmentsPage() {
|
|||||||
|
|
||||||
function handleAppointmentClick(appointment: AppointmentRecord) {
|
function handleAppointmentClick(appointment: AppointmentRecord) {
|
||||||
if (isViewingPastDay) {
|
if (isViewingPastDay) {
|
||||||
setToastSuccess('');
|
toast.showInfo('Past appointments are view-only.');
|
||||||
setToastError('');
|
|
||||||
setToastInfo('Past appointments are view-only.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const provider = providers.find((p) => p.userId === appointment.providerUserId);
|
const provider = providers.find((p) => p.userId === appointment.providerUserId);
|
||||||
@@ -206,9 +197,7 @@ export default function AppointmentsPage() {
|
|||||||
purpose: AppointmentPurpose;
|
purpose: AppointmentPurpose;
|
||||||
}) {
|
}) {
|
||||||
setSavingAppointment(true);
|
setSavingAppointment(true);
|
||||||
setToastError('');
|
toast.setError('');
|
||||||
setToastSuccess('');
|
|
||||||
setToastInfo('');
|
|
||||||
try {
|
try {
|
||||||
if (activeEditingAppointment) {
|
if (activeEditingAppointment) {
|
||||||
await appointmentsApi.update(activeEditingAppointment.id, payload);
|
await appointmentsApi.update(activeEditingAppointment.id, payload);
|
||||||
@@ -217,7 +206,7 @@ export default function AppointmentsPage() {
|
|||||||
}
|
}
|
||||||
setBookingOpen(false);
|
setBookingOpen(false);
|
||||||
setEditingAppointmentId(null);
|
setEditingAppointmentId(null);
|
||||||
setToastSuccess(activeEditingAppointment ? 'Appointment updated.' : 'Appointment saved.');
|
toast.showSuccess(activeEditingAppointment ? 'Appointment updated.' : 'Appointment saved.');
|
||||||
await loadSchedule();
|
await loadSchedule();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message =
|
const message =
|
||||||
@@ -226,7 +215,7 @@ export default function AppointmentsPage() {
|
|||||||
: activeEditingAppointment
|
: activeEditingAppointment
|
||||||
? 'Could not update appointment.'
|
? 'Could not update appointment.'
|
||||||
: 'Could not save appointment.';
|
: 'Could not save appointment.';
|
||||||
setToastError(message);
|
toast.showError(message);
|
||||||
} finally {
|
} finally {
|
||||||
setSavingAppointment(false);
|
setSavingAppointment(false);
|
||||||
}
|
}
|
||||||
@@ -236,57 +225,33 @@ export default function AppointmentsPage() {
|
|||||||
if (!window.confirm('Remove this appointment?')) {
|
if (!window.confirm('Remove this appointment?')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setToastError('');
|
toast.setError('');
|
||||||
setToastSuccess('');
|
|
||||||
setToastInfo('');
|
|
||||||
try {
|
try {
|
||||||
await appointmentsApi.remove(id);
|
await appointmentsApi.remove(id);
|
||||||
setToastSuccess('Appointment removed.');
|
toast.showSuccess('Appointment removed.');
|
||||||
await loadSchedule();
|
await loadSchedule();
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message =
|
const message =
|
||||||
err && typeof err === 'object' && 'message' in err
|
err && typeof err === 'object' && 'message' in err
|
||||||
? String((err as { message: unknown }).message)
|
? String((err as { message: unknown }).message)
|
||||||
: 'Could not delete appointment.';
|
: 'Could not delete appointment.';
|
||||||
setToastError(message);
|
toast.showError(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!toastSuccess) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const id = setTimeout(() => setToastSuccess(''), 3200);
|
|
||||||
return () => clearTimeout(id);
|
|
||||||
}, [toastSuccess]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!toastError) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const id = setTimeout(() => setToastError(''), 4000);
|
|
||||||
return () => clearTimeout(id);
|
|
||||||
}, [toastError]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!toastInfo) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const id = setTimeout(() => setToastInfo(''), 4000);
|
|
||||||
return () => clearTimeout(id);
|
|
||||||
}, [toastInfo]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative space-y-6 pb-24">
|
<div className="space-y-6">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-2xl font-semibold text-text-primary">Appointments</h1>
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
Search a patient, pick a date, then click a time slot under a provider to book.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ToastStack {...toast.messages} />
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
|
||||||
<div className="xl:col-span-1 space-y-4">
|
<div className="xl:col-span-1 space-y-4">
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h1 className="text-2xl font-semibold text-text-primary">Appointments</h1>
|
|
||||||
<p className="text-sm text-text-secondary">
|
|
||||||
Search a patient, pick a date, then click a time slot under a provider to book.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AppointmentsPatientSearch
|
<AppointmentsPatientSearch
|
||||||
search={search}
|
search={search}
|
||||||
onSearchChange={setSearch}
|
onSearchChange={setSearch}
|
||||||
@@ -361,16 +326,6 @@ export default function AppointmentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(scheduleError || toastError || toastSuccess || toastInfo) && (
|
|
||||||
<div className="fixed bottom-4 left-0 right-0 z-[70] px-4 pointer-events-none">
|
|
||||||
<div className="pointer-events-auto w-full space-y-2">
|
|
||||||
{scheduleError && <Toast variant="danger">{scheduleError}</Toast>}
|
|
||||||
{toastError && <Toast variant="danger">{toastError}</Toast>}
|
|
||||||
{toastInfo && <Toast variant="warning">{toastInfo}</Toast>}
|
|
||||||
{toastSuccess && <Toast variant="success">{toastSuccess}</Toast>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +1,299 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
import { useEffect, useId, useRef, useState } from 'react';
|
||||||
import { addCalendarDays } from '@/lib/appointmentTime';
|
import { ChevronDown, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
addCalendarDays,
|
||||||
|
compareLocalDayStart,
|
||||||
|
startOfLocalDay,
|
||||||
|
} from '@/lib/appointmentTime';
|
||||||
|
|
||||||
interface ScheduleDayPickerProps {
|
interface ScheduleDayPickerProps {
|
||||||
value: Date;
|
value: Date;
|
||||||
onChange: (day: Date) => void;
|
onChange: (day: Date) => void;
|
||||||
/** Optional lower bound; picker navigation is unrestricted for history browsing. */
|
/** Optional lower bound for day selection and previous-day navigation. */
|
||||||
minDate?: Date;
|
minDate?: Date;
|
||||||
label?: string;
|
label?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ScheduleDayPicker({ value, onChange, label = 'Schedule date' }: ScheduleDayPickerProps) {
|
const MONTH_LABELS = [
|
||||||
const labelText = value.toLocaleDateString(undefined, {
|
'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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampToValidDay(
|
||||||
|
year: number,
|
||||||
|
month: number,
|
||||||
|
day: number,
|
||||||
|
min?: Date,
|
||||||
|
): Date {
|
||||||
|
const maxDay = daysInMonth(year, month);
|
||||||
|
let next = buildLocalDay(year, month, Math.min(Math.max(1, day), maxDay));
|
||||||
|
if (min) {
|
||||||
|
const floor = startOfLocalDay(min);
|
||||||
|
if (compareLocalDayStart(next, floor) < 0) {
|
||||||
|
next = floor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function yearRange(min?: Date, anchor?: Date): number[] {
|
||||||
|
const now = new Date();
|
||||||
|
const startYear = min ? min.getFullYear() : now.getFullYear() - 5;
|
||||||
|
const endYear = Math.max(now.getFullYear() + 2, anchor?.getFullYear() ?? now.getFullYear());
|
||||||
|
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
|
||||||
|
disabled:opacity-50 disabled:cursor-not-allowed
|
||||||
|
`;
|
||||||
|
|
||||||
|
export function ScheduleDayPicker({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
minDate,
|
||||||
|
label = 'Schedule date',
|
||||||
|
}: ScheduleDayPickerProps) {
|
||||||
|
const panelId = useId();
|
||||||
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
|
|
||||||
|
const normalizedValue = startOfLocalDay(value);
|
||||||
|
const normalizedMin = minDate ? startOfLocalDay(minDate) : undefined;
|
||||||
|
|
||||||
|
const labelText = normalizedValue.toLocaleDateString(undefined, {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const previousDay = addCalendarDays(normalizedValue, -1);
|
||||||
|
const canGoPrevious =
|
||||||
|
!normalizedMin || compareLocalDayStart(previousDay, normalizedMin) >= 0;
|
||||||
|
|
||||||
|
const years = yearRange(normalizedMin, normalizedValue);
|
||||||
|
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) {
|
||||||
|
onChange(clampToValidDay(year, month, day, normalizedMin));
|
||||||
|
if (closePanel) {
|
||||||
|
setPanelOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePreviousDay() {
|
||||||
|
if (!canGoPrevious) return;
|
||||||
|
onChange(previousDay);
|
||||||
|
}
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-md">
|
<div ref={rootRef} className="relative w-full max-w-md">
|
||||||
<p className="text-sm font-medium text-text-secondary mb-2">{label}</p>
|
<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)]">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(addCalendarDays(value, -1))}
|
onClick={handlePreviousDay}
|
||||||
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"
|
disabled={!canGoPrevious}
|
||||||
|
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 disabled:opacity-40 disabled:pointer-events-none"
|
||||||
aria-label="Previous day"
|
aria-label="Previous day"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="h-4 w-4 icon-flat" />
|
<ChevronLeft className="h-4 w-4 icon-flat" />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 min-w-0 text-center text-sm font-medium text-text-primary tabular-nums px-2 py-1.5">
|
|
||||||
{labelText}
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(addCalendarDays(value, 1))}
|
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))}
|
||||||
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"
|
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"
|
aria-label="Next day"
|
||||||
>
|
>
|
||||||
<ChevronRight className="h-4 w-4 icon-flat" />
|
<ChevronRight className="h-4 w-4 icon-flat" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{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}
|
||||||
|
>
|
||||||
|
{MONTH_LABELS.map((name, index) => {
|
||||||
|
const disabled =
|
||||||
|
normalizedMin &&
|
||||||
|
selectedYear === normalizedMin.getFullYear() &&
|
||||||
|
index < normalizedMin.getMonth();
|
||||||
|
return (
|
||||||
|
<option key={name} value={index} disabled={disabled}>
|
||||||
|
{name}
|
||||||
|
</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}-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}
|
||||||
|
>
|
||||||
|
{Array.from({ length: dayCount }, (_, i) => i + 1).map((day) => {
|
||||||
|
const disabled =
|
||||||
|
normalizedMin &&
|
||||||
|
selectedYear === normalizedMin.getFullYear() &&
|
||||||
|
selectedMonth === normalizedMin.getMonth() &&
|
||||||
|
day < normalizedMin.getDate();
|
||||||
|
return (
|
||||||
|
<option key={day} value={day} disabled={disabled}>
|
||||||
|
{day}
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user