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:
Khalid Abdi
2026-06-18 21:03:33 +03:00
parent 064aa22099
commit f9615fa74e
8 changed files with 376 additions and 59 deletions
+25
View File
@@ -18,6 +18,7 @@ import {
saveAiConfig,
toAiConfig,
} from "../services/ai/config.js";
import { validatePatientImport } from "../services/ai/import.js";
import { getPolicy, savePolicy } from "../services/ai/policy.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);
}
},
);
+33
View File
@@ -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 };
}
+8 -20
View File
@@ -10,7 +10,7 @@ import { appointmentInputSchema } from "../../lib/appointment-validation.js";
import { initialsFromName } from "../../lib/initials.js";
import { inventoryInputSchema } from "../../lib/inventory-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 { taskInputSchema } from "../../lib/task-validation.js";
import * as analytics from "../analytics.js";
@@ -620,30 +620,18 @@ export function createChatTools(ctx: ToolContext) {
}),
execute: async ({ records }) => {
step(`Validating ${records.length} record(s)`);
const valid: unknown[] = [];
const invalid: { index: number; errors: string[] }[] = [];
records.forEach((rec, index) => {
const parsed = patientInputSchema.safeParse(rec);
if (parsed.success) {
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.
const { valid, invalid, total } = validatePatientImport(records);
// Hand the validated set + the raw records to the UI for an approval
// card. The client can edit any record, re-validate, and posts the valid
// set back to /api/ai/import on approval. `records` carries the originals
// so invalid rows are editable.
writer.write({
type: "data-importPreview",
data: { valid, invalid, total: records.length },
data: { records, valid, invalid, total },
});
step(`${valid.length} ready, ${invalid.length} skipped`);
return {
total: records.length,
total,
validCount: valid.length,
invalidCount: invalid.length,
invalid,