mirror of
https://github.com/temetro/temetro.git
synced 2026-08-05 16:37:42 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user