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 ? ( + + ) : 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}
    + + + {listRows.map((row, index) => ( +
    + {list.itemFields.map((field) => ( + + setRows( + listRows.map((r, i) => + i === index ? { ...r, [field.key]: v } : r, + ), + ) + } + value={row[field.key]} + /> + ))} + +
    + ))} + + )} + + + + }> + {t("chat.actionCard.edit.cancel")} + + + + + + ); +} 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",