diff --git a/frontend/components/chat/add-patient-dialog.tsx b/frontend/components/chat/add-patient-dialog.tsx new file mode 100644 index 0000000..fab5f2e --- /dev/null +++ b/frontend/components/chat/add-patient-dialog.tsx @@ -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 ( + + ); +} + +function SectionHeader({ + label, + onAdd, +}: { + label: string; + onAdd: () => void; +}) { + return ( +
+ + {label} + + +
+ ); +} + +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("F"); + const [status, setStatus] = useState("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([]); + const [medications, setMedications] = useState([]); + + const handleSubmit = (event: FormEvent) => { + 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 ( + + + + Add patient + + Create a new chart. A file number has been generated for you. + + + +
+ +
+ + +
+
+ + + setName(event.target.value)} + placeholder="e.g. Jordan Pierce" + required + value={name} + /> + + +
+ + setAge(event.target.value)} + placeholder="—" + value={age} + /> + + + + + + + +
+ + + setPcp(event.target.value)} + placeholder="e.g. Dr. Lena Ortiz" + value={pcp} + /> + + +
+ + Current vitals + +
+ setBp(event.target.value)} + placeholder="BP" + value={bp} + /> + setHr(event.target.value)} + placeholder="HR" + value={hr} + /> + setTemp(event.target.value)} + placeholder="Temp" + value={temp} + /> + setSpo2(event.target.value)} + placeholder="SpO₂" + value={spo2} + /> +
+
+ +
+ + setAllergies((prev) => [ + ...prev, + { substance: "", reaction: "", severity: "mild" }, + ]) + } + /> + {allergies.map((allergy, index) => ( +
+ + setAllergies((prev) => + prev.map((row, i) => + i === index + ? { ...row, substance: event.target.value } + : row + ) + ) + } + placeholder="Substance" + value={allergy.substance} + /> + + setAllergies((prev) => + prev.map((row, i) => + i === index + ? { ...row, reaction: event.target.value } + : row + ) + ) + } + placeholder="Reaction" + value={allergy.reaction} + /> + + +
+ ))} +
+ +
+ + setMedications((prev) => [ + ...prev, + { name: "", dose: "", frequency: "" }, + ]) + } + /> + {medications.map((med, index) => ( +
+ + setMedications((prev) => + prev.map((row, i) => + i === index ? { ...row, name: event.target.value } : row + ) + ) + } + placeholder="Name" + value={med.name} + /> + + setMedications((prev) => + prev.map((row, i) => + i === index ? { ...row, dose: event.target.value } : row + ) + ) + } + placeholder="Dose" + value={med.dose} + /> + + setMedications((prev) => + prev.map((row, i) => + i === index + ? { ...row, frequency: event.target.value } + : row + ) + ) + } + placeholder="Frequency" + value={med.frequency} + /> + +
+ ))} +
+ + + }> + Cancel + + + +
+
+
+ ); +} diff --git a/frontend/components/chat/chat-input.tsx b/frontend/components/chat/chat-input.tsx index e0a43da..efdeb74 100644 --- a/frontend/components/chat/chat-input.tsx +++ b/frontend/components/chat/chat-input.tsx @@ -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(null); const isGenerating = status === "submitted" || status === "streaming"; @@ -187,6 +192,7 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) { }, []); return ( + <>
{ event.preventDefault(); @@ -323,7 +329,26 @@ export function ChatInput({ onSubmit, status, onStop }: ChatInputProps) { triggerClassName={contextPill} value={timeRange} /> +
+ + onSubmit(`/patient ${fileNumber}`)} + onOpenChange={setAddOpen} + open={addOpen} + /> + ); } diff --git a/frontend/components/chat/patient-cards.tsx b/frontend/components/chat/patient-cards.tsx index cd2bf7d..f4ed9de 100644 --- a/frontend/components/chat/patient-cards.tsx +++ b/frontend/components/chat/patient-cards.tsx @@ -85,7 +85,14 @@ function Row({ label, value }: { label: ReactNode; value: ReactNode }) { ); } +function Empty({ children }: { children: ReactNode }) { + return

{children}

; +} + function TrendBlock({ trend }: { trend: Trend }) { + if (trend.points.length === 0) { + return No trend data yet.; + } return (
@@ -101,6 +108,9 @@ function TrendBlock({ trend }: { trend: Trend }) { } function TrendDetail({ trend }: { trend: Trend }) { + if (trend.points.length === 0) { + return No trend data yet.; + } const min = Math.min(...trend.points); const max = Math.max(...trend.points); return ( @@ -285,26 +295,30 @@ function LabsCard({ patient }: { patient: Patient }) { -
- {patient.labs.map((lab) => ( -
-
- {lab.name} - - {lab.takenAt} - + patient.labs.length === 0 ? ( + No labs on file. + ) : ( +
+
+ {patient.labs.map((lab) => ( +
+
+ {lab.name} + + {lab.takenAt} + +
+ {labValue(lab.value, lab.flag)}
- {labValue(lab.value, lab.flag)} -
- ))} + ))} +
+ +
- - -
+ ) } title="Labs" > @@ -313,34 +327,43 @@ function LabsCard({ patient }: { patient: Patient }) { As of {patient.labs[0]?.takenAt ?? "—"} -
- {patient.labs.map((lab) => ( - - ))} -
- - + {patient.labs.length === 0 ? ( + No labs on file. + ) : ( + <> +
+ {patient.labs.map((lab) => ( + + ))} +
+ + + + )}
); } function MedicationsCard({ patient }: { patient: Patient }) { - const list = ( -
- {patient.medications.map((med) => ( - - ))} -
- ); + const list = + patient.medications.length === 0 ? ( + No active medications. + ) : ( +
+ {patient.medications.map((med) => ( + + ))} +
+ ); return ( - {patient.problems.map((problem) => ( - - ))} -
- ); + const list = + patient.problems.length === 0 ? ( + No active problems. + ) : ( +
+ {patient.problems.map((problem) => ( + + ))} +
+ ); return ( No visits yet.; + } return (
{patient.encounters.map((encounter) => ( diff --git a/frontend/lib/patients.ts b/frontend/lib/patients.ts index dad7553..2ef0c46 100644 --- a/frontend/lib/patients.ts +++ b/frontend/lib/patients.ts @@ -266,3 +266,18 @@ export async function getPatient(fileNumber: string): Promise { 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; +}