mirror of
https://github.com/temetro/temetro.git
synced 2026-08-09 01:59:30 +00:00
d237504af9
Add three UI locales (so/ar/de) with full ~1,660-key translations alongside en/fr, selectable in Settings → Profile. Arabic gets full right-to-left support: - config.ts registers the locales and exports a `dirFor` helper; an inline <head> script in layout.tsx sets <html dir/lang> before first paint (no RTL flash), and i18n-provider keeps them in sync on language change. - ~160 physical direction utilities converted to logical (ms/me/ps/pe/ start/end/text-start/text-end); directional chevrons/arrows get rtl:rotate-180; chat-bubble align variants fixed to logical. - IBM Plex Sans Arabic appended to the sans/heading font stacks for per-character Arabic fallback. - Language persists to the backend user_settings and re-applies on sign-in so it roams across devices (localStorage stays the offline source of truth). - New scripts/check-locales.mjs (npm run check-locales) enforces key/placeholder parity and Arabic CLDR plural completeness. Bump to 0.3.0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
271 lines
9.4 KiB
TypeScript
271 lines
9.4 KiB
TypeScript
"use client";
|
|
|
|
import { AlertTriangle, Check, Pencil, Sparkles, X } from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import {
|
|
ACTION_ICONS,
|
|
commitAction,
|
|
summarize,
|
|
} from "@/components/chat/action-preview-card";
|
|
import { RecordEditDialog } from "@/components/chat/record-edit-dialog";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card } from "@/components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogPanel,
|
|
DialogPopup,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import type { ActionPreviewData } from "@/lib/ai-chat";
|
|
import { notify } from "@/lib/toast";
|
|
|
|
type Status = "pending" | "committing" | "done" | "rejected";
|
|
|
|
// A single approval surface for many agent-proposed records (e.g. an imported
|
|
// file of appointments) instead of one card per record. The clinician reviews
|
|
// the full list in a dialog, removes any they don't want, and adds them all at
|
|
// once. Each commit goes through the same RBAC-gated create endpoint as the
|
|
// single-record card; appointments without a file number create a patient.
|
|
export function BatchActionPreviewCard({
|
|
items,
|
|
onResolved,
|
|
}: {
|
|
items: ActionPreviewData[];
|
|
// Called once committed/discarded so the parent can persist the resolution
|
|
// across re-render and conversation reload (prevents re-adding).
|
|
onResolved?: (resolution: "added" | "discarded") => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [open, setOpen] = useState(false);
|
|
const [removed, setRemoved] = useState<Set<string>>(new Set());
|
|
const [status, setStatus] = useState<Status>(
|
|
items[0]?.resolved === "added"
|
|
? "done"
|
|
: items[0]?.resolved === "discarded"
|
|
? "rejected"
|
|
: "pending",
|
|
);
|
|
const [result, setResult] = useState<{ added: number; failed: number } | null>(
|
|
null,
|
|
);
|
|
// Per-row edits, keyed by token. The committed record is the edit if present,
|
|
// otherwise the agent's original proposal.
|
|
const [edits, setEdits] = useState<Record<string, Record<string, unknown>>>(
|
|
{},
|
|
);
|
|
// The row currently open in the edit dialog.
|
|
const [editing, setEditing] = useState<ActionPreviewData | null>(null);
|
|
|
|
const recordFor = (it: ActionPreviewData) =>
|
|
edits[it.token] ?? (it.record as Record<string, unknown>);
|
|
|
|
const kept = useMemo(
|
|
() => items.filter((it) => !removed.has(it.token)),
|
|
[items, removed],
|
|
);
|
|
|
|
const Icon = ACTION_ICONS[items[0]?.kind ?? "appointment"];
|
|
|
|
const addAll = async () => {
|
|
setStatus("committing");
|
|
let added = 0;
|
|
let failed = 0;
|
|
// Sequential so server-side patient de-dup (by name) sees prior creates.
|
|
for (const it of kept) {
|
|
try {
|
|
await commitAction({ ...it, record: recordFor(it) });
|
|
added += 1;
|
|
} catch {
|
|
failed += 1;
|
|
}
|
|
}
|
|
setResult({ added, failed });
|
|
setStatus("done");
|
|
setOpen(false);
|
|
onResolved?.("added");
|
|
if (added > 0) {
|
|
notify.success(
|
|
t("chat.actionCard.addedTitle"),
|
|
t("chat.actionCard.batch.done", { added, total: kept.length }),
|
|
);
|
|
} else if (failed > 0) {
|
|
notify.error(
|
|
t("chat.actionCard.failedTitle"),
|
|
t("chat.actionCard.failedBody"),
|
|
);
|
|
}
|
|
};
|
|
|
|
const discardAll = () => {
|
|
setStatus("rejected");
|
|
setOpen(false);
|
|
onResolved?.("discarded");
|
|
};
|
|
|
|
return (
|
|
<Card className="w-full gap-3 p-4">
|
|
<div className="flex items-center gap-2">
|
|
<Icon className="size-4 text-muted-foreground" />
|
|
<span className="font-medium text-sm">
|
|
{t("chat.actionCard.batch.title", { count: items.length })}
|
|
</span>
|
|
<Badge className="ms-auto gap-1" variant="secondary">
|
|
<Sparkles className="size-3" />
|
|
AI
|
|
</Badge>
|
|
</div>
|
|
|
|
{status === "done" ? (
|
|
<p className="flex items-center gap-1.5 text-foreground text-sm">
|
|
<Check className="size-4" />
|
|
{result
|
|
? `${t("chat.actionCard.batch.done", {
|
|
added: result.added,
|
|
total: items.length,
|
|
})}${
|
|
result.failed > 0
|
|
? ` · ${t("chat.actionCard.batch.failedCount", { count: result.failed })}`
|
|
: ""
|
|
}`
|
|
: t("chat.actionCard.batch.alreadyAdded")}
|
|
</p>
|
|
) : status === "rejected" ? (
|
|
<p className="text-muted-foreground text-sm">
|
|
{t("chat.actionCard.batch.discarded")}
|
|
</p>
|
|
) : (
|
|
<div className="flex items-center gap-2">
|
|
<Button onClick={() => setOpen(true)} size="sm">
|
|
{t("chat.actionCard.batch.review")}
|
|
</Button>
|
|
<Button onClick={discardAll} size="sm" variant="outline">
|
|
<X className="size-4" />
|
|
{t("chat.actionCard.discard")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<Dialog onOpenChange={setOpen} open={open}>
|
|
<DialogPopup className="sm:max-w-lg">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("chat.actionCard.batch.dialogTitle")}</DialogTitle>
|
|
<DialogDescription>
|
|
{t("chat.actionCard.batch.dialogDescription")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<DialogPanel className="flex max-h-[60vh] flex-col gap-2 overflow-y-auto">
|
|
{kept.length === 0 ? (
|
|
<p className="py-6 text-center text-muted-foreground text-sm">
|
|
{t("chat.actionCard.batch.discarded")}
|
|
</p>
|
|
) : (
|
|
kept.map((it) => {
|
|
const record = recordFor(it);
|
|
const lines = summarize({ ...it, record });
|
|
const newPatient =
|
|
it.kind === "appointment" && !record.fileNumber;
|
|
const edited = it.token in edits;
|
|
return (
|
|
<div
|
|
className="flex items-start gap-2 rounded-xl border bg-card/30 p-3"
|
|
key={it.token}
|
|
>
|
|
<div className="flex min-w-0 flex-1 flex-col">
|
|
{lines.map((line, i) => (
|
|
<span
|
|
className={
|
|
i === 0
|
|
? "truncate font-medium text-foreground text-sm"
|
|
: "truncate text-muted-foreground text-xs"
|
|
}
|
|
key={line + i}
|
|
>
|
|
{line}
|
|
</span>
|
|
))}
|
|
{edited ? (
|
|
<span className="mt-1 inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
|
<Pencil className="size-3" />
|
|
{t("chat.actionCard.batch.edited")}
|
|
</span>
|
|
) : null}
|
|
{newPatient ? (
|
|
<span className="mt-1 inline-flex items-center gap-1 text-[11px] text-muted-foreground">
|
|
<Sparkles className="size-3" />
|
|
{t("chat.actionCard.batch.newPatient")}
|
|
</span>
|
|
) : null}
|
|
{it.issues && it.issues.length > 0 ? (
|
|
<span className="mt-1 flex items-center gap-1 text-warning-foreground text-xs">
|
|
<AlertTriangle className="size-3" />
|
|
{it.issues[0]}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
<Button
|
|
aria-label={t("chat.actionCard.editButton")}
|
|
className="shrink-0"
|
|
onClick={() => setEditing({ ...it, record })}
|
|
size="icon"
|
|
variant="ghost"
|
|
>
|
|
<Pencil className="size-4" />
|
|
</Button>
|
|
<Button
|
|
aria-label={t("chat.actionCard.batch.remove")}
|
|
className="shrink-0"
|
|
onClick={() =>
|
|
setRemoved((prev) => new Set(prev).add(it.token))
|
|
}
|
|
size="icon"
|
|
variant="ghost"
|
|
>
|
|
<X className="size-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
</DialogPanel>
|
|
|
|
<DialogFooter>
|
|
<Button onClick={discardAll} type="button" variant="outline">
|
|
{t("chat.actionCard.batch.discardAll")}
|
|
</Button>
|
|
<Button
|
|
disabled={status === "committing" || kept.length === 0}
|
|
onClick={addAll}
|
|
type="button"
|
|
>
|
|
{status === "committing"
|
|
? t("chat.actionCard.batch.adding")
|
|
: `${t("chat.actionCard.batch.addAll")} (${kept.length})`}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogPopup>
|
|
</Dialog>
|
|
|
|
{editing ? (
|
|
<RecordEditDialog
|
|
kind={editing.kind}
|
|
onOpenChange={(o) => {
|
|
if (!o) setEditing(null);
|
|
}}
|
|
onSave={(record) =>
|
|
setEdits((prev) => ({ ...prev, [editing.token]: record }))
|
|
}
|
|
open={editing !== null}
|
|
record={editing.record as Record<string, unknown>}
|
|
/>
|
|
) : null}
|
|
</Card>
|
|
);
|
|
}
|