frontend: keyboard combobox for appointments + clickable edit sheet

- new reusable <Combobox> (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 <AppointmentDetailSheet> 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 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-14 19:38:26 +03:00
parent 929bec8f31
commit 91fbe4129f
5 changed files with 543 additions and 74 deletions
@@ -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<Patient[]>([]);
const [query, setQuery] = useState("");
const [providers, setProviders] = useState<Provider[]>([]);
const [selected, setSelected] = useState<Patient | null>(null);
const [date, setDate] = useState<Date>(() => 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<ComboboxOption[]>(
() =>
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: (
<span className="flex w-full items-center justify-between gap-2">
<span className="truncate">{p.name}</span>
<span className="shrink-0 text-muted-foreground text-xs">
#{p.fileNumber}
</span>
</span>
),
})),
[patients],
);
const providerOptions = useMemo<ComboboxOption[]>(
() =>
providers.map((pr) => ({
value: pr.name,
label: pr.name,
node: (
<span className="flex w-full items-center justify-between gap-2">
<span className="truncate">{pr.name}</span>
<span className="shrink-0 text-muted-foreground text-xs capitalize">
{pr.role}
</span>
</span>
),
})),
[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({
</span>
</div>
<Button
onClick={() => {
setSelected(null);
setQuery("");
}}
onClick={() => setSelected(null)}
size="sm"
type="button"
variant="ghost"
@@ -187,46 +214,16 @@ export function AddAppointmentDialog({
</Button>
</div>
) : (
<div className="flex flex-col gap-1.5">
<div className="relative">
<Search className="-translate-y-1/2 absolute top-1/2 left-3 size-4 text-muted-foreground" />
<Input
autoFocus
className="pl-9"
onChange={(event) => setQuery(event.target.value)}
placeholder={t("appointments.dialog.searchPlaceholder")}
value={query}
/>
</div>
{query.trim() && (
<div className="max-h-56 overflow-y-auto rounded-2xl border bg-popover p-1">
{matches.length === 0 ? (
<p className="px-2 py-2 text-muted-foreground text-sm">
{t("appointments.dialog.noPatients")}
</p>
) : (
matches.map((p) => (
<button
className="flex w-full items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-accent"
key={p.fileNumber}
onClick={() => {
setSelected(p);
setQuery("");
}}
type="button"
>
<span className="truncate text-foreground text-sm">
{p.name}
</span>
<span className="shrink-0 text-muted-foreground text-xs">
#{p.fileNumber}
</span>
</button>
))
)}
</div>
)}
</div>
<Combobox
autoFocus
emptyText={t("appointments.dialog.noPatients")}
onSelect={(fileNumber) => {
const p = patients.find((x) => x.fileNumber === fileNumber);
if (p) setSelected(p);
}}
options={patientOptions}
placeholder={t("appointments.dialog.searchPlaceholder")}
/>
)}
</Field>
@@ -290,10 +287,16 @@ export function AddAppointmentDialog({
</select>
</Field>
<Field label={t("appointments.dialog.provider")}>
<Input
onChange={(event) => setProvider(event.target.value)}
<Combobox
emptyText={t("appointments.dialog.noProviders")}
onSelect={(name) => {
setProvider(name);
setProviderQuery(name);
}}
onValueChange={setProviderQuery}
options={providerOptions}
placeholder={t("appointments.dialog.providerPlaceholder")}
value={provider}
value={providerQuery}
/>
</Field>
</div>
@@ -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 (
<label className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">{label}</span>
{children}
</label>
);
}
// 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<Provider[]>([]);
const [date, setDate] = useState<Date>(() => 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<AppointmentStatus>("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<ComboboxOption[]>(
() =>
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 (
<Sheet onOpenChange={onOpenChange} open={open}>
<SheetPopup className="sm:max-w-md" side="right">
<SheetHeader>
<SheetTitle className="flex items-center gap-2">
{appt?.name ?? t("appointments.sheet.title")}
<AiBadge source={appt?.source} />
</SheetTitle>
<p className="text-muted-foreground text-xs">
{t("appointments.sheet.editHint")}
</p>
</SheetHeader>
<SheetPanel className="min-h-0 flex-1">
{appt && (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Avatar className="size-10">
<AvatarFallback>{appt.initials}</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium text-foreground text-sm">
{appt.name}
</span>
<span className="text-muted-foreground text-xs">
{t("appointments.dialog.fileNumber", {
number: appt.fileNumber || "—",
})}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-xs">
{t("appointments.sheet.date")}
</span>
<Popover onOpenChange={setDateOpen} open={dateOpen}>
<PopoverTrigger
render={
<Button
className="w-full justify-start font-normal"
type="button"
variant="outline"
>
<CalendarDays className="size-4" />
{date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})}
</Button>
}
/>
<PopoverPopup>
<Calendar
mode="single"
onSelect={(d) => {
if (d) {
setDate(d);
setDateOpen(false);
}
}}
selected={date}
/>
</PopoverPopup>
</Popover>
</div>
<Field label={t("appointments.sheet.time")}>
<Input
onChange={(event) => setTime(event.target.value)}
type="time"
value={time}
/>
</Field>
</div>
<Field label={t("appointments.sheet.type")}>
<select
className={controlClass}
onChange={(event) => setType(event.target.value)}
value={TYPES.includes(type) ? type : ""}
>
{/* Preserve a non-standard / placeholder type as an option. */}
{!TYPES.includes(type) && <option value="">{type}</option>}
{TYPES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</Field>
<Field label={t("appointments.sheet.provider")}>
<Combobox
emptyText={t("appointments.dialog.noProviders")}
onSelect={setProvider}
onValueChange={setProvider}
options={providerOptions}
placeholder={t("appointments.dialog.providerPlaceholder")}
value={provider}
/>
</Field>
<Field label={t("appointments.sheet.status")}>
<select
className={controlClass}
onChange={(event) =>
setStatus(event.target.value as AppointmentStatus)
}
value={status}
>
{STATUSES.map((option) => (
<option key={option} value={option}>
{t(`appointments.status.${option}`)}
</option>
))}
</select>
</Field>
</div>
)}
</SheetPanel>
<SheetFooter className="flex-row justify-between">
<Button
disabled={busy}
onClick={remove}
type="button"
variant="destructive"
>
<Trash2 className="size-4" />
{t("appointments.sheet.delete")}
</Button>
<Button disabled={busy} onClick={save} type="button">
{busy ? t("appointments.sheet.saving") : t("appointments.sheet.save")}
</Button>
</SheetFooter>
</SheetPopup>
</Sheet>
);
}
@@ -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 (
<div className="flex items-center gap-3 px-4 py-3">
<div
className={cn(
"flex items-center gap-3 px-4 py-3",
interactive && "cursor-pointer transition-colors hover:bg-accent/50",
)}
onClick={interactive ? () => 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}
>
<span className="w-12 shrink-0 font-medium text-foreground text-sm tabular-nums">
{appt.time}
</span>
@@ -116,6 +144,7 @@ function ApptRow({ appt }: { appt: Appointment }) {
{appt.type} · {appt.provider}
</span>
</div>
<AiBadge source={appt.source} />
<Badge variant={statusVariant[appt.status]}>
{t(`appointments.status.${appt.status}`)}
</Badge>
@@ -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 (
<div className="divide-y divide-border overflow-hidden rounded-2xl border bg-card/30">
{items.map((appt) => (
<ApptRow appt={appt} key={appt.id} />
<ApptRow appt={appt} key={appt.id} onOpen={onOpen} />
))}
</div>
);
@@ -161,6 +196,13 @@ export function AppointmentsView() {
const [calendarOpen, setCalendarOpen] = useState(false);
const [appointments, setAppointments] = useState<Appointment[]>([]);
const [query, setQuery] = useState("");
const [selectedAppt, setSelectedAppt] = useState<Appointment | null>(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) => (
<Section key={group.key} title={formatDayKey(group.key)}>
<ScheduleList items={group.items} />
<ScheduleList items={group.items} onOpen={openAppt} />
</Section>
))
) : (
@@ -315,7 +357,7 @@ export function AppointmentsView() {
<Section description={formatDayKey(TODAY)} title={t("appointments.today")}>
{todayItems.length > 0 ? (
<ScheduleList items={todayItems} />
<ScheduleList items={todayItems} onOpen={openAppt} />
) : (
<p className="rounded-2xl border border-dashed bg-card/20 px-4 py-8 text-center text-muted-foreground text-sm">
{t("appointments.nothingToday")}
@@ -325,7 +367,7 @@ export function AppointmentsView() {
{upcoming.map((group) => (
<Section key={group.key} title={formatDayKey(group.key)}>
<ScheduleList items={group.items} />
<ScheduleList items={group.items} onOpen={openAppt} />
</Section>
))}
</>
@@ -342,6 +384,20 @@ export function AppointmentsView() {
onOpenChange={setCalendarOpen}
open={calendarOpen}
/>
<AppointmentDetailSheet
appt={selectedAppt}
onDeleted={(id) =>
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}
/>
</div>
);
}
+79
View File
@@ -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 (
<Autocomplete
defaultValue={defaultValue}
items={options}
onValueChange={onValueChange}
value={value}
>
<AutocompleteInput
autoFocus={autoFocus}
className={inputClassName}
placeholder={placeholder}
showTrigger
/>
<AutocompletePopup>
{emptyText ? <AutocompleteEmpty>{emptyText}</AutocompleteEmpty> : null}
<AutocompleteList>
{(opt: ComboboxOption) => (
<AutocompleteItem
key={opt.value}
onClick={() => onSelect(opt.value)}
value={opt.label}
>
{opt.node ?? opt.label}
</AutocompleteItem>
)}
</AutocompleteList>
</AutocompletePopup>
</Autocomplete>
);
}
@@ -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",