Files
temetro/frontend/components/appointments/add-appointment-dialog.tsx
T
Khalid Abdi e0de20b551 i18n: convert feature pages, dialogs, auth sub-pages and nav to t()
Move all hardcoded UI strings in the appointments, prescriptions, tasks,
messages, activity, analysis and patients views (plus their dialogs and
detail sheets), the remaining auth pages (verify-email, forgot/reset
password, accept-invite, onboarding), the clinic form and the sidebar user
menu into i18next keys in en/translation.json. Clinical option values
(appointment types, frequencies) stay canonical. English-only; no new
locale yet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-07 21:57:00 +03:00

315 lines
10 KiB
TypeScript

"use client";
import { CalendarDays, Search } 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 {
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 { 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";
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 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.
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 [query, setQuery] = useState("");
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("");
// Load patients lazily when the dialog opens (for the quick search).
useEffect(() => {
if (!open) return;
let active = true;
listPatients()
.then((data) => {
if (active) setPatients(data);
})
.catch(() => {
/* search just stays empty */
});
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 reset = () => {
setQuery("");
setSelected(null);
setDate(new Date(`${TODAY}T00:00:00`));
setDateOpen(false);
setTime("09:00");
setType(TYPES[0]);
setProvider("");
};
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);
setQuery("");
}}
size="sm"
type="button"
variant="ghost"
>
{t("appointments.dialog.change")}
</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>
)}
</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
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")}>
<Input
onChange={(event) => setProvider(event.target.value)}
placeholder={t("appointments.dialog.providerPlaceholder")}
value={provider}
/>
</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>
);
}