From 91fbe4129f29e324c5ca371703e68d0000269178 Mon Sep 17 00:00:00 2001 From: Khalid Abdi Date: Sun, 14 Jun 2026 19:38:26 +0300 Subject: [PATCH] frontend: keyboard combobox for appointments + clickable edit sheet - new reusable (Base UI Autocomplete): arrow-key + Enter navigation - New Appointment dialog: patient search and provider are now searchable comboboxes (provider sourced from /api/staff/providers) instead of a hand- rolled dropdown and a free-text input - appointment rows are clickable and open an to edit date/time/type/provider/status or delete; "Added by AI" badge shown on rows and in the sheet for source="ai" records Co-Authored-By: Claude Opus 4.8 --- .../appointments/add-appointment-dialog.tsx | 137 ++++---- .../appointments/appointment-detail-sheet.tsx | 311 ++++++++++++++++++ .../appointments/appointments-view.tsx | 70 +++- frontend/components/ui/combobox.tsx | 79 +++++ frontend/lib/i18n/locales/en/translation.json | 20 ++ 5 files changed, 543 insertions(+), 74 deletions(-) create mode 100644 frontend/components/appointments/appointment-detail-sheet.tsx create mode 100644 frontend/components/ui/combobox.tsx diff --git a/frontend/components/appointments/add-appointment-dialog.tsx b/frontend/components/appointments/add-appointment-dialog.tsx index 0e609ef..3bf4b00 100644 --- a/frontend/components/appointments/add-appointment-dialog.tsx +++ b/frontend/components/appointments/add-appointment-dialog.tsx @@ -1,12 +1,13 @@ "use client"; -import { CalendarDays, Search } from "lucide-react"; +import { CalendarDays } from "lucide-react"; import { type FormEvent, type ReactNode, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { TODAY } from "@/components/appointments/appointments-view"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; import { Dialog, DialogClose, @@ -20,6 +21,7 @@ import { import { Input } from "@/components/ui/input"; import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover"; import { listPatients, type Patient } from "@/lib/patients"; +import { listProviders, type Provider } from "@/lib/staff"; import { notify } from "@/lib/toast"; export type NewAppointment = { @@ -58,9 +60,9 @@ function Field({ label, children }: { label: string; children: ReactNode }) { ); } -// Compact "New appointment" dialog. The patient is chosen via a quick search by -// name or file number; the rest is the slot. The new entry is handed back to the -// page via onAdd, which persists it through the appointments API. +// Compact "New appointment" dialog. The patient and provider are chosen via +// searchable comboboxes (arrow keys + Enter); the rest is the slot. The new +// entry is handed back to the page via onAdd, which persists it through the API. export function AddAppointmentDialog({ open, onOpenChange, @@ -72,49 +74,77 @@ export function AddAppointmentDialog({ }) { const { t } = useTranslation(); const [patients, setPatients] = useState([]); - const [query, setQuery] = useState(""); + const [providers, setProviders] = useState([]); const [selected, setSelected] = useState(null); const [date, setDate] = useState(() => new Date(`${TODAY}T00:00:00`)); const [dateOpen, setDateOpen] = useState(false); const [time, setTime] = useState("09:00"); const [type, setType] = useState(TYPES[0]); const [provider, setProvider] = useState(""); + const [providerQuery, setProviderQuery] = useState(""); - // Load patients lazily when the dialog opens (for the quick search). + // Load patients + providers lazily when the dialog opens (for the searches). useEffect(() => { if (!open) return; let active = true; listPatients() - .then((data) => { - if (active) setPatients(data); - }) + .then((data) => active && setPatients(data)) .catch(() => { /* search just stays empty */ }); + listProviders() + .then((data) => active && setProviders(data)) + .catch(() => { + /* falls back to the patient's PCP on submit */ + }); return () => { active = false; }; }, [open]); - const matches = useMemo(() => { - const q = query.trim().toLowerCase(); - if (!q) return []; - return patients - .filter( - (p) => - p.name.toLowerCase().includes(q) || p.fileNumber.includes(q), - ) - .slice(0, 6); - }, [patients, query]); + const patientOptions = useMemo( + () => + patients.map((p) => ({ + value: p.fileNumber, + // Fold the file number into the label so it's type-to-searchable. + label: `${p.name} ${p.fileNumber}`, + node: ( + + {p.name} + + #{p.fileNumber} + + + ), + })), + [patients], + ); + + const providerOptions = useMemo( + () => + providers.map((pr) => ({ + value: pr.name, + label: pr.name, + node: ( + + {pr.name} + + {pr.role} + + + ), + })), + [providers], + ); const reset = () => { - setQuery(""); setSelected(null); setDate(new Date(`${TODAY}T00:00:00`)); setDateOpen(false); setTime("09:00"); setType(TYPES[0]); setProvider(""); + setProviderQuery(""); }; const submit = (event: FormEvent) => { @@ -175,10 +205,7 @@ export function AddAppointmentDialog({ ) : ( -
-
- - setQuery(event.target.value)} - placeholder={t("appointments.dialog.searchPlaceholder")} - value={query} - /> -
- {query.trim() && ( -
- {matches.length === 0 ? ( -

- {t("appointments.dialog.noPatients")} -

- ) : ( - matches.map((p) => ( - - )) - )} -
- )} -
+ { + const p = patients.find((x) => x.fileNumber === fileNumber); + if (p) setSelected(p); + }} + options={patientOptions} + placeholder={t("appointments.dialog.searchPlaceholder")} + /> )} @@ -290,10 +287,16 @@ export function AddAppointmentDialog({ - setProvider(event.target.value)} + { + setProvider(name); + setProviderQuery(name); + }} + onValueChange={setProviderQuery} + options={providerOptions} placeholder={t("appointments.dialog.providerPlaceholder")} - value={provider} + value={providerQuery} /> diff --git a/frontend/components/appointments/appointment-detail-sheet.tsx b/frontend/components/appointments/appointment-detail-sheet.tsx new file mode 100644 index 0000000..ca74645 --- /dev/null +++ b/frontend/components/appointments/appointment-detail-sheet.tsx @@ -0,0 +1,311 @@ +"use client"; + +import { CalendarDays, Trash2 } from "lucide-react"; +import { type ReactNode, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { AiBadge } from "@/components/ai-badge"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; +import { Input } from "@/components/ui/input"; +import { Popover, PopoverPopup, PopoverTrigger } from "@/components/ui/popover"; +import { + Sheet, + SheetFooter, + SheetHeader, + SheetPanel, + SheetPopup, + SheetTitle, +} from "@/components/ui/sheet"; +import { + type Appointment, + type AppointmentStatus, + deleteAppointment, + updateAppointment, +} from "@/lib/appointments"; +import { listProviders, type Provider } from "@/lib/staff"; +import { notify } from "@/lib/toast"; + +const TYPES = [ + "Follow-up", + "New patient", + "Consultation", + "Lab review", + "Vaccination", +]; + +const STATUSES: AppointmentStatus[] = [ + "confirmed", + "checked-in", + "completed", + "cancelled", +]; + +const controlClass = + "h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30"; + +// Local-date ISO key (avoids UTC drift from toISOString). +const keyOf = (d: Date) => + `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String( + d.getDate(), + ).padStart(2, "0")}`; + +function Field({ label, children }: { label: string; children: ReactNode }) { + return ( + + ); +} + +// Right-side Sheet for reviewing and editing a single appointment — opened by +// clicking a row in the schedule. Editing here is intentional (AI-drafted rows +// often need their placeholders filled in). Persists via the appointments API. +export function AppointmentDetailSheet({ + appt, + open, + onOpenChange, + onSaved, + onDeleted, +}: { + appt: Appointment | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSaved: (updated: Appointment) => void; + onDeleted: (id: string) => void; +}) { + const { t } = useTranslation(); + const [providers, setProviders] = useState([]); + const [date, setDate] = useState(() => new Date()); + const [dateOpen, setDateOpen] = useState(false); + const [time, setTime] = useState("09:00"); + const [type, setType] = useState(TYPES[0]); + const [provider, setProvider] = useState(""); + const [status, setStatus] = useState("confirmed"); + const [busy, setBusy] = useState(false); + + // Seed the form from the selected appointment whenever it changes. + useEffect(() => { + if (!appt) return; + setDate(new Date(`${appt.date}T00:00:00`)); + setTime(appt.time); + setType(appt.type); + setProvider(appt.provider); + setStatus(appt.status); + }, [appt]); + + useEffect(() => { + if (!open) return; + let active = true; + listProviders() + .then((data) => active && setProviders(data)) + .catch(() => { + /* provider combobox just stays empty; free-text is preserved */ + }); + return () => { + active = false; + }; + }, [open]); + + const providerOptions = useMemo( + () => + providers.map((pr) => ({ + value: pr.name, + label: pr.name, + keywords: pr.role, + })), + [providers], + ); + + const save = async () => { + if (!appt) return; + setBusy(true); + try { + const updated = await updateAppointment(appt.id, { + fileNumber: appt.fileNumber, + name: appt.name, + initials: appt.initials, + date: keyOf(date), + time, + type, + provider, + status, + }); + onSaved(updated); + notify.success( + t("appointments.sheet.savedTitle"), + t("appointments.sheet.savedBody"), + ); + onOpenChange(false); + } catch { + notify.error( + t("appointments.sheet.saveFailedTitle"), + t("appointments.sheet.saveFailedBody"), + ); + } finally { + setBusy(false); + } + }; + + const remove = async () => { + if (!appt) return; + setBusy(true); + try { + await deleteAppointment(appt.id); + onDeleted(appt.id); + notify.success(t("appointments.sheet.deletedTitle"), appt.name); + onOpenChange(false); + } catch { + notify.error( + t("appointments.sheet.deleteFailedTitle"), + t("appointments.sheet.deleteFailedBody"), + ); + } finally { + setBusy(false); + } + }; + + return ( + + + + + {appt?.name ?? t("appointments.sheet.title")} + + +

+ {t("appointments.sheet.editHint")} +

+
+ + + {appt && ( +
+
+ + {appt.initials} + +
+ + {appt.name} + + + {t("appointments.dialog.fileNumber", { + number: appt.fileNumber || "—", + })} + +
+
+ +
+
+ + {t("appointments.sheet.date")} + + + + + {date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + + } + /> + + { + if (d) { + setDate(d); + setDateOpen(false); + } + }} + selected={date} + /> + + +
+ + setTime(event.target.value)} + type="time" + value={time} + /> + +
+ + + + + + + + + + + + +
+ )} +
+ + + + + +
+
+ ); +} diff --git a/frontend/components/appointments/appointments-view.tsx b/frontend/components/appointments/appointments-view.tsx index 4bef873..e3d4b1c 100644 --- a/frontend/components/appointments/appointments-view.tsx +++ b/frontend/components/appointments/appointments-view.tsx @@ -12,6 +12,8 @@ import { import { type ReactNode, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; +import { AiBadge } from "@/components/ai-badge"; +import { AppointmentDetailSheet } from "@/components/appointments/appointment-detail-sheet"; import { AddAppointmentDialog, type NewAppointment, @@ -28,6 +30,7 @@ import { listAppointments, } from "@/lib/appointments"; import { notify } from "@/lib/toast"; +import { cn } from "@/lib/utils"; export type { Appointment } from "@/lib/appointments"; @@ -98,10 +101,35 @@ function Kpi({ ); } -function ApptRow({ appt }: { appt: Appointment }) { +function ApptRow({ + appt, + onOpen, +}: { + appt: Appointment; + onOpen?: (appt: Appointment) => void; +}) { const { t } = useTranslation(); + const interactive = Boolean(onOpen); return ( -
+
onOpen?.(appt) : undefined} + onKeyDown={ + interactive + ? (event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onOpen?.(appt); + } + } + : undefined + } + role={interactive ? "button" : undefined} + tabIndex={interactive ? 0 : undefined} + > {appt.time} @@ -116,6 +144,7 @@ function ApptRow({ appt }: { appt: Appointment }) { {appt.type} · {appt.provider}
+ {t(`appointments.status.${appt.status}`)} @@ -123,11 +152,17 @@ function ApptRow({ appt }: { appt: Appointment }) { ); } -export function ScheduleList({ items }: { items: Appointment[] }) { +export function ScheduleList({ + items, + onOpen, +}: { + items: Appointment[]; + onOpen?: (appt: Appointment) => void; +}) { return (
{items.map((appt) => ( - + ))}
); @@ -161,6 +196,13 @@ export function AppointmentsView() { const [calendarOpen, setCalendarOpen] = useState(false); const [appointments, setAppointments] = useState([]); const [query, setQuery] = useState(""); + const [selectedAppt, setSelectedAppt] = useState(null); + const [sheetOpen, setSheetOpen] = useState(false); + + const openAppt = (appt: Appointment) => { + setSelectedAppt(appt); + setSheetOpen(true); + }; useEffect(() => { let active = true; @@ -297,7 +339,7 @@ export function AppointmentsView() { results.length > 0 ? ( results.map((group) => (
- +
)) ) : ( @@ -315,7 +357,7 @@ export function AppointmentsView() {
{todayItems.length > 0 ? ( - + ) : (

{t("appointments.nothingToday")} @@ -325,7 +367,7 @@ export function AppointmentsView() { {upcoming.map((group) => (

- +
))} @@ -342,6 +384,20 @@ export function AppointmentsView() { onOpenChange={setCalendarOpen} open={calendarOpen} /> + + + setAppointments((prev) => prev.filter((a) => a.id !== id)) + } + onOpenChange={setSheetOpen} + onSaved={(updated) => + setAppointments((prev) => + prev.map((a) => (a.id === updated.id ? updated : a)), + ) + } + open={sheetOpen} + />
); } diff --git a/frontend/components/ui/combobox.tsx b/frontend/components/ui/combobox.tsx new file mode 100644 index 0000000..f8ac171 --- /dev/null +++ b/frontend/components/ui/combobox.tsx @@ -0,0 +1,79 @@ +"use client"; + +import type { ReactNode } from "react"; + +import { + Autocomplete, + AutocompleteEmpty, + AutocompleteInput, + AutocompleteItem, + AutocompleteList, + AutocompletePopup, +} from "@/components/ui/autocomplete"; + +export type ComboboxOption = { + // Stable value handed back to onSelect. + value: string; + // Text used both for type-to-filter matching and the committed input text. + // Fold any extra searchable terms (e.g. a file number) in here. + label: string; + // Optional rich row content for the dropdown; falls back to `label`. + node?: ReactNode; +}; + +// A searchable single-select built on Base UI's Autocomplete, so arrow-key +// navigation and Enter-to-select work out of the box (same primitive as the ⌘K +// command palette). Filtering matches the option `label`. `onSelect` fires for +// both mouse clicks and keyboard Enter. +export function Combobox({ + options, + onSelect, + placeholder, + emptyText, + autoFocus, + value, + defaultValue, + onValueChange, + inputClassName, +}: { + options: ComboboxOption[]; + onSelect: (value: string) => void; + placeholder?: string; + emptyText?: string; + autoFocus?: boolean; + // Optionally control (value) or seed (defaultValue) the input text. + value?: string; + defaultValue?: string; + onValueChange?: (value: string) => void; + inputClassName?: string; +}) { + return ( + + + + {emptyText ? {emptyText} : null} + + {(opt: ComboboxOption) => ( + onSelect(opt.value)} + value={opt.label} + > + {opt.node ?? opt.label} + + )} + + + + ); +} diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json index 7edc59e..9938031 100644 --- a/frontend/lib/i18n/locales/en/translation.json +++ b/frontend/lib/i18n/locales/en/translation.json @@ -238,6 +238,7 @@ "change": "Change", "searchPlaceholder": "Search name or file number", "noPatients": "No patients found.", + "noProviders": "No doctors found.", "date": "Date", "time": "Time", "type": "Type", @@ -249,6 +250,25 @@ "pickPatientBody": "Search and select a patient first.", "addedTitle": "Appointment added" }, + "sheet": { + "title": "Appointment", + "editHint": "Click any appointment to edit — this is enabled.", + "date": "Date", + "time": "Time", + "type": "Type", + "provider": "Provider", + "status": "Status", + "save": "Save changes", + "saving": "Saving…", + "delete": "Delete", + "savedTitle": "Appointment updated", + "savedBody": "Your changes were saved.", + "saveFailedTitle": "Couldn't save", + "saveFailedBody": "Please try again.", + "deletedTitle": "Appointment deleted", + "deleteFailedTitle": "Couldn't delete", + "deleteFailedBody": "Please try again." + }, "calendarDialog": { "today": "Today", "appointmentCount_one": "{{count}} appointment",