mirror of
https://github.com/temetro/temetro.git
synced 2026-08-19 14:46:29 +00:00
Edit patient records + fix Add-patient dialog scrollbar
- Generalize add-patient-dialog → shared PatientFormDialog (create | edit). Edit mode prefills from the patient; both modes now cover all sections with add/remove rows: identity, vitals, allergies, meds, problems, labs, visits. Save writes to the store (addPatient overwrites by file #). - Fix the dialog scrollbar: fixed header + footer (Save/Cancel pinned), only the field body scrolls, scrollbar hidden (no-scrollbar). - patient-cards: "Edit record" button on the Summary card opens the editor (stopPropagation so it doesn't open the detail dialog); PatientResult holds the editor state and exposes onPatientUpdated. - chat-panel: onPatientUpdated replaces the message's patient in place, so saved edits update the on-screen cards (no duplicate message, no requery). - chat-input: Add pill now uses PatientFormDialog mode="create". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,403 +0,0 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { AddPatientDialog } from "@/components/chat/add-patient-dialog";
|
||||
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -343,8 +343,9 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<AddPatientDialog
|
||||
<PatientFormDialog
|
||||
key={addKey}
|
||||
mode="create"
|
||||
onCreated={(fileNumber) => onSubmit(`/patient ${fileNumber}`)}
|
||||
onOpenChange={setAddOpen}
|
||||
open={addOpen}
|
||||
|
||||
@@ -134,6 +134,17 @@ export function ChatPanel() {
|
||||
<MessageContent className="w-full">
|
||||
<PatientResult
|
||||
fileNumber={message.fileNumber}
|
||||
onPatientUpdated={(updated) =>
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === message.id &&
|
||||
m.role === "assistant" &&
|
||||
m.kind === "patient"
|
||||
? { ...m, patient: updated, status: "ready" }
|
||||
: m
|
||||
)
|
||||
)
|
||||
}
|
||||
patient={message.patient}
|
||||
status={message.status}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import type { ReactNode } from "react";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { PatientFormDialog } from "@/components/chat/patient-form-dialog";
|
||||
import { Sparkline } from "@/components/chat/sparkline";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { AllergySeverity, LabFlag, Patient, Trend } from "@/lib/patients";
|
||||
@@ -31,6 +33,7 @@ type PatientResultProps = {
|
||||
status: "loading" | "ready" | "not-found";
|
||||
fileNumber: string;
|
||||
patient?: Patient;
|
||||
onPatientUpdated?: (patient: Patient) => void;
|
||||
};
|
||||
|
||||
const severityVariant: Record<AllergySeverity, BadgeVariant> = {
|
||||
@@ -183,7 +186,13 @@ function ExpandableCard({
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ patient }: { patient: Patient }) {
|
||||
function SummaryCard({
|
||||
patient,
|
||||
onEdit,
|
||||
}: {
|
||||
patient: Patient;
|
||||
onEdit?: () => void;
|
||||
}) {
|
||||
const idLine = `${patient.age} · ${sexLabel[patient.sex]} · MRN ${patient.fileNumber}`;
|
||||
return (
|
||||
<ExpandableCard
|
||||
@@ -233,6 +242,17 @@ function SummaryCard({ patient }: { patient: Patient }) {
|
||||
<Stat label="Open problems" value={patient.problems.length} />
|
||||
</div>
|
||||
<AlertBadges alerts={patient.alerts} />
|
||||
<button
|
||||
className="mt-auto flex items-center justify-center gap-1.5 rounded-2xl border border-border/60 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onEdit?.();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
Edit record
|
||||
</button>
|
||||
</CardContent>
|
||||
</ExpandableCard>
|
||||
);
|
||||
@@ -542,7 +562,16 @@ function LoadingCards() {
|
||||
);
|
||||
}
|
||||
|
||||
export function PatientResult({ status, fileNumber, patient }: PatientResultProps) {
|
||||
export function PatientResult({
|
||||
status,
|
||||
fileNumber,
|
||||
patient,
|
||||
onPatientUpdated,
|
||||
}: PatientResultProps) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
// Bumped on open so the editor remounts with the latest patient data.
|
||||
const [editKey, setEditKey] = useState(0);
|
||||
|
||||
if (status === "not-found") {
|
||||
return (
|
||||
<Card size="sm">
|
||||
@@ -561,13 +590,27 @@ export function PatientResult({ status, fileNumber, patient }: PatientResultProp
|
||||
<LoadingCards />
|
||||
) : (
|
||||
<>
|
||||
<SummaryCard patient={patient} />
|
||||
<SummaryCard
|
||||
onEdit={() => {
|
||||
setEditKey((k) => k + 1);
|
||||
setEditOpen(true);
|
||||
}}
|
||||
patient={patient}
|
||||
/>
|
||||
<VitalsCard patient={patient} />
|
||||
<LabsCard patient={patient} />
|
||||
<MedicationsCard patient={patient} />
|
||||
<ProblemsCard patient={patient} />
|
||||
<AllergiesCard patient={patient} />
|
||||
<VisitsCard patient={patient} />
|
||||
<PatientFormDialog
|
||||
key={editKey}
|
||||
mode="edit"
|
||||
onOpenChange={setEditOpen}
|
||||
onSaved={(updated) => onPatientUpdated?.(updated)}
|
||||
open={editOpen}
|
||||
patient={patient}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,512 @@
|
||||
"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 LabFlag,
|
||||
type Patient,
|
||||
} from "@/lib/patients";
|
||||
|
||||
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 today = () =>
|
||||
new Date().toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
|
||||
export function PatientFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
patient,
|
||||
onCreated,
|
||||
onSaved,
|
||||
}: PatientFormDialogProps) {
|
||||
const isEdit = mode === "edit";
|
||||
|
||||
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 = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!name.trim()) {
|
||||
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,
|
||||
})),
|
||||
};
|
||||
|
||||
addPatient(built);
|
||||
if (isEdit) {
|
||||
onSaved?.(built);
|
||||
} else {
|
||||
onCreated?.(fileNumber);
|
||||
}
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange} open={open}>
|
||||
<DialogContent className="flex max-h-[85dvh] flex-col gap-4 overflow-hidden 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="flex min-h-0 flex-1 flex-col gap-4"
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
<div 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}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Since"
|
||||
className="w-28 shrink-0"
|
||||
onChange={(event) => set({ since: event.target.value })}
|
||||
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}
|
||||
/>
|
||||
<Input
|
||||
aria-label="Visit date"
|
||||
className="w-32 shrink-0"
|
||||
onChange={(event) => set({ date: event.target.value })}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose render={<Button type="button" variant="outline" />}>
|
||||
Cancel
|
||||
</DialogClose>
|
||||
<Button disabled={!name.trim()} type="submit">
|
||||
{isEdit ? "Save changes" : "Save patient"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user