Files
temetro/frontend/components/appointments/add-appointment-dialog.tsx
T
Khalid Abdi 35f07c508d frontend: block past dates in appointment/invoice pickers; drop stale Settings Features
- Appointment date picker disables days before today.
- Invoice issue-date picker disables past days by default, with an opt-in
  "Back-date" checkbox for recording older invoices (edit mode keeps past
  dates). Added invoices.dialog.backdate to all locales.
- Removed the inert, outdated "Features" section (patient-owned storage /
  require signed toggles) from Settings and its now-unused i18n keys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:41:58 +03:00

327 lines
10 KiB
TypeScript

"use client";
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,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
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 = {
fileNumber: string;
name: string;
initials: string;
date: string; // ISO YYYY-MM-DD
time: string;
type: string;
provider: string;
};
// 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")}`;
const TYPES = [
"Follow-up",
"New patient",
"Consultation",
"Lab review",
"Vaccination",
];
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 start-of-day so the calendar can disable days strictly before today
// (appointments can't be scheduled in the past).
const startOfToday = () => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
};
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>
);
}
// 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,
onAdd,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onAdd: (appt: NewAppointment) => void;
}) {
const { t } = useTranslation();
const [patients, setPatients] = useState<Patient[]>([]);
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 + providers lazily when the dialog opens (for the searches).
useEffect(() => {
if (!open) return;
let active = true;
listPatients()
.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 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 = () => {
setSelected(null);
setDate(new Date(`${TODAY}T00:00:00`));
setDateOpen(false);
setTime("09:00");
setType(TYPES[0]);
setProvider("");
setProviderQuery("");
};
const submit = (event: FormEvent) => {
event.preventDefault();
if (!selected) {
notify.error(
t("appointments.dialog.pickPatientTitle"),
t("appointments.dialog.pickPatientBody"),
);
return;
}
onAdd({
fileNumber: selected.fileNumber,
name: selected.name,
initials: selected.initials,
date: keyOf(date),
time,
type,
provider: provider.trim() || selected.pcp,
});
notify.success(
t("appointments.dialog.addedTitle"),
`${selected.name} · ${time}`,
);
reset();
onOpenChange(false);
};
return (
<Dialog
onOpenChange={(o) => {
onOpenChange(o);
if (!o) reset();
}}
open={open}
>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("appointments.dialog.title")}</DialogTitle>
<DialogDescription>
{t("appointments.dialog.description")}
</DialogDescription>
</DialogHeader>
<form className="contents" onSubmit={submit}>
<DialogPanel className="flex flex-col gap-4">
<Field label={t("appointments.dialog.patient")}>
{selected ? (
<div className="flex items-center justify-between gap-2 rounded-2xl border bg-input/30 px-3 py-2">
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium text-foreground text-sm">
{selected.name}
</span>
<span className="text-muted-foreground text-xs">
{t("appointments.dialog.fileNumber", {
number: selected.fileNumber,
})}
</span>
</div>
<Button
onClick={() => setSelected(null)}
size="sm"
type="button"
variant="ghost"
>
{t("appointments.dialog.change")}
</Button>
</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>
<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.dialog.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
disabled={{ before: startOfToday() }}
mode="single"
onSelect={(d) => {
if (d) {
setDate(d);
setDateOpen(false);
}
}}
selected={date}
/>
</PopoverPopup>
</Popover>
</div>
<Field label={t("appointments.dialog.time")}>
<Input
onChange={(event) => setTime(event.target.value)}
type="time"
value={time}
/>
</Field>
</div>
<div className="grid grid-cols-2 gap-3">
<Field label={t("appointments.dialog.type")}>
<select
className={controlClass}
onChange={(event) => setType(event.target.value)}
value={type}
>
{TYPES.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</Field>
<Field label={t("appointments.dialog.provider")}>
<Combobox
emptyText={t("appointments.dialog.noProviders")}
onSelect={(name) => {
setProvider(name);
setProviderQuery(name);
}}
onValueChange={setProviderQuery}
options={providerOptions}
placeholder={t("appointments.dialog.providerPlaceholder")}
value={providerQuery}
/>
</Field>
</div>
</DialogPanel>
<DialogFooter>
<DialogClose render={<Button type="button" variant="outline" />}>
{t("appointments.dialog.cancel")}
</DialogClose>
<Button disabled={!selected} type="submit">
{t("appointments.dialog.submit")}
</Button>
</DialogFooter>
</form>
</DialogPopup>
</Dialog>
);
}