"use client"; import { AlertTriangle, Check, Database, Pencil, X } from "lucide-react"; import { useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { PatientFormDialog } from "@/components/chat/patient-form-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Dialog, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogPanel, DialogPopup, DialogTitle, } from "@/components/ui/dialog"; import type { ImportPreviewData } from "@/lib/ai-chat"; import { commitImport, validateImport } from "@/lib/ai-settings"; import type { AllergySeverity, LabFlag, Patient } from "@/lib/patients"; import { notify } from "@/lib/toast"; type Status = "pending" | "committing" | "done" | "rejected"; const str = (v: unknown): string => (v == null ? "" : String(v)); const arr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []); // Coerce an arbitrary parsed record (a normalized valid row, or a raw invalid // one with bare-string lists / gender words) into a complete Patient the form // can render and edit. Mirrors the backend's tolerant normalization. function toPatientDraft(rec: unknown): Patient { const r = (rec ?? {}) as Record; const sx = str(r.sex).trim().toLowerCase(); const sex: Patient["sex"] = sx.startsWith("f") || sx.startsWith("w") ? "F" : "M"; const status = (["active", "inpatient", "discharged"] as const).includes( r.status as Patient["status"], ) ? (r.status as Patient["status"]) : "active"; const obj = (v: unknown) => (v ?? {}) as Record; return { fileNumber: str(r.fileNumber).replace(/\D/g, ""), name: str(r.name), age: Number(r.age) || 0, sex, pcp: str(r.pcp), primaryProviderId: (r.primaryProviderId as string | null) ?? null, status, initials: str(r.initials), alerts: arr(r.alerts).map(str), allergies: arr(r.allergies).map((a) => typeof a === "string" ? { substance: a, reaction: "", severity: "mild" as AllergySeverity } : { substance: str(obj(a).substance), reaction: str(obj(a).reaction), severity: (["mild", "moderate", "severe"].includes( obj(a).severity as string, ) ? obj(a).severity : "mild") as AllergySeverity, }, ), medications: arr(r.medications).map((m) => typeof m === "string" ? { name: m, dose: "", frequency: "" } : { name: str(obj(m).name), dose: str(obj(m).dose), frequency: str(obj(m).frequency), }, ), problems: arr(r.problems).map((p) => typeof p === "string" ? { label: p, since: "" } : { label: str(obj(p).label), since: str(obj(p).since) }, ), vitals: { bp: str(obj(r.vitals).bp), hr: str(obj(r.vitals).hr), temp: str(obj(r.vitals).temp), spo2: str(obj(r.vitals).spo2), takenAt: str(obj(r.vitals).takenAt), }, vitalsTrend: { label: "", unit: "", points: [] }, labs: arr(r.labs).map((l) => ({ name: str(obj(l).name), value: str(obj(l).value), flag: (["normal", "high", "low", "critical"].includes( obj(l).flag as string, ) ? obj(l).flag : "normal") as LabFlag, takenAt: str(obj(l).takenAt), })), labTrend: { label: "", unit: "", points: [] }, encounters: arr(r.encounters).map((e) => ({ type: str(obj(e).type) || "Visit", date: str(obj(e).date), provider: str(obj(e).provider), summary: str(obj(e).summary), })), }; } // The human approval gate for the migration import. The agent proposes records // (dry run, nothing written); the clinician reviews counts, can open and edit // any record (fixing skipped rows), and must approve before anything is // inserted via POST /api/ai/import. export function ImportPreviewCard({ data, onResolved, }: { data: ImportPreviewData; // Called once imported/discarded so the parent can persist the resolution // across re-render and conversation reload (prevents re-importing). onResolved?: (resolution: "added" | "discarded") => void; }) { const { t } = useTranslation(); const [status, setStatus] = useState( data.resolved === "added" ? "done" : data.resolved === "discarded" ? "rejected" : "pending", ); const [result, setResult] = useState<{ created: number; failed: number } | null>( null, ); // Working set of records (editable). Older threads may lack `records`; fall // back to the valid set so the card still works. const [records, setRecords] = useState( () => data.records ?? data.valid ?? [], ); // index → errors for rows that still fail validation. const [invalid, setInvalid] = useState< { index: number; errors: string[] }[] >(() => data.invalid ?? []); const [reviewOpen, setReviewOpen] = useState(false); const [editingIndex, setEditingIndex] = useState(null); const invalidByIndex = useMemo( () => new Map(invalid.map((i) => [i.index, i.errors])), [invalid], ); const validCount = records.length - invalidByIndex.size; // Re-validate the working set server-side after an edit. const revalidate = async (next: unknown[]) => { try { const res = await validateImport(next); setInvalid(res.invalid.map((i) => ({ index: i.index, errors: i.errors }))); } catch { /* keep prior validation state */ } }; const saveEdit = (index: number, record: Patient) => { const next = records.map((r, i) => (i === index ? record : r)); setRecords(next); setEditingIndex(null); void revalidate(next); }; const approve = async () => { setStatus("committing"); try { // Send the whole working set; the backend re-validates and skips any // still-invalid rows, returning created/failed. const res = await commitImport(records); setResult({ created: res.created.length, failed: res.failed.length }); setStatus("done"); setReviewOpen(false); onResolved?.("added"); notify.success( t("chat.importCard.importedTitle"), t("chat.importCard.importedBody", { count: res.created.length }), ); } catch { setStatus("pending"); notify.error( t("chat.importCard.failedTitle"), t("chat.importCard.failedBody"), ); } }; return (
{t("chat.importCard.title")} {status === "pending" && records.length > 0 ? ( ) : null}
{t("chat.importCard.ready")}{" "} {validCount} {invalidByIndex.size > 0 ? ( {t("chat.importCard.skipped")}{" "} {invalidByIndex.size} ) : null} {t("chat.importCard.total")}{" "} {records.length}
{invalidByIndex.size > 0 && status === "pending" ? (

{t("chat.importCard.fixHint")}

) : null} {status === "done" ? (

{result ? `${t("chat.importCard.importedBody", { count: result.created })}${ result.failed > 0 ? ` · ${t("chat.importCard.failedCount", { count: result.failed })}` : "" }` : t("chat.importCard.alreadyImported")}

) : status === "rejected" ? (

{t("chat.importCard.rejectedNote")}

) : (
)} {/* Review list: every parsed record, editable. */} {t("chat.importCard.reviewTitle")} {t("chat.importCard.reviewDescription")} {records.map((rec, index) => { const errors = invalidByIndex.get(index); const name = str((rec as Record).name) || t("chat.importCard.unnamed"); return ( ); })} }> {t("chat.importCard.reviewClose")} {/* Edit one record in the full patient form (review mode — no write). */} {editingIndex !== null ? ( saveEdit(editingIndex, record)} onOpenChange={(o) => { if (!o) setEditingIndex(null); }} open={editingIndex !== null} patient={toPatientDraft(records[editingIndex])} /> ) : null}
); }