mirror of
https://github.com/temetro/temetro.git
synced 2026-08-21 23:47:14 +00:00
e0de20b551
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>
315 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|