settings: Records import & export for temetro data

Backend: GET /api/settings/records/export downloads the clinic's full patient
archive as JSON; POST /api/settings/records/import creates new patients (skips
existing file numbers, drops cross-clinic provider links). Both admin-gated.

Frontend: Records settings now has a working Export button and an Import flow
(choose file -> preview count -> apply) wired to the new endpoints.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Khalid Abdi
2026-06-20 18:44:37 +03:00
parent bbc6869745
commit e656eae362
4 changed files with 307 additions and 7 deletions
@@ -1,6 +1,7 @@
"use client";
import { Database, Download, Import, Smartphone } from "lucide-react";
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@/components/ui/badge";
@@ -9,6 +10,13 @@ import {
SettingsCard,
SettingsSection,
} from "@/components/settings/settings-parts";
import {
downloadJson,
exportRecords,
importRecords,
type RecordsExport,
} from "@/lib/records";
import { notify } from "@/lib/toast";
// A single titled row inside a records card: icon + title/description on the
// left, a status badge or action on the right.
@@ -44,6 +52,74 @@ export function RecordsPanel() {
const comingSoon = (
<Badge variant="secondary">{t("settings.records.comingSoon")}</Badge>
);
const fileRef = useRef<HTMLInputElement>(null);
const [exporting, setExporting] = useState(false);
const [importing, setImporting] = useState(false);
// A parsed archive staged for import (preview before applying).
const [staged, setStaged] = useState<{ name: string; count: number; patients: unknown[] } | null>(
null,
);
const runExport = async () => {
setExporting(true);
try {
const data = await exportRecords();
const stamp = new Date().toISOString().slice(0, 10);
downloadJson(data, `temetro-records-${stamp}.json`);
notify.success(
t("settings.records.exportDoneTitle"),
t("settings.records.exportDoneBody", { count: data.patientCount }),
);
} catch {
notify.error(
t("settings.records.exportFailedTitle"),
t("settings.records.exportFailedBody"),
);
} finally {
setExporting(false);
}
};
const onFile = async (file: File) => {
try {
const parsed = JSON.parse(await file.text()) as RecordsExport;
const patients = Array.isArray(parsed?.patients) ? parsed.patients : null;
if (!patients) throw new Error("bad");
setStaged({ name: file.name, count: patients.length, patients });
} catch {
setStaged(null);
notify.error(
t("settings.records.importInvalidTitle"),
t("settings.records.importInvalidBody"),
);
} finally {
if (fileRef.current) fileRef.current.value = "";
}
};
const runImport = async () => {
if (!staged) return;
setImporting(true);
try {
const result = await importRecords(staged.patients);
notify.success(
t("settings.records.importDoneTitle"),
t("settings.records.importDoneBody", {
created: result.created,
skipped: result.skipped,
}),
);
setStaged(null);
} catch {
notify.error(
t("settings.records.importFailedTitle"),
t("settings.records.importFailedBody"),
);
} finally {
setImporting(false);
}
};
return (
<>
<SettingsSection
@@ -79,18 +155,70 @@ export function RecordsPanel() {
description={t("settings.records.exportDesc")}
icon={<Download className="size-4" />}
right={
<Button disabled size="sm" variant="outline">
{t("settings.records.export")}
<Button
disabled={exporting}
onClick={runExport}
size="sm"
variant="outline"
>
{exporting
? t("settings.records.exporting")
: t("settings.records.export")}
</Button>
}
title={t("settings.records.export")}
/>
<RecordRow
description={t("settings.records.importDesc")}
description={t("settings.records.importTemetroDesc")}
icon={<Import className="size-4" />}
right={comingSoon}
title={t("settings.records.import")}
right={
<>
<input
accept="application/json,.json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onFile(f);
}}
ref={fileRef}
type="file"
/>
<Button
onClick={() => fileRef.current?.click()}
size="sm"
variant="outline"
>
{t("settings.records.importChoose")}
</Button>
</>
}
title={t("settings.records.importTemetro")}
/>
{staged ? (
<div className="flex items-center justify-between gap-4 bg-muted/30 px-4 py-3.5">
<div className="min-w-0 space-y-0.5">
<p className="truncate text-sm font-medium">{staged.name}</p>
<p className="text-sm text-muted-foreground">
{t("settings.records.importPreview", { count: staged.count })}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button
disabled={importing}
onClick={() => setStaged(null)}
size="sm"
variant="ghost"
>
{t("settings.records.importCancel")}
</Button>
<Button disabled={importing} onClick={runImport} size="sm">
{importing
? t("settings.records.importing")
: t("settings.records.importApply")}
</Button>
</div>
</div>
) : null}
</SettingsCard>
</SettingsSection>
+19 -1
View File
@@ -1496,7 +1496,25 @@
"importDesc": "Connect an existing patient database and browse it in the same card UI.",
"retentionTitle": "Retention",
"retentionDescription": "How long records are kept",
"retentionBody": "Records are kept for as long as your clinic exists. Deleting a patient permanently removes their chart from the clinic database, and the activity log keeps an audit trail of every change — who made it and when."
"retentionBody": "Records are kept for as long as your clinic exists. Deleting a patient permanently removes their chart from the clinic database, and the activity log keeps an audit trail of every change — who made it and when.",
"exporting": "Exporting…",
"exportDoneTitle": "Records exported",
"exportDoneBody": "Downloaded {{count}} patient record(s).",
"exportFailedTitle": "Export failed",
"exportFailedBody": "Couldn't export records. Please try again.",
"importTemetro": "Import a temetro archive",
"importTemetroDesc": "Load patients from a temetro export file. Existing file numbers are skipped.",
"importChoose": "Choose file",
"importPreview": "{{count}} patient record(s) ready to import.",
"importCancel": "Cancel",
"importApply": "Import",
"importing": "Importing…",
"importInvalidTitle": "Invalid file",
"importInvalidBody": "That doesn't look like a temetro export.",
"importDoneTitle": "Import complete",
"importDoneBody": "Created {{created}}, skipped {{skipped}}.",
"importFailedTitle": "Import failed",
"importFailedBody": "Couldn't import records. Please try again."
},
"developers": {
"description": "Access tokens for the temetro API",
+46
View File
@@ -0,0 +1,46 @@
import { apiFetch } from "@/lib/api-client";
import type { Patient } from "@/lib/patients";
// Shape of a temetro records archive (GET /api/settings/records/export).
export type RecordsExport = {
temetroExport: true;
version: number;
exportedAt: string;
organizationId: string;
patientCount: number;
patients: Patient[];
};
// Summary returned by POST /api/settings/records/import.
export type ImportResult = {
created: number;
skipped: number;
total: number;
errors: string[];
};
export function exportRecords(): Promise<RecordsExport> {
return apiFetch<RecordsExport>("/api/settings/records/export");
}
export function importRecords(patients: unknown[]): Promise<ImportResult> {
return apiFetch<ImportResult>("/api/settings/records/import", {
method: "POST",
body: JSON.stringify({ patients }),
});
}
// Trigger a browser download of a JSON archive.
export function downloadJson(data: unknown, filename: string): void {
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.append(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}