mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
2c5624e049
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>
606 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|