mirror of
https://github.com/temetro/temetro.git
synced 2026-09-01 13:29:16 +00:00
feat: edit parsed records before importing
The import preview only showed counts + skipped-row errors with no way to fix anything. Now every parsed record is editable before import: - backend: shared validatePatientImport() (used by the previewImport tool) + POST /api/ai/import/validate for dry-run re-validation; the import preview payload now carries the original records (and the source record on each invalid row) so the UI can edit them. - frontend: the import card gets a "Review & edit" dialog listing every record with a ready / needs-fixing badge; opening one reuses the full PatientFormDialog in a new non-persisting review mode (editable file number) and re-validates on save, so skipped rows can be fixed and included. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
|||||||
saveAiConfig,
|
saveAiConfig,
|
||||||
toAiConfig,
|
toAiConfig,
|
||||||
} from "../services/ai/config.js";
|
} from "../services/ai/config.js";
|
||||||
|
import { validatePatientImport } from "../services/ai/import.js";
|
||||||
import { getPolicy, savePolicy } from "../services/ai/policy.js";
|
import { getPolicy, savePolicy } from "../services/ai/policy.js";
|
||||||
import * as patients from "../services/patients.js";
|
import * as patients from "../services/patients.js";
|
||||||
|
|
||||||
@@ -189,3 +190,27 @@ aiRouter.post(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// --- Migration import re-validation (dry run) -------------------------------
|
||||||
|
// Powers the "review & edit before import" UI: the client edits parsed records
|
||||||
|
// and calls this to refresh which are ready vs. need fixing. Writes nothing.
|
||||||
|
aiRouter.post(
|
||||||
|
"/import/validate",
|
||||||
|
requireAuth,
|
||||||
|
requireOrg,
|
||||||
|
requirePermission({ patient: ["write"] }),
|
||||||
|
async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const records = (req.body as { records?: unknown[] }).records;
|
||||||
|
if (!Array.isArray(records)) {
|
||||||
|
throw new HttpError(400, "records must be an array.");
|
||||||
|
}
|
||||||
|
if (records.length > 500) {
|
||||||
|
throw new HttpError(400, "Too many records (max 500).");
|
||||||
|
}
|
||||||
|
res.json(validatePatientImport(records));
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { patientInputSchema } from "../../lib/patient-validation.js";
|
||||||
|
|
||||||
|
// Result of a dry-run validation of parsed patient records. `valid` holds the
|
||||||
|
// normalized, ready-to-commit records; `invalid` keeps the *original* record
|
||||||
|
// alongside its errors so the clinician can edit and re-validate it in the UI.
|
||||||
|
export type ImportValidation = {
|
||||||
|
valid: unknown[];
|
||||||
|
invalid: { index: number; errors: string[]; record: unknown }[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate parsed patient records against the (tolerant) patient schema without
|
||||||
|
// writing anything. Shared by the chat `previewImport` tool and the
|
||||||
|
// re-validation endpoint the edit-before-import UI calls.
|
||||||
|
export function validatePatientImport(records: unknown[]): ImportValidation {
|
||||||
|
const valid: unknown[] = [];
|
||||||
|
const invalid: ImportValidation["invalid"] = [];
|
||||||
|
records.forEach((record, index) => {
|
||||||
|
const parsed = patientInputSchema.safeParse(record);
|
||||||
|
if (parsed.success) {
|
||||||
|
valid.push(parsed.data);
|
||||||
|
} else {
|
||||||
|
invalid.push({
|
||||||
|
index,
|
||||||
|
errors: parsed.error.issues.map(
|
||||||
|
(i) => `${i.path.join(".") || "(root)"}: ${i.message}`,
|
||||||
|
),
|
||||||
|
record,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return { valid, invalid, total: records.length };
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import { appointmentInputSchema } from "../../lib/appointment-validation.js";
|
|||||||
import { initialsFromName } from "../../lib/initials.js";
|
import { initialsFromName } from "../../lib/initials.js";
|
||||||
import { inventoryInputSchema } from "../../lib/inventory-validation.js";
|
import { inventoryInputSchema } from "../../lib/inventory-validation.js";
|
||||||
import { invoiceInputSchema } from "../../lib/invoice-validation.js";
|
import { invoiceInputSchema } from "../../lib/invoice-validation.js";
|
||||||
import { patientInputSchema } from "../../lib/patient-validation.js";
|
import { validatePatientImport } from "./import.js";
|
||||||
import { prescriptionInputSchema } from "../../lib/prescription-validation.js";
|
import { prescriptionInputSchema } from "../../lib/prescription-validation.js";
|
||||||
import { taskInputSchema } from "../../lib/task-validation.js";
|
import { taskInputSchema } from "../../lib/task-validation.js";
|
||||||
import * as analytics from "../analytics.js";
|
import * as analytics from "../analytics.js";
|
||||||
@@ -620,30 +620,18 @@ export function createChatTools(ctx: ToolContext) {
|
|||||||
}),
|
}),
|
||||||
execute: async ({ records }) => {
|
execute: async ({ records }) => {
|
||||||
step(`Validating ${records.length} record(s)`);
|
step(`Validating ${records.length} record(s)`);
|
||||||
const valid: unknown[] = [];
|
const { valid, invalid, total } = validatePatientImport(records);
|
||||||
const invalid: { index: number; errors: string[] }[] = [];
|
// Hand the validated set + the raw records to the UI for an approval
|
||||||
records.forEach((rec, index) => {
|
// card. The client can edit any record, re-validate, and posts the valid
|
||||||
const parsed = patientInputSchema.safeParse(rec);
|
// set back to /api/ai/import on approval. `records` carries the originals
|
||||||
if (parsed.success) {
|
// so invalid rows are editable.
|
||||||
valid.push(parsed.data);
|
|
||||||
} else {
|
|
||||||
invalid.push({
|
|
||||||
index,
|
|
||||||
errors: parsed.error.issues.map(
|
|
||||||
(i) => `${i.path.join(".") || "(root)"}: ${i.message}`,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// Hand the validated, ready-to-commit set to the UI for an approval
|
|
||||||
// card. The client posts these back to /api/ai/import on approval.
|
|
||||||
writer.write({
|
writer.write({
|
||||||
type: "data-importPreview",
|
type: "data-importPreview",
|
||||||
data: { valid, invalid, total: records.length },
|
data: { records, valid, invalid, total },
|
||||||
});
|
});
|
||||||
step(`${valid.length} ready, ${invalid.length} skipped`);
|
step(`${valid.length} ready, ${invalid.length} skipped`);
|
||||||
return {
|
return {
|
||||||
total: records.length,
|
total,
|
||||||
validCount: valid.length,
|
validCount: valid.length,
|
||||||
invalidCount: invalid.length,
|
invalidCount: invalid.length,
|
||||||
invalid,
|
invalid,
|
||||||
|
|||||||
@@ -1,33 +1,166 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertTriangle, Check, Database, X } from "lucide-react";
|
import { AlertTriangle, Check, Database, Pencil, X } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { Card } from "@/components/ui/card";
|
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 type { ImportPreviewData } from "@/lib/ai-chat";
|
||||||
import { commitImport } from "@/lib/ai-settings";
|
import { commitImport, validateImport } from "@/lib/ai-settings";
|
||||||
|
import type { AllergySeverity, LabFlag, Patient } from "@/lib/patients";
|
||||||
import { notify } from "@/lib/toast";
|
import { notify } from "@/lib/toast";
|
||||||
|
|
||||||
type Status = "pending" | "committing" | "done" | "rejected";
|
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
|
// The human approval gate for the migration import. The agent proposes records
|
||||||
// (dry run, nothing written); the clinician reviews counts + issues here and
|
// (dry run, nothing written); the clinician reviews counts, can open and edit
|
||||||
// must approve before anything is inserted via POST /api/ai/import.
|
// any record (fixing skipped rows), and must approve before anything is
|
||||||
|
// inserted via POST /api/ai/import.
|
||||||
export function ImportPreviewCard({ data }: { data: ImportPreviewData }) {
|
export function ImportPreviewCard({ data }: { data: ImportPreviewData }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [status, setStatus] = useState<Status>("pending");
|
const [status, setStatus] = useState<Status>("pending");
|
||||||
const [result, setResult] = useState<{ created: number; failed: number } | null>(
|
const [result, setResult] = useState<{ created: number; failed: number } | null>(
|
||||||
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 () => {
|
const approve = async () => {
|
||||||
setStatus("committing");
|
setStatus("committing");
|
||||||
try {
|
try {
|
||||||
const res = await commitImport(data.valid);
|
// 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 });
|
setResult({ created: res.created.length, failed: res.failed.length });
|
||||||
setStatus("done");
|
setStatus("done");
|
||||||
|
setReviewOpen(false);
|
||||||
notify.success(
|
notify.success(
|
||||||
t("chat.importCard.importedTitle"),
|
t("chat.importCard.importedTitle"),
|
||||||
t("chat.importCard.importedBody", { count: res.created.length }),
|
t("chat.importCard.importedBody", { count: res.created.length }),
|
||||||
@@ -46,41 +179,41 @@ export function ImportPreviewCard({ data }: { data: ImportPreviewData }) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Database className="size-4 text-muted-foreground" />
|
<Database className="size-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">{t("chat.importCard.title")}</span>
|
<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>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 text-sm">
|
<div className="flex flex-wrap gap-4 text-sm">
|
||||||
<span>
|
<span>
|
||||||
{t("chat.importCard.ready")}{" "}
|
{t("chat.importCard.ready")}{" "}
|
||||||
<strong className="tabular-nums">{data.valid.length}</strong>
|
<strong className="tabular-nums">{validCount}</strong>
|
||||||
</span>
|
</span>
|
||||||
{data.invalid.length > 0 ? (
|
{invalidByIndex.size > 0 ? (
|
||||||
<span className="flex items-center gap-1 text-warning-foreground">
|
<span className="flex items-center gap-1 text-warning-foreground">
|
||||||
<AlertTriangle className="size-3.5" />
|
<AlertTriangle className="size-3.5" />
|
||||||
{t("chat.importCard.skipped")}{" "}
|
{t("chat.importCard.skipped")}{" "}
|
||||||
<strong className="tabular-nums">{data.invalid.length}</strong>
|
<strong className="tabular-nums">{invalidByIndex.size}</strong>
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
{t("chat.importCard.total")}{" "}
|
{t("chat.importCard.total")}{" "}
|
||||||
<span className="tabular-nums">{data.total}</span>
|
<span className="tabular-nums">{records.length}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{data.invalid.length > 0 ? (
|
{invalidByIndex.size > 0 && status === "pending" ? (
|
||||||
<ul className="max-h-40 space-y-1 overflow-y-auto rounded-lg bg-muted/50 p-3 text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{data.invalid.slice(0, 5).map((issue) => (
|
{t("chat.importCard.fixHint")}
|
||||||
<li className="truncate" key={issue.index}>
|
</p>
|
||||||
{t("chat.importCard.row", { index: issue.index + 1 })}:{" "}
|
|
||||||
{issue.errors[0]}
|
|
||||||
{issue.errors.length > 1 ? ` (+${issue.errors.length - 1})` : ""}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
{data.invalid.length > 5 ? (
|
|
||||||
<li className="text-muted-foreground/70">
|
|
||||||
{t("chat.importCard.more", { count: data.invalid.length - 5 })}
|
|
||||||
</li>
|
|
||||||
) : null}
|
|
||||||
</ul>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{status === "done" && result ? (
|
{status === "done" && result ? (
|
||||||
@@ -98,13 +231,13 @@ export function ImportPreviewCard({ data }: { data: ImportPreviewData }) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
disabled={status === "committing" || data.valid.length === 0}
|
disabled={status === "committing" || validCount === 0}
|
||||||
onClick={approve}
|
onClick={approve}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{status === "committing"
|
{status === "committing"
|
||||||
? t("chat.importCard.importing")
|
? t("chat.importCard.importing")
|
||||||
: t("chat.importCard.approve", { count: data.valid.length })}
|
: t("chat.importCard.approve", { count: validCount })}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={status === "committing"}
|
disabled={status === "committing"}
|
||||||
@@ -117,6 +250,86 @@ export function ImportPreviewCard({ data }: { data: ImportPreviewData }) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,10 @@ type PatientFormDialogProps = {
|
|||||||
patient?: Patient;
|
patient?: Patient;
|
||||||
onCreated?: (fileNumber: string) => void;
|
onCreated?: (fileNumber: string) => void;
|
||||||
onSaved?: (patient: Patient) => 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 AllergyDraft = { substance: string; reaction: string; severity: AllergySeverity };
|
||||||
@@ -206,9 +210,12 @@ export function PatientFormDialog({
|
|||||||
patient,
|
patient,
|
||||||
onCreated,
|
onCreated,
|
||||||
onSaved,
|
onSaved,
|
||||||
|
onDraft,
|
||||||
}: PatientFormDialogProps) {
|
}: PatientFormDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isEdit = mode === "edit";
|
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
|
// Reception registers demographics only — clinical sections are hidden (the
|
||||||
// backend also redacts/ignores clinical data for this role). Show everything
|
// backend also redacts/ignores clinical data for this role). Show everything
|
||||||
// while the role is still loading to avoid a flash for clinical users.
|
// while the role is still loading to avoid a flash for clinical users.
|
||||||
@@ -333,6 +340,13 @@ export function PatientFormDialog({
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Review mode: hand the edited record back to the caller, don't persist.
|
||||||
|
if (onDraft) {
|
||||||
|
onDraft(built);
|
||||||
|
onOpenChange(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -386,14 +400,20 @@ export function PatientFormDialog({
|
|||||||
<DialogPopup className="max-h-[85dvh] sm:max-w-lg">
|
<DialogPopup className="max-h-[85dvh] sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>
|
<DialogTitle>
|
||||||
{isEdit ? t("patientForm.editTitle") : t("patientForm.createTitle")}
|
{isReview
|
||||||
|
? t("patientForm.reviewTitle")
|
||||||
|
: isEdit
|
||||||
|
? t("patientForm.editTitle")
|
||||||
|
: t("patientForm.createTitle")}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{isEdit
|
{isReview
|
||||||
? t("patientForm.editDescription", {
|
? t("patientForm.reviewDescription")
|
||||||
name: patient?.name ?? "this",
|
: isEdit
|
||||||
})
|
? t("patientForm.editDescription", {
|
||||||
: t("patientForm.createDescription")}
|
name: patient?.name ?? "this",
|
||||||
|
})
|
||||||
|
: t("patientForm.createDescription")}
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
@@ -404,8 +424,17 @@ export function PatientFormDialog({
|
|||||||
>
|
>
|
||||||
<Field label={t("patientForm.fileNumber")}>
|
<Field label={t("patientForm.fileNumber")}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input readOnly value={fileNumber} />
|
<Input
|
||||||
{!isEdit && (
|
onChange={
|
||||||
|
isReview
|
||||||
|
? (event) =>
|
||||||
|
setFileNumber(event.target.value.replace(/\D/g, ""))
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
readOnly={!isReview}
|
||||||
|
value={fileNumber}
|
||||||
|
/>
|
||||||
|
{!isEdit && !isReview && (
|
||||||
<Button
|
<Button
|
||||||
aria-label={t("patientForm.regenerate")}
|
aria-label={t("patientForm.regenerate")}
|
||||||
onClick={() => setFileNumber(generateFileNumber())}
|
onClick={() => setFileNumber(generateFileNumber())}
|
||||||
@@ -703,9 +732,11 @@ export function PatientFormDialog({
|
|||||||
<Button disabled={!name.trim() || submitting} type="submit">
|
<Button disabled={!name.trim() || submitting} type="submit">
|
||||||
{submitting
|
{submitting
|
||||||
? t("patientForm.saving")
|
? t("patientForm.saving")
|
||||||
: isEdit
|
: isReview
|
||||||
? t("patientForm.saveChanges")
|
? t("patientForm.saveDraft")
|
||||||
: t("patientForm.savePatient")}
|
: isEdit
|
||||||
|
? t("patientForm.saveChanges")
|
||||||
|
: t("patientForm.savePatient")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -19,9 +19,12 @@ export type LabCardData = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type ImportPreviewData = {
|
export type ImportPreviewData = {
|
||||||
|
// Every parsed record (originals), so the clinician can edit any row.
|
||||||
|
records: unknown[];
|
||||||
// Validated, ready-to-commit records (server re-validates on commit).
|
// Validated, ready-to-commit records (server re-validates on commit).
|
||||||
valid: unknown[];
|
valid: unknown[];
|
||||||
invalid: { index: number; errors: string[] }[];
|
// Skipped rows, with their errors and the original record (for editing).
|
||||||
|
invalid: { index: number; errors: string[]; record: unknown }[];
|
||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -61,3 +61,16 @@ export async function commitImport(
|
|||||||
body: JSON.stringify({ records }),
|
body: JSON.stringify({ records }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-validate edited import records (dry run) so the review UI can refresh which
|
||||||
|
// rows are ready vs. need fixing. Writes nothing.
|
||||||
|
export async function validateImport(records: unknown[]): Promise<{
|
||||||
|
valid: unknown[];
|
||||||
|
invalid: { index: number; errors: string[]; record: unknown }[];
|
||||||
|
total: number;
|
||||||
|
}> {
|
||||||
|
return apiFetch("/api/ai/import/validate", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ records }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1075,6 +1075,14 @@
|
|||||||
"approve": "Import {{count}} record(s)",
|
"approve": "Import {{count}} record(s)",
|
||||||
"approve_one": "Import 1 record",
|
"approve_one": "Import 1 record",
|
||||||
"reject": "Discard",
|
"reject": "Discard",
|
||||||
|
"reviewEdit": "Review & edit",
|
||||||
|
"reviewTitle": "Review records",
|
||||||
|
"reviewDescription": "Open any record to edit it before importing. Fix the skipped ones to include them.",
|
||||||
|
"reviewClose": "Close",
|
||||||
|
"fixHint": "Open Review & edit to fix the skipped rows, or import the ready ones.",
|
||||||
|
"rowReady": "Ready to import",
|
||||||
|
"needsFix": "Needs fixing",
|
||||||
|
"unnamed": "Unnamed record",
|
||||||
"importing": "Importing…",
|
"importing": "Importing…",
|
||||||
"rejectedNote": "Import discarded. Nothing was written.",
|
"rejectedNote": "Import discarded. Nothing was written.",
|
||||||
"importedTitle": "Records imported",
|
"importedTitle": "Records imported",
|
||||||
@@ -1293,6 +1301,9 @@
|
|||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"saveChanges": "Save changes",
|
"saveChanges": "Save changes",
|
||||||
"savePatient": "Save patient",
|
"savePatient": "Save patient",
|
||||||
|
"saveDraft": "Save changes",
|
||||||
|
"reviewTitle": "Review record before import",
|
||||||
|
"reviewDescription": "Edit any field, then save to update this record in the import. Nothing is written until you import.",
|
||||||
"saveError": "Could not save the patient.",
|
"saveError": "Could not save the patient.",
|
||||||
"updatedTitle": "Record updated",
|
"updatedTitle": "Record updated",
|
||||||
"updatedBody": "{{name}}'s chart was saved.",
|
"updatedBody": "{{name}}'s chart was saved.",
|
||||||
|
|||||||
Reference in New Issue
Block a user