Files
temetro/frontend/components/chat/patient-form-dialog.tsx
T
Khalid Abdi 2c5624e049 Add COSS toast notifications and swap in the new logo
Wire COSS's (Base UI) Sonner-style stacked toast into the app:

- Install @coss/toast (components/ui/toast.tsx) and mount <ToastProvider> in
  the root layout; add a small notify.{success,error,info,warning} helper
  (lib/toast.ts) over toastManager so callers don't repeat the toast shape.
- Toast on the events requested: clinic created (onboarding), auth actions
  (sign in / sign up / sign out / password reset request + reset), patient
  saved (create/edit), and all failure branches as error toasts (kept inline
  errors as a fallback). New auth toast strings added to the en translation.
- Replace the logo everywhere: overwrite public/temetro-logo.png with the new
  1024² square mark (same aspect ratio as the old 500², so existing 32×32
  next/image usages stay undistorted; no reference changes needed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 18:58:04 +03:00

606 lines
20 KiB
TypeScript

"use client";
import { CalendarIcon, Plus, RefreshCw, X } from "lucide-react";
import { type FormEvent, type ReactNode, useState } from "react";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverPopup,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import {
type AllergySeverity,
createPatient,
generateFileNumber,
type LabFlag,
type Patient,
updatePatient,
} from "@/lib/patients";
import { notify } from "@/lib/toast";
type PatientFormDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
mode: "create" | "edit";
patient?: Patient;
onCreated?: (fileNumber: string) => void;
onSaved?: (patient: Patient) => void;
};
type AllergyDraft = { substance: string; reaction: string; severity: AllergySeverity };
type MedicationDraft = { name: string; dose: string; frequency: string };
type ProblemDraft = { label: string; since: string };
type LabDraft = { name: string; value: string; flag: LabFlag; takenAt: string };
type VisitDraft = { type: string; date: string; provider: string; summary: string };
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-xs text-muted-foreground">{label}</span>
{children}
</label>
);
}
function SectionList<T>({
label,
rows,
blank,
onChange,
render,
}: {
label: string;
rows: T[];
blank: T;
onChange: (rows: T[]) => void;
render: (row: T, set: (patch: Partial<T>) => void) => ReactNode;
}) {
return (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{label}
</span>
<Button
onClick={() => onChange([...rows, blank])}
size="sm"
type="button"
variant="ghost"
>
<Plus className="size-4" />
Add
</Button>
</div>
{rows.map((row, index) => (
<div className="flex items-center gap-2" key={index}>
{render(row, (patch) =>
onChange(rows.map((r, i) => (i === index ? { ...r, ...patch } : r)))
)}
<button
aria-label={`Remove ${label} row`}
className="shrink-0 text-muted-foreground transition-colors hover:text-foreground"
onClick={() => onChange(rows.filter((_, i) => i !== index))}
type="button"
>
<X className="size-4" />
</button>
</div>
))}
</div>
);
}
function initialsFromName(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) {
return "?";
}
return parts
.slice(0, 2)
.map((part) => part[0])
.join("")
.toUpperCase();
}
const formatDate = (date: Date) =>
date.toLocaleDateString("en-US", {
month: "short",
day: "2-digit",
year: "numeric",
});
const today = () => formatDate(new Date());
// Patient dates are stored as formatted strings (e.g. "Jun 02, 2026"); parse one
// back to a Date so the calendar can highlight the current selection.
function parseDate(value: string): Date | undefined {
const trimmed = value.trim();
if (!trimmed) {
return undefined;
}
const parsed = new Date(trimmed);
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
}
// Calendar-backed date field that reads/writes the same formatted string the rest
// of the form uses.
function DatePicker({
value,
onChange,
ariaLabel,
placeholder = "Pick a date",
className,
}: {
value: string;
onChange: (value: string) => void;
ariaLabel: string;
placeholder?: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger
render={
<Button
aria-label={ariaLabel}
className={cn(
"justify-start font-normal",
!value && "text-muted-foreground",
className
)}
type="button"
variant="outline"
/>
}
>
<CalendarIcon className="size-4" />
<span className="truncate">{value || placeholder}</span>
</PopoverTrigger>
<PopoverPopup className="w-auto p-0">
<Calendar
mode="single"
onSelect={(date) => {
onChange(date ? formatDate(date) : "");
setOpen(false);
}}
selected={parseDate(value)}
/>
</PopoverPopup>
</Popover>
);
}
export function PatientFormDialog({
open,
onOpenChange,
mode,
patient,
onCreated,
onSaved,
}: PatientFormDialogProps) {
const isEdit = mode === "edit";
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [fileNumber, setFileNumber] = useState(() =>
isEdit && patient ? patient.fileNumber : generateFileNumber()
);
const [name, setName] = useState(patient?.name ?? "");
const [age, setAge] = useState(patient ? String(patient.age) : "");
const [sex, setSex] = useState<Patient["sex"]>(patient?.sex ?? "F");
const [status, setStatus] = useState<Patient["status"]>(
patient?.status ?? "active"
);
const [pcp, setPcp] = useState(patient?.pcp ?? "");
const [bp, setBp] = useState(patient?.vitals.bp ?? "");
const [hr, setHr] = useState(patient?.vitals.hr ?? "");
const [temp, setTemp] = useState(patient?.vitals.temp ?? "");
const [spo2, setSpo2] = useState(patient?.vitals.spo2 ?? "");
const [allergies, setAllergies] = useState<AllergyDraft[]>(
() => patient?.allergies.map((a) => ({ ...a })) ?? []
);
const [medications, setMedications] = useState<MedicationDraft[]>(
() => patient?.medications.map((m) => ({ ...m })) ?? []
);
const [problems, setProblems] = useState<ProblemDraft[]>(
() => patient?.problems.map((p) => ({ ...p })) ?? []
);
const [labs, setLabs] = useState<LabDraft[]>(
() => patient?.labs.map((l) => ({ ...l })) ?? []
);
const [visits, setVisits] = useState<VisitDraft[]>(
() =>
patient?.encounters.map((e) => ({
type: e.type,
date: e.date,
provider: e.provider,
summary: e.summary,
})) ?? []
);
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!name.trim() || submitting) {
return;
}
const built: Patient = {
fileNumber,
name: name.trim(),
age: Number(age) || 0,
sex,
pcp: pcp.trim() || "—",
status,
initials: initialsFromName(name),
allergies: allergies.filter((a) => a.substance.trim()),
alerts: patient?.alerts ?? [],
medications: medications.filter((m) => m.name.trim()),
problems: problems.filter((p) => p.label.trim()),
vitals: {
bp: bp.trim() || "—",
hr: hr.trim() || "—",
temp: temp.trim() || "—",
spo2: spo2.trim() || "—",
takenAt: isEdit ? (patient?.vitals.takenAt ?? today()) : today(),
},
vitalsTrend: patient?.vitalsTrend ?? {
label: "Heart rate",
unit: "bpm",
points: [],
},
labs: labs
.filter((l) => l.name.trim())
.map((l) => ({ ...l, takenAt: l.takenAt.trim() || today() })),
labTrend: patient?.labTrend ?? { label: "—", unit: "", points: [] },
encounters: visits
.filter((v) => v.type.trim() || v.summary.trim())
.map((v) => ({
type: v.type.trim() || "Visit",
date: v.date.trim() || today(),
provider: v.provider,
summary: v.summary,
})),
};
setSubmitting(true);
setError(null);
try {
const saved = isEdit
? await updatePatient(built)
: await createPatient(built);
if (isEdit) {
onSaved?.(saved);
notify.success("Record updated", `${saved.name}'s chart was saved.`);
} else {
onCreated?.(saved.fileNumber);
notify.success("Patient added", `${saved.name} (${saved.fileNumber}).`);
}
onOpenChange(false);
} catch (err) {
const message =
err instanceof Error ? err.message : "Could not save the patient.";
setError(message);
notify.error("Couldn't save patient", message);
} finally {
setSubmitting(false);
}
};
return (
<Dialog onOpenChange={onOpenChange} open={open}>
<DialogPopup className="max-h-[85dvh] sm:max-w-lg">
<DialogHeader>
<DialogTitle>{isEdit ? "Edit record" : "Add patient"}</DialogTitle>
<DialogDescription>
{isEdit
? `Update ${patient?.name ?? "this"}'s chart and add new data.`
: "Create a new chart. A file number has been generated for you."}
</DialogDescription>
</DialogHeader>
<form className="contents" onSubmit={handleSubmit}>
<DialogPanel
scrollFade={false}
className="no-scrollbar flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto"
>
<Field label="File number">
<div className="flex items-center gap-2">
<Input readOnly value={fileNumber} />
{!isEdit && (
<Button
aria-label="Regenerate file number"
onClick={() => setFileNumber(generateFileNumber())}
size="icon"
type="button"
variant="outline"
>
<RefreshCw className="size-4" />
</Button>
)}
</div>
</Field>
<Field label="Full name">
<Input
autoFocus={!isEdit}
onChange={(event) => setName(event.target.value)}
placeholder="e.g. Jordan Pierce"
required
value={name}
/>
</Field>
<div className="grid grid-cols-3 gap-3">
<Field label="Age">
<Input
inputMode="numeric"
onChange={(event) => setAge(event.target.value)}
placeholder="—"
value={age}
/>
</Field>
<Field label="Sex">
<select
className={controlClass}
onChange={(event) =>
setSex(event.target.value as Patient["sex"])
}
value={sex}
>
<option value="F">Female</option>
<option value="M">Male</option>
</select>
</Field>
<Field label="Status">
<select
className={controlClass}
onChange={(event) =>
setStatus(event.target.value as Patient["status"])
}
value={status}
>
<option value="active">Active</option>
<option value="inpatient">Inpatient</option>
<option value="discharged">Discharged</option>
</select>
</Field>
</div>
<Field label="Primary care">
<Input
onChange={(event) => setPcp(event.target.value)}
placeholder="e.g. Dr. Lena Ortiz"
value={pcp}
/>
</Field>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
Current vitals
</span>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Input
aria-label="Blood pressure"
onChange={(event) => setBp(event.target.value)}
placeholder="BP"
value={bp}
/>
<Input
aria-label="Heart rate"
onChange={(event) => setHr(event.target.value)}
placeholder="HR"
value={hr}
/>
<Input
aria-label="Temperature"
onChange={(event) => setTemp(event.target.value)}
placeholder="Temp"
value={temp}
/>
<Input
aria-label="Oxygen saturation"
onChange={(event) => setSpo2(event.target.value)}
placeholder="SpO₂"
value={spo2}
/>
</div>
</div>
<SectionList<AllergyDraft>
blank={{ substance: "", reaction: "", severity: "mild" }}
label="Allergies"
onChange={setAllergies}
render={(row, set) => (
<>
<Input
aria-label="Substance"
onChange={(event) => set({ substance: event.target.value })}
placeholder="Substance"
value={row.substance}
/>
<Input
aria-label="Reaction"
onChange={(event) => set({ reaction: event.target.value })}
placeholder="Reaction"
value={row.reaction}
/>
<select
aria-label="Severity"
className={cn(controlClass, "w-auto")}
onChange={(event) =>
set({ severity: event.target.value as AllergySeverity })
}
value={row.severity}
>
<option value="mild">Mild</option>
<option value="moderate">Moderate</option>
<option value="severe">Severe</option>
</select>
</>
)}
rows={allergies}
/>
<SectionList<MedicationDraft>
blank={{ name: "", dose: "", frequency: "" }}
label="Medications"
onChange={setMedications}
render={(row, set) => (
<>
<Input
aria-label="Medication name"
onChange={(event) => set({ name: event.target.value })}
placeholder="Name"
value={row.name}
/>
<Input
aria-label="Dose"
onChange={(event) => set({ dose: event.target.value })}
placeholder="Dose"
value={row.dose}
/>
<Input
aria-label="Frequency"
onChange={(event) => set({ frequency: event.target.value })}
placeholder="Frequency"
value={row.frequency}
/>
</>
)}
rows={medications}
/>
<SectionList<ProblemDraft>
blank={{ label: "", since: "" }}
label="Problems"
onChange={setProblems}
render={(row, set) => (
<>
<Input
aria-label="Problem"
onChange={(event) => set({ label: event.target.value })}
placeholder="Diagnosis"
value={row.label}
/>
<DatePicker
ariaLabel="Since"
className="w-40 shrink-0"
onChange={(since) => set({ since })}
placeholder="Since"
value={row.since}
/>
</>
)}
rows={problems}
/>
<SectionList<LabDraft>
blank={{ name: "", value: "", flag: "normal", takenAt: "" }}
label="Labs"
onChange={setLabs}
render={(row, set) => (
<>
<Input
aria-label="Lab name"
onChange={(event) => set({ name: event.target.value })}
placeholder="Test"
value={row.name}
/>
<Input
aria-label="Value"
onChange={(event) => set({ value: event.target.value })}
placeholder="Value"
value={row.value}
/>
<select
aria-label="Flag"
className={cn(controlClass, "w-auto")}
onChange={(event) => set({ flag: event.target.value as LabFlag })}
value={row.flag}
>
<option value="normal">Normal</option>
<option value="low">Low</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</>
)}
rows={labs}
/>
<SectionList<VisitDraft>
blank={{ type: "", date: "", provider: "", summary: "" }}
label="Visits"
onChange={setVisits}
render={(row, set) => (
<div className="flex w-full flex-col gap-2">
<div className="flex items-center gap-2">
<Input
aria-label="Visit type"
onChange={(event) => set({ type: event.target.value })}
placeholder="Type"
value={row.type}
/>
<DatePicker
ariaLabel="Visit date"
className="w-40 shrink-0"
onChange={(date) => set({ date })}
placeholder="Date"
value={row.date}
/>
</div>
<Input
aria-label="Provider"
onChange={(event) => set({ provider: event.target.value })}
placeholder="Provider"
value={row.provider}
/>
<Input
aria-label="Summary"
onChange={(event) => set({ summary: event.target.value })}
placeholder="Summary"
value={row.summary}
/>
</div>
)}
rows={visits}
/>
</DialogPanel>
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:items-center">
{error && (
<p className="text-sm text-destructive sm:mr-auto">{error}</p>
)}
<DialogClose render={<Button type="button" variant="outline" />}>
Cancel
</DialogClose>
<Button disabled={!name.trim() || submitting} type="submit">
{submitting ? "Saving…" : isEdit ? "Save changes" : "Save patient"}
</Button>
</DialogFooter>
</form>
</DialogPopup>
</Dialog>
);
}