Files
temetro/frontend/components/chat/import-preview-card.tsx
T
Khalid Abdi 31d86bb5dd frontend: wire chat to the real agent + lab charts + import approval
Replace the mock chat with the live backend agent:
- chat-panel uses @ai-sdk/react useChat against /api/chat (credentials included),
  passing the selected model + effort per send. The `/patient <file#>` fast-path
  is kept as an instant client-side shortcut.
- Renders the agent's streamed data parts: patientCard → record cards
  (PatientResult), labCard → new LabChartCard (visx area chart with a high/low
  flag badge in the top-right corner + recent values), importPreview →
  ImportPreviewCard (the human approval gate: review counts/issues, then commit
  via POST /api/ai/import — nothing is written until approved).
- Veil consent: a one-time dialog before the first send to a cloud model,
  explaining that identifiers are de-identified before leaving the clinic.
- Attached text files (csv/json/txt…) now include their content in the message
  so the agent can parse an export for import.

Shared chat message/data-part types in lib/ai-chat.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 18:44:20 +03:00

117 lines
4.0 KiB
TypeScript

"use client";
import { AlertTriangle, Check, Database, X } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import type { ImportPreviewData } from "@/lib/ai-chat";
import { commitImport } from "@/lib/ai-settings";
import { notify } from "@/lib/toast";
type Status = "pending" | "committing" | "done" | "rejected";
// The human approval gate for the migration import. The agent proposes records
// (dry run, nothing written); the clinician reviews counts + issues here and
// must approve before anything is inserted via POST /api/ai/import.
export function ImportPreviewCard({ data }: { data: ImportPreviewData }) {
const { t } = useTranslation();
const [status, setStatus] = useState<Status>("pending");
const [result, setResult] = useState<{ created: number; failed: number } | null>(
null,
);
const approve = async () => {
setStatus("committing");
try {
const res = await commitImport(data.valid);
setResult({ created: res.created.length, failed: res.failed.length });
setStatus("done");
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>
</div>
<div className="flex flex-wrap gap-4 text-sm">
<span>
{t("chat.importCard.ready")}{" "}
<strong className="tabular-nums">{data.valid.length}</strong>
</span>
{data.invalid.length > 0 ? (
<span className="flex items-center gap-1 text-warning-foreground">
<AlertTriangle className="size-3.5" />
{t("chat.importCard.skipped")}{" "}
<strong className="tabular-nums">{data.invalid.length}</strong>
</span>
) : null}
<span className="text-muted-foreground">
{t("chat.importCard.total")}{" "}
<span className="tabular-nums">{data.total}</span>
</span>
</div>
{data.invalid.length > 0 ? (
<ul className="space-y-1 rounded-lg bg-muted/50 p-3 text-xs text-muted-foreground">
{data.invalid.slice(0, 5).map((issue) => (
<li key={issue.index}>
{t("chat.importCard.row", { index: issue.index + 1 })}:{" "}
{issue.errors.join("; ")}
</li>
))}
</ul>
) : null}
{status === "done" && result ? (
<p className="flex items-center gap-1.5 text-sm text-foreground">
<Check className="size-4" />
{t("chat.importCard.importedBody", { count: result.created })}
{result.failed > 0
? ` · ${t("chat.importCard.failedCount", { count: result.failed })}`
: ""}
</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" || data.valid.length === 0}
onClick={approve}
size="sm"
>
{status === "committing"
? t("chat.importCard.importing")
: t("chat.importCard.approve", { count: data.valid.length })}
</Button>
<Button
disabled={status === "committing"}
onClick={() => setStatus("rejected")}
size="sm"
variant="outline"
>
<X className="size-4" />
{t("chat.importCard.reject")}
</Button>
</div>
)}
</Card>
);
}