diff --git a/frontend/components/chat/action-preview-card.tsx b/frontend/components/chat/action-preview-card.tsx
index 100ca68..e3c8658 100644
--- a/frontend/components/chat/action-preview-card.tsx
+++ b/frontend/components/chat/action-preview-card.tsx
@@ -6,6 +6,7 @@ import {
CalendarPlus,
Check,
ClipboardList,
+ Pencil,
Pill,
Receipt,
X,
@@ -13,9 +14,10 @@ import {
import { useState } from "react";
import { useTranslation } from "react-i18next";
+import { RecordEditDialog } from "@/components/chat/record-edit-dialog";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
-import type { ActionPreviewData } from "@/lib/ai-chat";
+import type { ActionPreviewData, ActionPreviewKind } from "@/lib/ai-chat";
import { type AppointmentInput, createAppointment } from "@/lib/appointments";
import {
createInvoice,
@@ -110,20 +112,123 @@ export async function commitAction(data: ActionPreviewData): Promise {
}
}
+// A structured, de-densified view of a proposed record. Inventory and invoice
+// records carry an item array — rendered as a compact scrollable list (one row
+// per item) rather than a single comma-joined wall of text; everything else
+// uses the per-kind summary lines.
+export function RecordSummary({
+ kind,
+ record,
+}: {
+ kind: ActionPreviewKind;
+ record: Record;
+}) {
+ const { t } = useTranslation();
+
+ if (kind === "inventory") {
+ const items = (record.items as InventoryInput[] | undefined) ?? [];
+ return (
+
+
+ {t("chat.actionCard.itemCount", { count: items.length })}
+
+
+ {items.map((it, i) => (
+
+
+ {it.name}
+ {it.strength ? (
+ · {it.strength}
+ ) : null}
+
+ {it.stockQuantity != null ? (
+
+ ×{it.stockQuantity}
+
+ ) : null}
+
+ ))}
+
+
+ );
+ }
+
+ if (kind === "invoice") {
+ const items = (record.lineItems as InvoiceLineItem[] | undefined) ?? [];
+ const total = items.reduce((s, li) => s + li.quantity * li.unitPrice, 0);
+ return (
+
+ {record.name ? (
+
+ {String(record.name)}
+
+ ) : null}
+
+ {items.map((li, i) => (
+
+
+ {li.description}
+ ×{li.quantity}
+
+
+ {formatMoney(li.quantity * li.unitPrice)}
+
+
+ ))}
+
+
+
+ {t("chat.actionCard.total")}
+
+
+ {formatMoney(total)}
+
+
+
+ );
+ }
+
+ const lines = summarize({ kind, record } as ActionPreviewData);
+ return (
+
+ {lines.map((line, i) => (
+
+ {line}
+
+ ))}
+
+ );
+}
+
// The human approval gate for an agent-proposed add. The agent drafts the record
-// (dry run, nothing written); the clinician reviews it here and must approve
-// before it is committed via the matching RBAC-gated create endpoint.
+// (dry run, nothing written); the clinician reviews it here, may edit it, and
+// must approve before it is committed via the matching RBAC-gated create endpoint.
export function ActionPreviewCard({ data }: { data: ActionPreviewData }) {
const { t } = useTranslation();
const [status, setStatus] = useState("pending");
+ // Editable working copy of the proposed record (edits commit, not the draft).
+ const [record, setRecord] = useState>(
+ data.record as Record,
+ );
+ const [editOpen, setEditOpen] = useState(false);
const Icon = ICONS[data.kind];
const hasIssues = (data.issues?.length ?? 0) > 0;
- const lines = summarize(data);
const approve = async () => {
setStatus("committing");
try {
- await commitAction(data);
+ await commitAction({ ...data, record });
setStatus("done");
notify.success(
t("chat.actionCard.addedTitle"),
@@ -138,6 +243,8 @@ export function ActionPreviewCard({ data }: { data: ActionPreviewData }) {
}
};
+ const editable = status === "pending";
+
return (
@@ -145,18 +252,20 @@ export function ActionPreviewCard({ data }: { data: ActionPreviewData }) {
{t(`chat.actionCard.title.${data.kind}`)}
+ {editable ? (
+
setEditOpen(true)}
+ size="sm"
+ variant="ghost"
+ >
+
+ {t("chat.actionCard.editButton")}
+
+ ) : null}
-
- {lines.map((line, i) => (
-
- {line}
-
- ))}
-
+
{hasIssues ? (
@@ -200,6 +309,14 @@ export function ActionPreviewCard({ data }: { data: ActionPreviewData }) {
)}
+
+
);
}
diff --git a/frontend/components/chat/batch-action-preview-card.tsx b/frontend/components/chat/batch-action-preview-card.tsx
index ae4e70c..ea3a4dc 100644
--- a/frontend/components/chat/batch-action-preview-card.tsx
+++ b/frontend/components/chat/batch-action-preview-card.tsx
@@ -1,6 +1,6 @@
"use client";
-import { AlertTriangle, Check, Sparkles, X } from "lucide-react";
+import { AlertTriangle, Check, Pencil, Sparkles, X } from "lucide-react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -9,6 +9,7 @@ import {
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";
@@ -39,6 +40,16 @@ export function BatchActionPreviewCard({ items }: { items: ActionPreviewData[] }
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>>(
+ {},
+ );
+ // The row currently open in the edit dialog.
+ const [editing, setEditing] = useState(null);
+
+ const recordFor = (it: ActionPreviewData) =>
+ edits[it.token] ?? (it.record as Record);
const kept = useMemo(
() => items.filter((it) => !removed.has(it.token)),
@@ -54,7 +65,7 @@ export function BatchActionPreviewCard({ items }: { items: ActionPreviewData[] }
// Sequential so server-side patient de-dup (by name) sees prior creates.
for (const it of kept) {
try {
- await commitAction(it);
+ await commitAction({ ...it, record: recordFor(it) });
added += 1;
} catch {
failed += 1;
@@ -137,10 +148,11 @@ export function BatchActionPreviewCard({ items }: { items: ActionPreviewData[] }
) : (
kept.map((it) => {
- const lines = summarize(it);
- const record = it.record as Record;
+ const record = recordFor(it);
+ const lines = summarize({ ...it, record });
const newPatient =
it.kind === "appointment" && !record.fileNumber;
+ const edited = it.token in edits;
return (
))}
+ {edited ? (
+
+
+ {t("chat.actionCard.batch.edited")}
+
+ ) : null}
{newPatient ? (
@@ -172,6 +190,15 @@ export function BatchActionPreviewCard({ items }: { items: ActionPreviewData[] }
) : null}
+ setEditing({ ...it, record })}
+ size="icon"
+ variant="ghost"
+ >
+
+
+
+ {editing ? (
+ {
+ if (!o) setEditing(null);
+ }}
+ onSave={(record) =>
+ setEdits((prev) => ({ ...prev, [editing.token]: record }))
+ }
+ open={editing !== null}
+ record={editing.record as Record}
+ />
+ ) : null}
);
}
diff --git a/frontend/components/chat/record-edit-dialog.tsx b/frontend/components/chat/record-edit-dialog.tsx
new file mode 100644
index 0000000..946c9ff
--- /dev/null
+++ b/frontend/components/chat/record-edit-dialog.tsx
@@ -0,0 +1,295 @@
+"use client";
+
+// Lets a clinician edit an AI-proposed record before it is committed. Driven by
+// a small per-kind schema (EDIT_SCHEMAS) so every action kind — appointment,
+// task, prescription, invoice, inventory — gets the right fields, including
+// array fields (invoice line items, inventory items) edited as add/remove rows.
+
+import { Plus, X } from "lucide-react";
+import { type ReactNode, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogClose,
+ DialogFooter,
+ DialogHeader,
+ DialogPanel,
+ DialogPopup,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import { Input } from "@/components/ui/input";
+import type { ActionPreviewKind } from "@/lib/ai-chat";
+import { cn } from "@/lib/utils";
+
+type FieldType = "text" | "number" | "select";
+
+type FieldDef = {
+ key: string;
+ labelKey: string;
+ type?: FieldType;
+ // For select fields: option values + an i18n key prefix resolved as `${prefix}.${value}`.
+ options?: { value: string; labelPrefix: string }[];
+};
+
+type ListDef = {
+ key: string;
+ labelKey: string;
+ itemFields: FieldDef[];
+ blank: Record;
+};
+
+type KindSchema = { fields: FieldDef[]; list?: ListDef };
+
+const controlClass =
+ "h-9 w-full rounded-3xl border border-transparent bg-input/50 px-3 text-sm text-foreground outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/30";
+
+const PRIORITY_OPTIONS = [
+ { value: "high", labelPrefix: "tasks.priority" },
+ { value: "medium", labelPrefix: "tasks.priority" },
+ { value: "low", labelPrefix: "tasks.priority" },
+];
+
+export const EDIT_SCHEMAS: Record = {
+ appointment: {
+ fields: [
+ { key: "name", labelKey: "chat.actionCard.fields.name" },
+ { key: "date", labelKey: "chat.actionCard.fields.date" },
+ { key: "time", labelKey: "chat.actionCard.fields.time" },
+ { key: "type", labelKey: "chat.actionCard.fields.type" },
+ { key: "provider", labelKey: "chat.actionCard.fields.provider" },
+ ],
+ },
+ task: {
+ fields: [
+ { key: "title", labelKey: "chat.actionCard.fields.title" },
+ { key: "assignee", labelKey: "chat.actionCard.fields.assignee" },
+ { key: "due", labelKey: "chat.actionCard.fields.due" },
+ {
+ key: "priority",
+ labelKey: "chat.actionCard.fields.priority",
+ type: "select",
+ options: PRIORITY_OPTIONS,
+ },
+ { key: "patient", labelKey: "chat.actionCard.fields.patient" },
+ ],
+ },
+ prescription: {
+ fields: [
+ { key: "medication", labelKey: "chat.actionCard.fields.medication" },
+ { key: "dose", labelKey: "chat.actionCard.fields.dose" },
+ { key: "frequency", labelKey: "chat.actionCard.fields.frequency" },
+ { key: "duration", labelKey: "chat.actionCard.fields.duration" },
+ { key: "name", labelKey: "chat.actionCard.fields.name" },
+ ],
+ },
+ invoice: {
+ fields: [{ key: "name", labelKey: "chat.actionCard.fields.name" }],
+ list: {
+ key: "lineItems",
+ labelKey: "chat.actionCard.fields.lineItems",
+ itemFields: [
+ { key: "description", labelKey: "chat.actionCard.fields.description" },
+ {
+ key: "quantity",
+ labelKey: "chat.actionCard.fields.quantity",
+ type: "number",
+ },
+ {
+ key: "unitPrice",
+ labelKey: "chat.actionCard.fields.unitPrice",
+ type: "number",
+ },
+ ],
+ blank: { description: "", quantity: 1, unitPrice: 0 },
+ },
+ },
+ inventory: {
+ fields: [],
+ list: {
+ key: "items",
+ labelKey: "chat.actionCard.fields.items",
+ itemFields: [
+ { key: "name", labelKey: "chat.actionCard.fields.itemName" },
+ { key: "strength", labelKey: "chat.actionCard.fields.strength" },
+ {
+ key: "stockQuantity",
+ labelKey: "chat.actionCard.fields.stockQuantity",
+ type: "number",
+ },
+ ],
+ blank: { name: "", strength: "", stockQuantity: 0 },
+ },
+ },
+};
+
+type Rec = Record;
+
+function FieldInput({
+ field,
+ value,
+ onChange,
+}: {
+ field: FieldDef;
+ value: unknown;
+ onChange: (value: unknown) => void;
+}) {
+ const { t } = useTranslation();
+ if (field.type === "select" && field.options) {
+ return (
+ onChange(e.target.value)}
+ value={String(value ?? "")}
+ >
+ {field.options.map((o) => (
+
+ {t(`${o.labelPrefix}.${o.value}`)}
+
+ ))}
+
+ );
+ }
+ return (
+
+ onChange(
+ field.type === "number"
+ ? e.target.value === ""
+ ? ""
+ : Number(e.target.value)
+ : e.target.value,
+ )
+ }
+ value={value === null || value === undefined ? "" : String(value)}
+ />
+ );
+}
+
+function Labelled({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+ {label}
+ {children}
+
+ );
+}
+
+export function RecordEditDialog({
+ kind,
+ record,
+ open,
+ onOpenChange,
+ onSave,
+}: {
+ kind: ActionPreviewKind;
+ record: Rec;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSave: (record: Rec) => void;
+}) {
+ const { t } = useTranslation();
+ const schema = EDIT_SCHEMAS[kind];
+ const [draft, setDraft] = useState(record);
+
+ // Re-seed the draft whenever a fresh record is opened for editing.
+ useEffect(() => {
+ if (open) setDraft(record);
+ }, [open, record]);
+
+ const setField = (key: string, value: unknown) =>
+ setDraft((d) => ({ ...d, [key]: value }));
+
+ const list = schema.list;
+ const listRows = (list ? (draft[list.key] as Rec[] | undefined) : undefined) ?? [];
+ const setRows = (rows: Rec[]) => list && setField(list.key, rows);
+
+ const save = () => {
+ onSave(draft);
+ onOpenChange(false);
+ };
+
+ return (
+
+
+
+ {t("chat.actionCard.edit.title")}
+
+
+
+ {schema.fields.map((field) => (
+
+ setField(field.key, v)}
+ value={draft[field.key]}
+ />
+
+ ))}
+
+ {list && (
+
+
+
+ {t(list.labelKey)}
+
+
setRows([...listRows, { ...list.blank }])}
+ size="sm"
+ type="button"
+ variant="ghost"
+ >
+
+ {t("chat.actionCard.edit.addRow")}
+
+
+ {listRows.map((row, index) => (
+
+ {list.itemFields.map((field) => (
+
+ setRows(
+ listRows.map((r, i) =>
+ i === index ? { ...r, [field.key]: v } : r,
+ ),
+ )
+ }
+ value={row[field.key]}
+ />
+ ))}
+
+ setRows(listRows.filter((_, i) => i !== index))
+ }
+ type="button"
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+ }>
+ {t("chat.actionCard.edit.cancel")}
+
+
+ {t("chat.actionCard.edit.save")}
+
+
+
+
+ );
+}
diff --git a/frontend/lib/i18n/locales/en/translation.json b/frontend/lib/i18n/locales/en/translation.json
index 3fa42e5..a491b6c 100644
--- a/frontend/lib/i18n/locales/en/translation.json
+++ b/frontend/lib/i18n/locales/en/translation.json
@@ -970,6 +970,40 @@
"inventory": "Inventory updated."
},
"inventoryItems": "{{count}} item(s)",
+ "itemCount": "{{count}} item(s)",
+ "total": "Total",
+ "editButton": "Edit",
+ "edit": {
+ "title": "Edit proposed record",
+ "save": "Save changes",
+ "cancel": "Cancel",
+ "addRow": "Add",
+ "removeRow": "Remove row"
+ },
+ "fields": {
+ "name": "Patient name",
+ "date": "Date",
+ "time": "Time",
+ "type": "Type",
+ "provider": "Provider",
+ "title": "Title",
+ "assignee": "Assignee",
+ "due": "Due",
+ "priority": "Priority",
+ "patient": "Patient",
+ "medication": "Medication",
+ "dose": "Dose",
+ "frequency": "Frequency",
+ "duration": "Duration",
+ "lineItems": "Line items",
+ "description": "Description",
+ "quantity": "Qty",
+ "unitPrice": "Unit price",
+ "items": "Items",
+ "itemName": "Name",
+ "strength": "Strength",
+ "stockQuantity": "Quantity"
+ },
"approve": "Add",
"adding": "Adding…",
"discard": "Discard",
@@ -987,6 +1021,7 @@
"adding": "Adding…",
"discardAll": "Discard all",
"newPatient": "New patient will be created",
+ "edited": "Edited",
"remove": "Remove",
"done": "Added {{added}} of {{total}}.",
"failedCount": "{{count}} failed",