mirror of
https://github.com/temetro/temetro.git
synced 2026-08-10 10:37:43 +00:00
7bb1ee39ab
Approval state lived only in each card's local React state, so after committing (or after reloading a saved conversation) the Add / Review & add / Import buttons reappeared active and the same records could be added again. Persist the resolution onto the message data part via a chat-panel handler (setMessages), and initialize each card's status from data.resolved so committed/discarded proposals stay locked across re-render and history reload. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
357 lines
12 KiB
TypeScript
357 lines
12 KiB
TypeScript
"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<string, unknown>;
|
|
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<string, unknown>;
|
|
|
|
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<Status>(
|
|
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<unknown[]>(
|
|
() => 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<number | null>(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 (
|
|
<Card className="w-full gap-3 p-4">
|
|
<div className="flex items-center gap-2">
|
|
<Database className="size-4 text-muted-foreground" />
|
|
<span className="text-sm font-medium">{t("chat.importCard.title")}</span>
|
|
{status === "pending" && records.length > 0 ? (
|
|
<Button
|
|
className="ml-auto"
|
|
onClick={() => setReviewOpen(true)}
|
|
size="sm"
|
|
variant="ghost"
|
|
>
|
|
<Pencil className="size-3.5" />
|
|
{t("chat.importCard.reviewEdit")}
|
|
</Button>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-4 text-sm">
|
|
<span>
|
|
{t("chat.importCard.ready")}{" "}
|
|
<strong className="tabular-nums">{validCount}</strong>
|
|
</span>
|
|
{invalidByIndex.size > 0 ? (
|
|
<span className="flex items-center gap-1 text-warning-foreground">
|
|
<AlertTriangle className="size-3.5" />
|
|
{t("chat.importCard.skipped")}{" "}
|
|
<strong className="tabular-nums">{invalidByIndex.size}</strong>
|
|
</span>
|
|
) : null}
|
|
<span className="text-muted-foreground">
|
|
{t("chat.importCard.total")}{" "}
|
|
<span className="tabular-nums">{records.length}</span>
|
|
</span>
|
|
</div>
|
|
|
|
{invalidByIndex.size > 0 && status === "pending" ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("chat.importCard.fixHint")}
|
|
</p>
|
|
) : null}
|
|
|
|
{status === "done" ? (
|
|
<p className="flex items-center gap-1.5 text-sm text-foreground">
|
|
<Check className="size-4" />
|
|
{result
|
|
? `${t("chat.importCard.importedBody", { count: result.created })}${
|
|
result.failed > 0
|
|
? ` · ${t("chat.importCard.failedCount", { count: result.failed })}`
|
|
: ""
|
|
}`
|
|
: t("chat.importCard.alreadyImported")}
|
|
</p>
|
|
) : status === "rejected" ? (
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("chat.importCard.rejectedNote")}
|
|
</p>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
disabled={status === "committing" || validCount === 0}
|
|
onClick={approve}
|
|
size="sm"
|
|
>
|
|
{status === "committing"
|
|
? t("chat.importCard.importing")
|
|
: t("chat.importCard.approve", { count: validCount })}
|
|
</Button>
|
|
<Button
|
|
disabled={status === "committing"}
|
|
onClick={() => {
|
|
setStatus("rejected");
|
|
onResolved?.("discarded");
|
|
}}
|
|
size="sm"
|
|
variant="outline"
|
|
>
|
|
<X className="size-4" />
|
|
{t("chat.importCard.reject")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Review list: every parsed record, editable. */}
|
|
<Dialog onOpenChange={setReviewOpen} open={reviewOpen}>
|
|
<DialogPopup className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("chat.importCard.reviewTitle")}</DialogTitle>
|
|
<DialogDescription>
|
|
{t("chat.importCard.reviewDescription")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogPanel className="flex max-h-[60vh] flex-col gap-2 overflow-y-auto">
|
|
{records.map((rec, index) => {
|
|
const errors = invalidByIndex.get(index);
|
|
const name =
|
|
str((rec as Record<string, unknown>).name) ||
|
|
t("chat.importCard.unnamed");
|
|
return (
|
|
<button
|
|
className="flex items-start gap-2 rounded-xl border bg-card/30 p-3 text-left transition-colors hover:bg-accent"
|
|
key={index}
|
|
onClick={() => setEditingIndex(index)}
|
|
type="button"
|
|
>
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
<span className="truncate font-medium text-foreground text-sm">
|
|
{name}
|
|
</span>
|
|
{errors ? (
|
|
<span className="mt-0.5 flex items-center gap-1 text-warning-foreground text-xs">
|
|
<AlertTriangle className="size-3 shrink-0" />
|
|
<span className="truncate">{errors[0]}</span>
|
|
</span>
|
|
) : (
|
|
<span className="mt-0.5 text-muted-foreground text-xs">
|
|
{t("chat.importCard.rowReady")}
|
|
</span>
|
|
)}
|
|
</div>
|
|
{errors ? (
|
|
<Badge variant="outline">
|
|
{t("chat.importCard.needsFix")}
|
|
</Badge>
|
|
) : (
|
|
<Badge variant="secondary">
|
|
{t("chat.importCard.ready")}
|
|
</Badge>
|
|
)}
|
|
<Pencil className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
|
</button>
|
|
);
|
|
})}
|
|
</DialogPanel>
|
|
<DialogFooter>
|
|
<DialogClose render={<Button type="button" variant="outline" />}>
|
|
{t("chat.importCard.reviewClose")}
|
|
</DialogClose>
|
|
<Button
|
|
disabled={status === "committing" || validCount === 0}
|
|
onClick={approve}
|
|
type="button"
|
|
>
|
|
{t("chat.importCard.approve", { count: validCount })}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogPopup>
|
|
</Dialog>
|
|
|
|
{/* Edit one record in the full patient form (review mode — no write). */}
|
|
{editingIndex !== null ? (
|
|
<PatientFormDialog
|
|
key={editingIndex}
|
|
mode="edit"
|
|
onDraft={(record) => saveEdit(editingIndex, record)}
|
|
onOpenChange={(o) => {
|
|
if (!o) setEditingIndex(null);
|
|
}}
|
|
open={editingIndex !== null}
|
|
patient={toPatientDraft(records[editingIndex])}
|
|
/>
|
|
) : null}
|
|
</Card>
|
|
);
|
|
}
|