mirror of
https://github.com/temetro/temetro.git
synced 2026-08-17 13:57:48 +00:00
bb7478433c
- chat-input: new "Add patient" pill (UserPlus) opens an intake dialog; remounts it via a key each open for a fresh file # / cleared form. On create it runs /patient <new#> so the record's cards open in the chat. - add-patient-dialog: controlled Dialog + form — generated file # (with regenerate), identity (name/age/sex/status/PCP), current vitals, and repeatable allergies & medications. Save builds a Patient and adds it to the store. Save disabled until a name is entered. - lib/patients: generateFileNumber() (unique) + addPatient() (session store; swap point for a real create API). - patient-cards: graceful empty states (no labs / meds / problems / visits / trend) so a brand-new sparse chart renders cleanly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
404 lines
13 KiB
TypeScript
404 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { Plus, RefreshCw, X } from "lucide-react";
|
|
import { type FormEvent, type ReactNode, useState } from "react";
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Input } from "@/components/ui/input";
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
addPatient,
|
|
type AllergySeverity,
|
|
generateFileNumber,
|
|
type Patient,
|
|
} from "@/lib/patients";
|
|
|
|
type AddPatientDialogProps = {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onCreated: (fileNumber: string) => void;
|
|
};
|
|
|
|
type AllergyDraft = { substance: string; reaction: string; severity: AllergySeverity };
|
|
type MedicationDraft = { name: string; dose: string; frequency: 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 SectionHeader({
|
|
label,
|
|
onAdd,
|
|
}: {
|
|
label: string;
|
|
onAdd: () => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
|
{label}
|
|
</span>
|
|
<Button onClick={onAdd} size="sm" type="button" variant="ghost">
|
|
<Plus className="size-4" />
|
|
Add
|
|
</Button>
|
|
</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 today = () =>
|
|
new Date().toLocaleDateString("en-US", {
|
|
month: "short",
|
|
day: "2-digit",
|
|
year: "numeric",
|
|
});
|
|
|
|
export function AddPatientDialog({
|
|
open,
|
|
onOpenChange,
|
|
onCreated,
|
|
}: AddPatientDialogProps) {
|
|
// Lazily generate the file number on mount. The dialog is remounted (via a
|
|
// `key`) each time it opens, so this gives a fresh number + cleared form
|
|
// without a reset effect.
|
|
const [fileNumber, setFileNumber] = useState(generateFileNumber);
|
|
const [name, setName] = useState("");
|
|
const [age, setAge] = useState("");
|
|
const [sex, setSex] = useState<Patient["sex"]>("F");
|
|
const [status, setStatus] = useState<Patient["status"]>("active");
|
|
const [pcp, setPcp] = useState("");
|
|
const [bp, setBp] = useState("");
|
|
const [hr, setHr] = useState("");
|
|
const [temp, setTemp] = useState("");
|
|
const [spo2, setSpo2] = useState("");
|
|
const [allergies, setAllergies] = useState<AllergyDraft[]>([]);
|
|
const [medications, setMedications] = useState<MedicationDraft[]>([]);
|
|
|
|
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
|
|
event.preventDefault();
|
|
if (!name.trim()) {
|
|
return;
|
|
}
|
|
|
|
const patient: 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: [],
|
|
medications: medications.filter((m) => m.name.trim()),
|
|
problems: [],
|
|
vitals: {
|
|
bp: bp.trim() || "—",
|
|
hr: hr.trim() || "—",
|
|
temp: temp.trim() || "—",
|
|
spo2: spo2.trim() || "—",
|
|
takenAt: today(),
|
|
},
|
|
vitalsTrend: { label: "Heart rate", unit: "bpm", points: [] },
|
|
labs: [],
|
|
labTrend: { label: "—", unit: "", points: [] },
|
|
encounters: [],
|
|
};
|
|
|
|
addPatient(patient);
|
|
onCreated(fileNumber);
|
|
onOpenChange(false);
|
|
};
|
|
|
|
return (
|
|
<Dialog onOpenChange={onOpenChange} open={open}>
|
|
<DialogContent className="max-h-[85dvh] overflow-y-auto sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>Add patient</DialogTitle>
|
|
<DialogDescription>
|
|
Create a new chart. A file number has been generated for you.
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
|
<Field label="File number">
|
|
<div className="flex items-center gap-2">
|
|
<Input readOnly value={fileNumber} />
|
|
<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
|
|
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>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<SectionHeader
|
|
label="Allergies"
|
|
onAdd={() =>
|
|
setAllergies((prev) => [
|
|
...prev,
|
|
{ substance: "", reaction: "", severity: "mild" },
|
|
])
|
|
}
|
|
/>
|
|
{allergies.map((allergy, index) => (
|
|
<div className="flex items-center gap-2" key={index}>
|
|
<Input
|
|
aria-label="Substance"
|
|
onChange={(event) =>
|
|
setAllergies((prev) =>
|
|
prev.map((row, i) =>
|
|
i === index
|
|
? { ...row, substance: event.target.value }
|
|
: row
|
|
)
|
|
)
|
|
}
|
|
placeholder="Substance"
|
|
value={allergy.substance}
|
|
/>
|
|
<Input
|
|
aria-label="Reaction"
|
|
onChange={(event) =>
|
|
setAllergies((prev) =>
|
|
prev.map((row, i) =>
|
|
i === index
|
|
? { ...row, reaction: event.target.value }
|
|
: row
|
|
)
|
|
)
|
|
}
|
|
placeholder="Reaction"
|
|
value={allergy.reaction}
|
|
/>
|
|
<select
|
|
aria-label="Severity"
|
|
className={cn(controlClass, "w-auto")}
|
|
onChange={(event) =>
|
|
setAllergies((prev) =>
|
|
prev.map((row, i) =>
|
|
i === index
|
|
? {
|
|
...row,
|
|
severity: event.target.value as AllergySeverity,
|
|
}
|
|
: row
|
|
)
|
|
)
|
|
}
|
|
value={allergy.severity}
|
|
>
|
|
<option value="mild">Mild</option>
|
|
<option value="moderate">Moderate</option>
|
|
<option value="severe">Severe</option>
|
|
</select>
|
|
<button
|
|
aria-label="Remove allergy"
|
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
|
onClick={() =>
|
|
setAllergies((prev) => prev.filter((_, i) => i !== index))
|
|
}
|
|
type="button"
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-2">
|
|
<SectionHeader
|
|
label="Medications"
|
|
onAdd={() =>
|
|
setMedications((prev) => [
|
|
...prev,
|
|
{ name: "", dose: "", frequency: "" },
|
|
])
|
|
}
|
|
/>
|
|
{medications.map((med, index) => (
|
|
<div className="flex items-center gap-2" key={index}>
|
|
<Input
|
|
aria-label="Medication name"
|
|
onChange={(event) =>
|
|
setMedications((prev) =>
|
|
prev.map((row, i) =>
|
|
i === index ? { ...row, name: event.target.value } : row
|
|
)
|
|
)
|
|
}
|
|
placeholder="Name"
|
|
value={med.name}
|
|
/>
|
|
<Input
|
|
aria-label="Dose"
|
|
onChange={(event) =>
|
|
setMedications((prev) =>
|
|
prev.map((row, i) =>
|
|
i === index ? { ...row, dose: event.target.value } : row
|
|
)
|
|
)
|
|
}
|
|
placeholder="Dose"
|
|
value={med.dose}
|
|
/>
|
|
<Input
|
|
aria-label="Frequency"
|
|
onChange={(event) =>
|
|
setMedications((prev) =>
|
|
prev.map((row, i) =>
|
|
i === index
|
|
? { ...row, frequency: event.target.value }
|
|
: row
|
|
)
|
|
)
|
|
}
|
|
placeholder="Frequency"
|
|
value={med.frequency}
|
|
/>
|
|
<button
|
|
aria-label="Remove medication"
|
|
className="text-muted-foreground transition-colors hover:text-foreground"
|
|
onClick={() =>
|
|
setMedications((prev) => prev.filter((_, i) => i !== index))
|
|
}
|
|
type="button"
|
|
>
|
|
<X className="size-4" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<DialogClose render={<Button type="button" variant="outline" />}>
|
|
Cancel
|
|
</DialogClose>
|
|
<Button disabled={!name.trim()} type="submit">
|
|
Save patient
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|