Add patient: intake form in the input that creates + opens a new chart

- 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>
This commit is contained in:
Khalid Abdi
2026-06-01 21:00:12 +03:00
parent eeae642751
commit bb7478433c
4 changed files with 523 additions and 51 deletions
@@ -0,0 +1,403 @@
"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>
);
}
+25
View File
@@ -11,6 +11,7 @@ import {
Plus,
Square,
Stethoscope,
UserPlus,
X,
} from "lucide-react";
import {
@@ -22,6 +23,7 @@ import {
useState,
} from "react";
import { AddPatientDialog } from "@/components/chat/add-patient-dialog";
import {
DropdownMenu,
DropdownMenuContent,
@@ -133,6 +135,9 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
const [specialty, setSpecialty] = useState("internal-medicine");
const [facility, setFacility] = useState("main-hospital");
const [timeRange, setTimeRange] = useState("12m");
const [addOpen, setAddOpen] = useState(false);
// Bumped on each open so the dialog remounts with a fresh file number + form.
const [addKey, setAddKey] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const isGenerating = status === "submitted" || status === "streaming";
@@ -187,6 +192,7 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
}, []);
return (
<>
<form
onSubmit={(event) => {
event.preventDefault();
@@ -323,7 +329,26 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) {
triggerClassName={contextPill}
value={timeRange}
/>
<button
className={cn(contextPill, "ml-auto")}
onClick={() => {
setAddKey((k) => k + 1);
setAddOpen(true);
}}
type="button"
>
<UserPlus className="size-4" />
<span>Add patient</span>
</button>
</div>
</form>
<AddPatientDialog
key={addKey}
onCreated={(fileNumber) => onSubmit(`/patient ${fileNumber}`)}
onOpenChange={setAddOpen}
open={addOpen}
/>
</>
);
}
+80 -51
View File
@@ -85,7 +85,14 @@ function Row({ label, value }: { label: ReactNode; value: ReactNode }) {
);
}
function Empty({ children }: { children: ReactNode }) {
return <p className="text-muted-foreground">{children}</p>;
}
function TrendBlock({ trend }: { trend: Trend }) {
if (trend.points.length === 0) {
return <Empty>No trend data yet.</Empty>;
}
return (
<div className="flex flex-col gap-1.5">
<div className="flex items-baseline justify-between gap-2">
@@ -101,6 +108,9 @@ function TrendBlock({ trend }: { trend: Trend }) {
}
function TrendDetail({ trend }: { trend: Trend }) {
if (trend.points.length === 0) {
return <Empty>No trend data yet.</Empty>;
}
const min = Math.min(...trend.points);
const max = Math.max(...trend.points);
return (
@@ -285,26 +295,30 @@ function LabsCard({ patient }: { patient: Patient }) {
<ExpandableCard
description={`As of ${patient.labs[0]?.takenAt ?? "—"}`}
detail={
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2.5">
{patient.labs.map((lab) => (
<div
className="flex items-center justify-between gap-3"
key={lab.name}
>
<div className="flex flex-col">
<span className="text-foreground">{lab.name}</span>
<span className="text-xs text-muted-foreground">
{lab.takenAt}
</span>
patient.labs.length === 0 ? (
<Empty>No labs on file.</Empty>
) : (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2.5">
{patient.labs.map((lab) => (
<div
className="flex items-center justify-between gap-3"
key={lab.name}
>
<div className="flex flex-col">
<span className="text-foreground">{lab.name}</span>
<span className="text-xs text-muted-foreground">
{lab.takenAt}
</span>
</div>
{labValue(lab.value, lab.flag)}
</div>
{labValue(lab.value, lab.flag)}
</div>
))}
))}
</div>
<Separator />
<TrendDetail trend={patient.labTrend} />
</div>
<Separator />
<TrendDetail trend={patient.labTrend} />
</div>
)
}
title="Labs"
>
@@ -313,34 +327,43 @@ function LabsCard({ patient }: { patient: Patient }) {
<CardDescription>As of {patient.labs[0]?.takenAt ?? "—"}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
{patient.labs.map((lab) => (
<Row
key={lab.name}
label={lab.name}
value={labValue(lab.value, lab.flag)}
/>
))}
</div>
<Separator />
<TrendBlock trend={patient.labTrend} />
{patient.labs.length === 0 ? (
<Empty>No labs on file.</Empty>
) : (
<>
<div className="flex flex-col gap-2">
{patient.labs.map((lab) => (
<Row
key={lab.name}
label={lab.name}
value={labValue(lab.value, lab.flag)}
/>
))}
</div>
<Separator />
<TrendBlock trend={patient.labTrend} />
</>
)}
</CardContent>
</ExpandableCard>
);
}
function MedicationsCard({ patient }: { patient: Patient }) {
const list = (
<div className="flex flex-col gap-2">
{patient.medications.map((med) => (
<Row
key={med.name}
label={med.name}
value={`${med.dose} · ${med.frequency}`}
/>
))}
</div>
);
const list =
patient.medications.length === 0 ? (
<Empty>No active medications.</Empty>
) : (
<div className="flex flex-col gap-2">
{patient.medications.map((med) => (
<Row
key={med.name}
label={med.name}
value={`${med.dose} · ${med.frequency}`}
/>
))}
</div>
);
return (
<ExpandableCard
description={`${patient.medications.length} active`}
@@ -357,17 +380,20 @@ function MedicationsCard({ patient }: { patient: Patient }) {
}
function ProblemsCard({ patient }: { patient: Patient }) {
const list = (
<div className="flex flex-col gap-2">
{patient.problems.map((problem) => (
<Row
key={problem.label}
label={problem.label}
value={`since ${problem.since}`}
/>
))}
</div>
);
const list =
patient.problems.length === 0 ? (
<Empty>No active problems.</Empty>
) : (
<div className="flex flex-col gap-2">
{patient.problems.map((problem) => (
<Row
key={problem.label}
label={problem.label}
value={`since ${problem.since}`}
/>
))}
</div>
);
return (
<ExpandableCard
description={`${patient.problems.length} active`}
@@ -437,6 +463,9 @@ function AllergiesCard({ patient }: { patient: Patient }) {
}
function VisitsList({ patient }: { patient: Patient }) {
if (patient.encounters.length === 0) {
return <Empty>No visits yet.</Empty>;
}
return (
<div className="flex flex-col gap-3">
{patient.encounters.map((encounter) => (
+15
View File
@@ -266,3 +266,18 @@ export async function getPatient(fileNumber: string): Promise<Patient | null> {
await new Promise((resolve) => setTimeout(resolve, 700));
return PATIENTS[fileNumber.trim()] ?? null;
}
// Generate a unique 5-digit file number not already in the store.
export function generateFileNumber(): string {
let candidate: string;
do {
candidate = String(10000 + Math.floor(Math.random() * 89999));
} while (candidate in PATIENTS);
return candidate;
}
// Add (or replace) a patient in the in-memory store. Session-only — no backend
// yet, so this resets on reload. Swap point for a real "create patient" API.
export function addPatient(patient: Patient): void {
PATIENTS[patient.fileNumber] = patient;
}