"use client"; import { CalendarIcon, Plus, RefreshCw, X } from "lucide-react"; import { type FormEvent, type ReactNode, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { StagedFilesField } from "@/components/patients/patient-files"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { Popover, PopoverPopup, PopoverTrigger, } from "@/components/ui/popover"; import { Dialog, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { ROLE_LABELS } from "@/lib/access"; import { authClient } from "@/lib/auth-client"; import { cn } from "@/lib/utils"; import { type AllergySeverity, createPatient, generateFileNumber, type LabFlag, type Patient, updatePatient, } from "@/lib/patients"; import { uploadAttachment } from "@/lib/attachments"; import { hasClinicalAccess, useActiveRole } from "@/lib/roles"; import { listProviders, type Provider } from "@/lib/staff"; import { notify } from "@/lib/toast"; import { useWalletSync } from "@/components/wallet/use-wallet-sync"; import { DialogStepper, WalletSyncStep, } from "@/components/wallet/wallet-sync-step"; type PatientFormDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; mode: "create" | "edit"; patient?: Patient; onCreated?: (fileNumber: string) => void; onSaved?: (patient: Patient) => void; // Review mode: when provided, the form does NOT persist — it emits the edited // record so a caller (e.g. the import review dialog) can stage it. The file // number becomes editable so a clinician can fix an import row. onDraft?: (record: 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; }) { const { t } = useTranslation(); 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 formatDate = (date: Date) => date.toLocaleDateString("en-US", { month: "short", day: "2-digit", year: "numeric", }); const today = () => formatDate(new Date()); // Patient dates are stored as formatted strings (e.g. "Jun 02, 2026"); parse one // back to a Date so the calendar can highlight the current selection. function parseDate(value: string): Date | undefined { const trimmed = value.trim(); if (!trimmed) { return undefined; } const parsed = new Date(trimmed); return Number.isNaN(parsed.getTime()) ? undefined : parsed; } // Calendar-backed date field that reads/writes the same formatted string the rest // of the form uses. function DatePicker({ value, onChange, ariaLabel, placeholder, className, }: { value: string; onChange: (value: string) => void; ariaLabel: string; placeholder?: string; className?: string; }) { const { t } = useTranslation(); const [open, setOpen] = useState(false); const placeholderText = placeholder ?? t("patientForm.pickDate"); return ( } > {value || placeholderText} { onChange(date ? formatDate(date) : ""); setOpen(false); }} selected={parseDate(value)} /> ); } export function PatientFormDialog({ open, onOpenChange, mode, patient, onCreated, onSaved, onDraft, }: PatientFormDialogProps) { const { t } = useTranslation(); const isEdit = mode === "edit"; // Review mode stages an edited record instead of writing it (import flow). const isReview = Boolean(onDraft); // Reception registers demographics only — clinical sections are hidden (the // backend also redacts/ignores clinical data for this role). Show everything // while the role is still loading to avoid a flash for clinical users. const role = useActiveRole(); const showClinical = role == null || hasClinicalAccess(role); const { data: session } = authClient.useSession(); const myId = session?.user?.id; const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); // Files staged in the form, uploaded once the patient record is saved (so the // attachment can be linked to the file number). const [files, setFiles] = useState([]); const [fileNumber, setFileNumber] = useState(() => isEdit && patient ? patient.fileNumber : generateFileNumber() ); const [step, setStep] = useState<"form" | "wallet">("form"); // Only edits to an existing (non-review) record can sync to a wallet — a newly // created patient has no wallet, and review mode stages an import. const sync = useWalletSync(isEdit && !isReview ? fileNumber : null); const handleOpenChange = (o: boolean) => { onOpenChange(o); if (!o) { setStep("form"); sync.reset(); } }; 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" ); // Primary care provider is picked from the clinic's clinicians (drives // per-doctor visibility), not free text. `providerId` is the selected user id. const [providers, setProviders] = useState([]); const [providerId, setProviderId] = useState(patient?.primaryProviderId ?? ""); const [phone, setPhone] = useState(patient?.phone ?? ""); const [bloodType, setBloodType] = useState(patient?.bloodType ?? ""); 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, })) ?? [] ); // Load the clinic's clinicians for the PCP picker. When creating, default the // PCP to the current user if they're a provider (a doctor registering their // own patient). useEffect(() => { let active = true; listProviders() .then((list) => { if (!active) return; setProviders(list); if (!isEdit && myId && list.some((p) => p.userId === myId)) { setProviderId((cur) => cur || myId); } }) .catch(() => { /* leave the picker empty; PCP just stays unassigned */ }); return () => { active = false; }; }, [isEdit, myId]); const handleSubmit = async (event: FormEvent) => { event.preventDefault(); if (!name.trim() || submitting) { return; } const selectedProvider = providers.find((p) => p.userId === providerId); // Display name follows the selected provider; preserve any existing label // when nothing is selected so legacy free-text PCPs aren't wiped on edit. const pcpName = selectedProvider?.name ?? (patient?.pcp || "—"); const built: Patient = { fileNumber, name: name.trim(), age: Number(age) || 0, sex, pcp: pcpName, primaryProviderId: providerId || null, status, initials: initialsFromName(name), phone: phone.trim(), bloodType, 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, })), }; // Review mode: hand the edited record back to the caller, don't persist. if (onDraft) { onDraft(built); onOpenChange(false); return; } setSubmitting(true); setError(null); try { const saved = isEdit ? await updatePatient(built) : await createPatient(built); // Upload any staged files now that we have a saved file number. if (files.length > 0) { const results = await Promise.allSettled( files.map((file) => uploadAttachment({ file, fileNumber: saved.fileNumber }), ), ); if (results.some((r) => r.status === "rejected")) { notify.error( t("patientFiles.uploadFailedTitle"), t("patientFiles.uploadFailedBody"), ); } setFiles([]); } if (isEdit) { onSaved?.(saved); notify.success( t("patientForm.updatedTitle"), t("patientForm.updatedBody", { name: saved.name }), ); if (sync.linked) { setStep("wallet"); return; } } else { onCreated?.(saved.fileNumber); notify.success( t("patientForm.addedTitle"), t("patientForm.addedBody", { name: saved.name, fileNumber: saved.fileNumber, }), ); } onOpenChange(false); } catch (err) { const message = err instanceof Error ? err.message : t("patientForm.saveError"); setError(message); notify.error(t("patientForm.saveFailedTitle"), message); } finally { setSubmitting(false); } }; return ( {isReview ? t("patientForm.reviewTitle") : isEdit ? t("patientForm.editTitle") : t("patientForm.createTitle")} {isReview ? t("patientForm.reviewDescription") : isEdit ? t("patientForm.editDescription", { name: patient?.name ?? "this", }) : t("patientForm.createDescription")} {sync.linked && } {step === "wallet" ? ( handleOpenChange(false)} patientName={name.trim()} summary={t("walletSync.summary.demographics")} sync={sync} /> ) : (
setFileNumber(event.target.value.replace(/\D/g, "")) : undefined } readOnly={!isReview} value={fileNumber} /> {!isEdit && !isReview && ( )}
setName(event.target.value)} placeholder={t("patientForm.fullNamePlaceholder")} required value={name} />
setAge(event.target.value)} placeholder="—" value={age} />
setPhone(event.target.value)} placeholder={t("patientForm.phonePlaceholder")} value={phone} />
{showClinical && ( <>
{t("patientForm.currentVitals")}
setBp(event.target.value)} placeholder={t("patientCard.vitals.bp")} value={bp} /> setHr(event.target.value)} placeholder={t("patientCard.vitals.hr")} value={hr} /> setTemp(event.target.value)} placeholder={t("patientCard.vitals.temp")} value={temp} /> setSpo2(event.target.value)} placeholder={t("patientCard.vitals.spo2")} value={spo2} />
blank={{ substance: "", reaction: "", severity: "mild" }} label={t("patientForm.allergies")} onChange={setAllergies} render={(row, set) => ( <> set({ substance: event.target.value })} placeholder={t("patientForm.substance")} value={row.substance} /> set({ reaction: event.target.value })} placeholder={t("patientForm.reaction")} value={row.reaction} /> )} rows={allergies} /> blank={{ name: "", dose: "", frequency: "" }} label={t("patientForm.medications")} onChange={setMedications} render={(row, set) => ( <> set({ name: event.target.value })} placeholder={t("patientForm.medName")} value={row.name} /> set({ dose: event.target.value })} placeholder={t("patientForm.dose")} value={row.dose} /> set({ frequency: event.target.value })} placeholder={t("patientForm.frequency")} value={row.frequency} /> )} rows={medications} /> blank={{ label: "", since: "" }} label={t("patientForm.problems")} onChange={setProblems} render={(row, set) => ( <> set({ label: event.target.value })} placeholder={t("patientForm.diagnosis")} value={row.label} /> set({ since })} placeholder={t("patientForm.sinceAria")} value={row.since} /> )} rows={problems} /> blank={{ name: "", value: "", flag: "normal", takenAt: "" }} label={t("patientForm.labs")} onChange={setLabs} render={(row, set) => ( <> set({ name: event.target.value })} placeholder={t("patientForm.test")} value={row.name} /> set({ value: event.target.value })} placeholder={t("patientForm.value")} value={row.value} /> )} rows={labs} /> blank={{ type: "", date: "", provider: "", summary: "" }} label={t("patientForm.visits")} onChange={setVisits} render={(row, set) => (
set({ type: event.target.value })} placeholder={t("patientForm.visitType")} value={row.type} /> set({ date })} placeholder={t("patientForm.visitDate")} value={row.date} />
set({ provider: event.target.value })} placeholder={t("patientForm.provider")} value={row.provider} /> set({ summary: event.target.value })} placeholder={t("patientForm.summary")} value={row.summary} />
)} rows={visits} /> )}
{error && (

{error}

)} }> {t("patientForm.cancel")}
)}
); }