Files
temetro/frontend/components/chat/patient-form-dialog.tsx
T
Khalid Abdi feffce6cbf frontend: in-dialog wallet-sync stepper for record changes
When the selected patient has a linked wallet, create/edit dialogs now show
a two-step stepper: after saving, step 2 offers to push the change to the
patient's wallet (reusing pushWalletUpdate + approval polling). Added a shared
useWalletSync hook and DialogStepper/WalletSyncStep components, wired into the
appointment, invoice, prescription, patient-edit, and scribe dialogs. Falls
back to the old close-on-save when the patient has no wallet. Added walletSync.*
keys to all locales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:52:36 +03:00

808 lines
28 KiB
TypeScript

"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 (
<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;
}) {
const { t } = useTranslation();
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" />
{t("patientForm.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={t("patientForm.removeRow", { label })}
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 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 (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger
render={
<Button
aria-label={ariaLabel}
className={cn(
"justify-start font-normal",
!value && "text-muted-foreground",
className
)}
type="button"
variant="outline"
/>
}
>
<CalendarIcon className="size-4" />
<span className="truncate">{value || placeholderText}</span>
</PopoverTrigger>
<PopoverPopup className="w-auto p-0">
<Calendar
mode="single"
onSelect={(date) => {
onChange(date ? formatDate(date) : "");
setOpen(false);
}}
selected={parseDate(value)}
/>
</PopoverPopup>
</Popover>
);
}
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<string | null>(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<File[]>([]);
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"]>(patient?.sex ?? "F");
const [status, setStatus] = useState<Patient["status"]>(
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<Provider[]>([]);
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<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,
})) ?? []
);
// 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<HTMLFormElement>) => {
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 (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogPopup className="flex max-h-[85dvh] flex-col sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{isReview
? t("patientForm.reviewTitle")
: isEdit
? t("patientForm.editTitle")
: t("patientForm.createTitle")}
</DialogTitle>
<DialogDescription>
{isReview
? t("patientForm.reviewDescription")
: isEdit
? t("patientForm.editDescription", {
name: patient?.name ?? "this",
})
: t("patientForm.createDescription")}
</DialogDescription>
{sync.linked && <DialogStepper step={step} />}
</DialogHeader>
{step === "wallet" ? (
<WalletSyncStep
onDone={() => handleOpenChange(false)}
patientName={name.trim()}
summary={t("walletSync.summary.demographics")}
sync={sync}
/>
) : (
<form className="contents" onSubmit={handleSubmit}>
<DialogPanel
scrollFade={false}
className="no-scrollbar flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto"
>
<Field label={t("patientForm.fileNumber")}>
<div className="flex items-center gap-2">
<Input
onChange={
isReview
? (event) =>
setFileNumber(event.target.value.replace(/\D/g, ""))
: undefined
}
readOnly={!isReview}
value={fileNumber}
/>
{!isEdit && !isReview && (
<Button
aria-label={t("patientForm.regenerate")}
onClick={() => setFileNumber(generateFileNumber())}
size="icon"
type="button"
variant="outline"
>
<RefreshCw className="size-4" />
</Button>
)}
</div>
</Field>
<Field label={t("patientForm.fullName")}>
<Input
autoFocus={!isEdit}
onChange={(event) => setName(event.target.value)}
placeholder={t("patientForm.fullNamePlaceholder")}
required
value={name}
/>
</Field>
<div className="grid grid-cols-3 gap-3">
<Field label={t("patientForm.age")}>
<Input
inputMode="numeric"
onChange={(event) => setAge(event.target.value)}
placeholder="—"
value={age}
/>
</Field>
<Field label={t("patientForm.sex")}>
<select
className={controlClass}
onChange={(event) =>
setSex(event.target.value as Patient["sex"])
}
value={sex}
>
<option value="F">{t("patientCard.sex.F")}</option>
<option value="M">{t("patientCard.sex.M")}</option>
</select>
</Field>
<Field label={t("patientForm.status")}>
<select
className={controlClass}
onChange={(event) =>
setStatus(event.target.value as Patient["status"])
}
value={status}
>
<option value="active">{t("patients.status.active")}</option>
<option value="inpatient">
{t("patients.status.inpatient")}
</option>
<option value="discharged">
{t("patients.status.discharged")}
</option>
</select>
</Field>
</div>
<Field label={t("patientForm.primaryCare")}>
<select
className={controlClass}
onChange={(event) => setProviderId(event.target.value)}
value={providerId}
>
<option value="">
{t("patientForm.primaryCareUnassigned")}
</option>
{providers.map((p) => (
<option key={p.userId} value={p.userId}>
{p.name} · {ROLE_LABELS[p.role as keyof typeof ROLE_LABELS] ?? p.role}
</option>
))}
</select>
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label={t("patientForm.phone")}>
<Input
inputMode="tel"
onChange={(event) => setPhone(event.target.value)}
placeholder={t("patientForm.phonePlaceholder")}
value={phone}
/>
</Field>
<Field label={t("patientForm.bloodType")}>
<select
className={controlClass}
onChange={(event) => setBloodType(event.target.value)}
value={bloodType}
>
<option value="">{t("patientForm.bloodTypeUnknown")}</option>
{["A+", "A-", "B+", "B-", "AB+", "AB-", "O+", "O-"].map((bt) => (
<option key={bt} value={bt}>
{bt}
</option>
))}
</select>
</Field>
</div>
{showClinical && (
<>
<div className="flex flex-col gap-1.5">
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
{t("patientForm.currentVitals")}
</span>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Input
aria-label={t("patientForm.bp")}
onChange={(event) => setBp(event.target.value)}
placeholder={t("patientCard.vitals.bp")}
value={bp}
/>
<Input
aria-label={t("patientForm.hr")}
onChange={(event) => setHr(event.target.value)}
placeholder={t("patientCard.vitals.hr")}
value={hr}
/>
<Input
aria-label={t("patientForm.temp")}
onChange={(event) => setTemp(event.target.value)}
placeholder={t("patientCard.vitals.temp")}
value={temp}
/>
<Input
aria-label={t("patientForm.spo2")}
onChange={(event) => setSpo2(event.target.value)}
placeholder={t("patientCard.vitals.spo2")}
value={spo2}
/>
</div>
</div>
<SectionList<AllergyDraft>
blank={{ substance: "", reaction: "", severity: "mild" }}
label={t("patientForm.allergies")}
onChange={setAllergies}
render={(row, set) => (
<>
<Input
aria-label={t("patientForm.substance")}
onChange={(event) => set({ substance: event.target.value })}
placeholder={t("patientForm.substance")}
value={row.substance}
/>
<Input
aria-label={t("patientForm.reaction")}
onChange={(event) => set({ reaction: event.target.value })}
placeholder={t("patientForm.reaction")}
value={row.reaction}
/>
<select
aria-label={t("patientForm.severityAria")}
className={cn(controlClass, "w-auto")}
onChange={(event) =>
set({ severity: event.target.value as AllergySeverity })
}
value={row.severity}
>
<option value="mild">{t("patientCard.severity.mild")}</option>
<option value="moderate">
{t("patientCard.severity.moderate")}
</option>
<option value="severe">
{t("patientCard.severity.severe")}
</option>
</select>
</>
)}
rows={allergies}
/>
<SectionList<MedicationDraft>
blank={{ name: "", dose: "", frequency: "" }}
label={t("patientForm.medications")}
onChange={setMedications}
render={(row, set) => (
<>
<Input
aria-label={t("patientForm.medNameAria")}
onChange={(event) => set({ name: event.target.value })}
placeholder={t("patientForm.medName")}
value={row.name}
/>
<Input
aria-label={t("patientForm.dose")}
onChange={(event) => set({ dose: event.target.value })}
placeholder={t("patientForm.dose")}
value={row.dose}
/>
<Input
aria-label={t("patientForm.frequency")}
onChange={(event) => set({ frequency: event.target.value })}
placeholder={t("patientForm.frequency")}
value={row.frequency}
/>
</>
)}
rows={medications}
/>
<SectionList<ProblemDraft>
blank={{ label: "", since: "" }}
label={t("patientForm.problems")}
onChange={setProblems}
render={(row, set) => (
<>
<Input
aria-label={t("patientForm.problemAria")}
onChange={(event) => set({ label: event.target.value })}
placeholder={t("patientForm.diagnosis")}
value={row.label}
/>
<DatePicker
ariaLabel={t("patientForm.sinceAria")}
className="w-40 shrink-0"
onChange={(since) => set({ since })}
placeholder={t("patientForm.sinceAria")}
value={row.since}
/>
</>
)}
rows={problems}
/>
<SectionList<LabDraft>
blank={{ name: "", value: "", flag: "normal", takenAt: "" }}
label={t("patientForm.labs")}
onChange={setLabs}
render={(row, set) => (
<>
<Input
aria-label={t("patientForm.labNameAria")}
onChange={(event) => set({ name: event.target.value })}
placeholder={t("patientForm.test")}
value={row.name}
/>
<Input
aria-label={t("patientForm.valueAria")}
onChange={(event) => set({ value: event.target.value })}
placeholder={t("patientForm.value")}
value={row.value}
/>
<select
aria-label={t("patientForm.flagAria")}
className={cn(controlClass, "w-auto")}
onChange={(event) => set({ flag: event.target.value as LabFlag })}
value={row.flag}
>
<option value="normal">
{t("patientCard.labFlag.normal")}
</option>
<option value="low">{t("patientCard.labFlag.low")}</option>
<option value="high">{t("patientCard.labFlag.high")}</option>
<option value="critical">
{t("patientCard.labFlag.critical")}
</option>
</select>
</>
)}
rows={labs}
/>
<SectionList<VisitDraft>
blank={{ type: "", date: "", provider: "", summary: "" }}
label={t("patientForm.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={t("patientForm.visitTypeAria")}
onChange={(event) => set({ type: event.target.value })}
placeholder={t("patientForm.visitType")}
value={row.type}
/>
<DatePicker
ariaLabel={t("patientForm.visitDateAria")}
className="w-40 shrink-0"
onChange={(date) => set({ date })}
placeholder={t("patientForm.visitDate")}
value={row.date}
/>
</div>
<Input
aria-label={t("patientForm.providerAria")}
onChange={(event) => set({ provider: event.target.value })}
placeholder={t("patientForm.provider")}
value={row.provider}
/>
<Input
aria-label={t("patientForm.summaryAria")}
onChange={(event) => set({ summary: event.target.value })}
placeholder={t("patientForm.summary")}
value={row.summary}
/>
</div>
)}
rows={visits}
/>
</>
)}
<StagedFilesField onChange={setFiles} value={files} />
</DialogPanel>
<DialogFooter className="flex-col items-stretch gap-2 sm:flex-row sm:items-center">
{error && (
<p className="text-sm text-destructive sm:me-auto">{error}</p>
)}
<DialogClose render={<Button type="button" variant="outline" />}>
{t("patientForm.cancel")}
</DialogClose>
<Button disabled={!name.trim() || submitting} type="submit">
{submitting
? t("patientForm.saving")
: isReview
? t("patientForm.saveDraft")
: isEdit
? t("patientForm.saveChanges")
: t("patientForm.savePatient")}
</Button>
</DialogFooter>
</form>
)}
</DialogPopup>
</Dialog>
);
}