diff --git a/frontend/components/chat/add-patient-dialog.tsx b/frontend/components/chat/add-patient-dialog.tsx deleted file mode 100644 index fab5f2e..0000000 --- a/frontend/components/chat/add-patient-dialog.tsx +++ /dev/null @@ -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 ( - - ); -} - -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 efdeb74..327520a 100644 --- a/frontend/components/chat/chat-input.tsx +++ b/frontend/components/chat/chat-input.tsx @@ -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) { - onSubmit(`/patient ${fileNumber}`)} onOpenChange={setAddOpen} open={addOpen} diff --git a/frontend/components/chat/chat-panel.tsx b/frontend/components/chat/chat-panel.tsx index 8e13bb8..bae5674 100644 --- a/frontend/components/chat/chat-panel.tsx +++ b/frontend/components/chat/chat-panel.tsx @@ -134,6 +134,17 @@ export function ChatPanel() { + 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} /> diff --git a/frontend/components/chat/patient-cards.tsx b/frontend/components/chat/patient-cards.tsx index f4ed9de..1270669 100644 --- a/frontend/components/chat/patient-cards.tsx +++ b/frontend/components/chat/patient-cards.tsx @@ -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 = { @@ -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 ( + ); @@ -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 ( @@ -561,13 +590,27 @@ export function PatientResult({ status, fileNumber, patient }: PatientResultProp ) : ( <> - + { + setEditKey((k) => k + 1); + setEditOpen(true); + }} + patient={patient} + /> + onPatientUpdated?.(updated)} + open={editOpen} + patient={patient} + /> )} diff --git a/frontend/components/chat/patient-form-dialog.tsx b/frontend/components/chat/patient-form-dialog.tsx new file mode 100644 index 0000000..656f3c5 --- /dev/null +++ b/frontend/components/chat/patient-form-dialog.tsx @@ -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 ( + + ); +} + +function SectionList({ + label, + rows, + blank, + onChange, + render, +}: { + label: string; + rows: T[]; + blank: T; + onChange: (rows: T[]) => void; + render: (row: T, set: (patch: Partial) => void) => ReactNode; +}) { + return ( +
+
+ + {label} + + +
+ {rows.map((row, index) => ( +
+ {render(row, (patch) => + onChange(rows.map((r, i) => (i === index ? { ...r, ...patch } : r))) + )} + +
+ ))} +
+ ); +} + +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 ?? "F"); + const [status, setStatus] = useState( + 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( + () => patient?.allergies.map((a) => ({ ...a })) ?? [] + ); + const [medications, setMedications] = useState( + () => patient?.medications.map((m) => ({ ...m })) ?? [] + ); + const [problems, setProblems] = useState( + () => patient?.problems.map((p) => ({ ...p })) ?? [] + ); + const [labs, setLabs] = useState( + () => patient?.labs.map((l) => ({ ...l })) ?? [] + ); + const [visits, setVisits] = useState( + () => + patient?.encounters.map((e) => ({ + type: e.type, + date: e.date, + provider: e.provider, + summary: e.summary, + })) ?? [] + ); + + const handleSubmit = (event: FormEvent) => { + 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 ( + + + + {isEdit ? "Edit record" : "Add patient"} + + {isEdit + ? `Update ${patient?.name ?? "this"}'s chart and add new data.` + : "Create a new chart. A file number has been generated for you."} + + + +
+
+ +
+ + {!isEdit && ( + + )} +
+
+ + + 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} + /> +
+
+ + + blank={{ substance: "", reaction: "", severity: "mild" }} + label="Allergies" + onChange={setAllergies} + render={(row, set) => ( + <> + set({ substance: event.target.value })} + placeholder="Substance" + value={row.substance} + /> + set({ reaction: event.target.value })} + placeholder="Reaction" + value={row.reaction} + /> + + + )} + rows={allergies} + /> + + + blank={{ name: "", dose: "", frequency: "" }} + label="Medications" + onChange={setMedications} + render={(row, set) => ( + <> + set({ name: event.target.value })} + placeholder="Name" + value={row.name} + /> + set({ dose: event.target.value })} + placeholder="Dose" + value={row.dose} + /> + set({ frequency: event.target.value })} + placeholder="Frequency" + value={row.frequency} + /> + + )} + rows={medications} + /> + + + blank={{ label: "", since: "" }} + label="Problems" + onChange={setProblems} + render={(row, set) => ( + <> + set({ label: event.target.value })} + placeholder="Diagnosis" + value={row.label} + /> + set({ since: event.target.value })} + placeholder="Since" + value={row.since} + /> + + )} + rows={problems} + /> + + + blank={{ name: "", value: "", flag: "normal", takenAt: "" }} + label="Labs" + onChange={setLabs} + render={(row, set) => ( + <> + set({ name: event.target.value })} + placeholder="Test" + value={row.name} + /> + set({ value: event.target.value })} + placeholder="Value" + value={row.value} + /> + + + )} + rows={labs} + /> + + + blank={{ type: "", date: "", provider: "", summary: "" }} + label="Visits" + onChange={setVisits} + render={(row, set) => ( +
+
+ set({ type: event.target.value })} + placeholder="Type" + value={row.type} + /> + set({ date: event.target.value })} + placeholder="Date" + value={row.date} + /> +
+ set({ provider: event.target.value })} + placeholder="Provider" + value={row.provider} + /> + set({ summary: event.target.value })} + placeholder="Summary" + value={row.summary} + /> +
+ )} + rows={visits} + /> +
+ + + }> + Cancel + + + +
+
+
+ ); +}