From 921ff993c46c8a0c4b2ec33f911fa176d6f3f774 Mon Sep 17 00:00:00 2001 From: Yashas Date: Wed, 29 Jul 2026 16:45:26 +0530 Subject: [PATCH] show the AI's work on the invoice page: timeline, decisions, match evidence, PDF MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail page rendered a flat grid of every field the view returned — 29 for Invoice Detail, 21 for Finance Review Detail — so a reviewer's actual decision inputs sat among twenty peers of equal weight, and the most valuable data in the app wasn't shown at all. InvoiceDetail now orders the page by the decision being made: identity+status, AI recommendation, 3-way match, clarification exchange, source document, AI reasoning trail, history, then the original grid collapsed. AIActivityPanel ports the studio builder's AI Employee -> Logs tab into the app, narrowed to one instance: decisions with status/confidence/model/reasoning/ reflexion, escalations, and a per-run trace waterfall with durations, tokens and cost. Same event vocabulary and confidence thresholds as the builder, so a client never has to be shown the builder to see what the AI did. The endpoints (/monitor/instances/{id}/log and /trace) are publicly routed and accept the app's own JWT. AuditTimeline finally uses getAuditLog, which had been written and never called from anywhere. It renders the trail as a story — who acted, AI or human, what state resulted, and how long each hop waited — deduping the extra rows the trigger's performActivity commit produces (9 raw entries collapse to 4 on instance 128). ThreeWayMatchCard surfaces match_summary and analysis_so_far. Both were effectively invisible: one buried in the field grid, the other explicitly dropped by DetailViewPanel's SKIP_KEYS for looking like raw JSON. analysis_so_far is actually the AI's gathered evidence — PO, goods receipt, contract, payment history and the specific discrepancy — so the recommendation becomes auditable instead of a wall of prose. InvoiceDocumentCard makes the uploaded PDF viewable. invoice_pdf appeared in zero record views and zero detail views, so the department answering a clarification and the reviewer approving payment could not open the document they were reasoning about. The /download endpoint forces an attachment, so the bytes are fetched as a Blob and shown inline via an object URL. Display metadata keys off state and activity UUIDs rather than the names the API returns, so nothing depends on matching "Financial Review" as a string. Verified against live app 169: instance 128 (3 decisions, 3 traces correctly linked, full match evidence, clarification Q&A) and 161 (OCR submission, PDF resolves and downloads, AI panels show empty states rather than errors). Co-Authored-By: Claude Opus 5 (1M context) --- src/api/viewService.ts | 124 +++++++++ src/components/AIActivityPanel.tsx | 343 +++++++++++++++++++++++++ src/components/AppShell.tsx | 12 +- src/components/AuditTimeline.tsx | 199 ++++++++++++++ src/components/InvoiceDetail.tsx | 280 ++++++++++++++++++++ src/components/InvoiceDocumentCard.tsx | 147 +++++++++++ src/components/ThreeWayMatchCard.tsx | 154 +++++++++++ src/hooks/useInstanceDetail.ts | 136 ++++++++++ src/lib/matchSummary.ts | 82 ++++++ src/lib/workflowMeta.ts | 157 +++++++++++ 10 files changed, 1633 insertions(+), 1 deletion(-) create mode 100644 src/components/AIActivityPanel.tsx create mode 100644 src/components/AuditTimeline.tsx create mode 100644 src/components/InvoiceDetail.tsx create mode 100644 src/components/InvoiceDocumentCard.tsx create mode 100644 src/components/ThreeWayMatchCard.tsx create mode 100644 src/hooks/useInstanceDetail.ts create mode 100644 src/lib/matchSummary.ts create mode 100644 src/lib/workflowMeta.ts diff --git a/src/api/viewService.ts b/src/api/viewService.ts index 19665a4..098624c 100644 --- a/src/api/viewService.ts +++ b/src/api/viewService.ts @@ -491,8 +491,10 @@ export interface AuditEntry { id: number; user_id: string; user_roles: string[]; + user_groups?: string[]; activity_id: string; data: Record; + /** State UUID the instance landed in after this activity. */ execution_state: string; created_at: string; // Optional human-readable summary line. Populated by useInstanceMeta when @@ -504,6 +506,128 @@ export function getAuditLog(instanceId: string): Promise { return request("GET", `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(instanceId)}`); } +// --------------------------------------------------------------------------- +// AI Employee monitoring (ai-employee-service) +// --------------------------------------------------------------------------- +// These are the same endpoints the studio builder's "AI Employee → Logs" tab +// uses. They are NOT app-scoped: the ingress maps studio.getzino.in/monitor +// straight to ai-employee-service:8090, so the path has no /app/{id} prefix. +// The app's own JWT is accepted. + +/** One reasoning pass the employee took, as stored in aiemployee.tbl_ai_decisions. */ +export interface AIDecision { + id: number; + ai_user_id: string; + instance_id: string; + /** The activity the employee SUBMITTED (its chosen next step), not the one it read. */ + activity_id: string; + /** State the instance was in when the employee looked at it. */ + instance_state?: string; + status: "submitted" | "failed" | "error" | "escalated" | "submitting" | string; + confidence?: number; + model?: string; + created_at: string; + /** Opening plan the model wrote before acting. */ + plan?: string; + /** Why it chose this action. */ + reasoning?: string; + /** Self-check pass. */ + reflexion?: { + concerns?: string; + should_escalate?: boolean; + adjusted_confidence?: number; + } | null; + knowledge_sources?: string[] | null; + /** The payload it submitted with the activity. */ + data?: Record; + error_message?: string; + cost?: number; + llm_calls?: number; + duration_ms?: number; +} + +export interface AIEscalation { + id: number; + instance_id: string; + recommended_action?: string; + reasoning?: string; + reason?: string; + status?: string; + created_at: string; +} + +export interface AIInstanceLog { + instance_id: string; + decisions: AIDecision[]; + escalations: AIEscalation[]; +} + +/** One step inside a run — a tool call, an LLM call, a context gather, etc. */ +export interface AITraceEvent { + seq: number; + parent_seq?: number; + type: string; + name: string; + status: string; + started_at: string; + ended_at?: string; + duration_ms?: number; + input?: unknown; + output?: unknown; + tokens_in?: number; + tokens_out?: number; + cost?: number; + error?: string; + decision_id?: number; +} + +/** One task the employee processed end-to-end. */ +export interface AITrace { + trace_id: string; + events: AITraceEvent[]; +} + +export interface AIInstanceTrace { + instance_id: string; + traces: AITrace[]; +} + +export function getAIInstanceLog(instanceId: string): Promise { + return request("GET", `/monitor/instances/${encodeURIComponent(instanceId)}/log`); +} + +export function getAIInstanceTrace(instanceId: string): Promise { + return request("GET", `/monitor/instances/${encodeURIComponent(instanceId)}/trace`); +} + +// --------------------------------------------------------------------------- +// Field-file download +// --------------------------------------------------------------------------- + +/** + * Fetches a stored form-field file (e.g. the uploaded invoice PDF) as a Blob. + * `blobPath` comes from the FileMeta the upload returned and is echoed back in + * the activity's submitted data. Returns a Blob so the caller can either build + * an object URL for inline preview or trigger a download — the endpoint sets + * Content-Disposition: attachment, so a plain link would always download. + */ +export async function downloadFieldFile(blobPath: string, originalName?: string): Promise { + const params = new URLSearchParams({ path: blobPath }); + if (originalName) params.set("name", originalName); + const token = localStorage.getItem(TOKEN_KEY); + const res = await fetch(`${baseUrl()}/app/${APP_ID}/download?${params.toString()}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) { + if (res.status === 401) { + localStorage.removeItem(TOKEN_KEY); + window.location.href = `${import.meta.env.BASE_URL}login`; + } + throw new Error(`Could not load the document (${res.status})`); + } + return res.blob(); +} + // --------------------------------------------------------------------------- // RDBMS lookup records — used by the demo calendar to pull rows from the // `mahindra_demo` schema via the lookup-field config on the INIT activity. diff --git a/src/components/AIActivityPanel.tsx b/src/components/AIActivityPanel.tsx new file mode 100644 index 0000000..c662bd4 --- /dev/null +++ b/src/components/AIActivityPanel.tsx @@ -0,0 +1,343 @@ +// AIActivityPanel — the AI employee's reasoning, brought into the app. +// +// Port of the studio builder's "AI Employee → Logs" tab +// (studio-frontend/src/components/monitoring/AIEmployeeLogsPanel.js + +// AIEmployeeTrace.jsx), narrowed to a single instance and restyled to match +// this app. Same data, same event vocabulary and confidence thresholds — a +// client shouldn't have to be shown the builder to see what the AI did. +// +// Two sources, both instance-scoped: +// /monitor/instances/{id}/log → decisions (+ escalations) +// /monitor/instances/{id}/trace → per-run step traces + +import { useMemo, useState } from "react"; +import { + Sparkles, ChevronDown, ChevronRight, AlertTriangle, Coins, Hash, Timer, Layers, +} from "lucide-react"; +import type { AIDecision, AIEscalation, AITrace, AITraceEvent } from "../api/viewService"; +import { activityLabel, fmtDuration, fmtRelative, fmtDateTime } from "../lib/workflowMeta"; + +// --------------------------------------------------------------------------- +// Event styling — mirrors studio's TYPE_META so the two read the same +// --------------------------------------------------------------------------- + +const TYPE_META: Record = { + context: { label: "Context", bar: "bg-slate-400", chip: "bg-slate-100 text-slate-600 dark:bg-zinc-800 dark:text-zinc-300" }, + plan: { label: "Plan", bar: "bg-violet-500", chip: "bg-violet-100 text-violet-700 dark:bg-violet-950/50 dark:text-violet-300" }, + llm: { label: "LLM", bar: "bg-blue-500", chip: "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300" }, + tool: { label: "Tool", bar: "bg-cyan-500", chip: "bg-cyan-100 text-cyan-700 dark:bg-cyan-950/50 dark:text-cyan-300" }, + api: { label: "API", bar: "bg-amber-500", chip: "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300" }, + knowledge: { label: "Knowledge", bar: "bg-emerald-500", chip: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300" }, + reflexion: { label: "Reflexion", bar: "bg-fuchsia-500", chip: "bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-950/50 dark:text-fuchsia-300" }, + decision: { label: "Decision", bar: "bg-emerald-600", chip: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300" }, + escalation: { label: "Escalation", bar: "bg-amber-600", chip: "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300" }, +}; +const FALLBACK = { label: "Step", bar: "bg-slate-400", chip: "bg-slate-100 text-slate-600 dark:bg-zinc-800 dark:text-zinc-300" }; +const metaFor = (t: string) => TYPE_META[t] ?? FALLBACK; + +const STATUS_CHIP: Record = { + submitted: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300", + submitting: "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300", + failed: "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300", + error: "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300", + escalated: "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300", +}; + +// Same thresholds studio uses: >=90 good, >=70 caution, below that a warning. +function ConfidencePill({ value }: { value?: number }) { + if (value == null) return null; + const pct = Math.round(value * 100); + const cls = pct >= 90 + ? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300" + : pct >= 70 + ? "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300" + : "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300"; + return {pct}%; +} + +const fmtCost = (c?: number): string | null => { + if (!c) return null; + return c < 0.0001 ? "<$0.0001" : `$${c.toFixed(4)}`; +}; + +const asText = (v: unknown): string => + v == null ? "" : typeof v === "string" ? v : JSON.stringify(v, null, 2); + +// --------------------------------------------------------------------------- +// Trace waterfall +// --------------------------------------------------------------------------- + +function Waterfall({ trace }: { trace: AITrace }) { + const [openSeq, setOpenSeq] = useState(null); + + const { t0, span, totals } = useMemo(() => { + const starts = trace.events.map((e) => new Date(e.started_at).getTime()).filter((n) => !Number.isNaN(n)); + const ends = trace.events.map((e) => + new Date(e.ended_at ?? e.started_at).getTime()).filter((n) => !Number.isNaN(n)); + const start = starts.length ? Math.min(...starts) : 0; + const end = ends.length ? Math.max(...ends) : start + 1; + return { + t0: start, + span: Math.max(end - start, 1), + totals: trace.events.reduce( + (acc, e) => ({ + tokens: acc.tokens + (e.tokens_in ?? 0) + (e.tokens_out ?? 0), + cost: acc.cost + (e.cost ?? 0), + }), + { tokens: 0, cost: 0 }, + ), + }; + }, [trace]); + + return ( +
+
+ {trace.events.length} steps + {fmtDuration(span)} + {totals.tokens > 0 && ( + {totals.tokens.toLocaleString()} tokens + )} + {fmtCost(totals.cost) && ( + {fmtCost(totals.cost)} + )} +
+ +
    + {trace.events.map((ev: AITraceEvent) => { + const m = metaFor(ev.type); + const s = new Date(ev.started_at).getTime(); + const dur = ev.duration_ms ?? Math.max(new Date(ev.ended_at ?? ev.started_at).getTime() - s, 0); + const left = Number.isNaN(s) ? 0 : ((s - t0) / span) * 100; + const width = Math.max((dur / span) * 100, 1.2); + const failed = ev.status && ev.status !== "ok" && ev.status !== "success"; + const isOpen = openSeq === ev.seq; + + return ( +
  1. + + + {isOpen && ( +
    + {(ev.tokens_in || ev.tokens_out) && ( +

    + {(ev.tokens_in ?? 0).toLocaleString()} in · {(ev.tokens_out ?? 0).toLocaleString()} out + {fmtCost(ev.cost) ? ` · ${fmtCost(ev.cost)}` : ""} +

    + )} + {ev.error && ( +

    {ev.error}

    + )} + {ev.input != null && ( + + )} + {ev.output != null && ( + + )} +
    + )} +
  2. + ); + })} +
+
+ ); +} + +function Snippet({ label, text }: { label: string; text: string }) { + if (!text.trim()) return null; + return ( +
+
{label}
+
+        {text}
+      
+
+ ); +} + +// --------------------------------------------------------------------------- +// Decision card +// --------------------------------------------------------------------------- + +function DecisionCard({ d, trace }: { d: AIDecision; trace?: AITrace }) { + const [open, setOpen] = useState(false); + const escalated = d.reflexion?.should_escalate; + + return ( +
+
+
+
+ + {activityLabel(d.activity_id)} + + + {d.status} + + +
+
+ {d.instance_state ? `saw "${d.instance_state}"` : null} + {d.model ? ` · ${d.model}` : ""} +
+
+ + {fmtRelative(d.created_at)} + +
+ + {d.reasoning && ( +

+ {d.reasoning} +

+ )} + + {d.error_message && ( +
+ + {d.error_message} +
+ )} + + {escalated && ( +
+ Flagged for human escalation +
+ )} + + + + {open && ( +
+ {d.plan && } + {d.reflexion?.concerns ? : null} + {d.reflexion?.adjusted_confidence != null && ( +

+ Adjusted confidence after self-check: {Math.round(d.reflexion.adjusted_confidence * 100)}% +

+ )} + {trace && } +
+ )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Panel +// --------------------------------------------------------------------------- + +interface Props { + decisions: AIDecision[]; + escalations: AIEscalation[]; + traces: AITrace[]; + unavailable?: boolean; +} + +export default function AIActivityPanel({ decisions, escalations, traces, unavailable }: Props) { + // Each trace carries the decision_id it belongs to on its events, so the + // waterfall can be attached to the decision it explains. + const traceByDecision = useMemo(() => { + const m = new Map(); + for (const t of traces) { + const id = t.events.find((e) => e.decision_id != null)?.decision_id; + if (id != null && !m.has(id)) m.set(id, t); + } + return m; + }, [traces]); + + const ordered = useMemo( + () => [...decisions].sort( + (a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()), + [decisions], + ); + + const totals = useMemo(() => { + let tokens = 0, cost = 0; + for (const t of traces) for (const e of t.events) { + tokens += (e.tokens_in ?? 0) + (e.tokens_out ?? 0); + cost += e.cost ?? 0; + } + return { tokens, cost }; + }, [traces]); + + return ( +
+
+

+ + AI Review + · Priya · AI Finance +

+
+ {ordered.length > 0 && {ordered.length} {ordered.length === 1 ? "pass" : "passes"}} + {totals.tokens > 0 && {totals.tokens.toLocaleString()} tokens} + {fmtCost(totals.cost) && {fmtCost(totals.cost)}} +
+
+ + {unavailable ? ( +

+ AI activity is unavailable right now — the monitoring service couldn't be reached. +

+ ) : ordered.length === 0 ? ( +

+ No AI review recorded for this invoice yet. +

+ ) : ( +
+ {ordered.map((d) => ( + + ))} +
+ )} + + {escalations.length > 0 && ( +
+

+ Escalations +

+
+ {escalations.map((e) => ( +
+ {e.recommended_action || "Escalated"} + {e.reason ? <> — {e.reason} : null} + · {fmtRelative(e.created_at)} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index f317e55..6081dc7 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -6,6 +6,7 @@ import DashboardStats from "./DashboardStats"; import ScreenRenderer from "./ScreenRenderer"; import { TableSkeleton } from "./Skeleton"; import DetailViewPanel from "./DetailViewPanel"; +import InvoiceDetail from "./InvoiceDetail"; import FormModal from "./FormModal"; import { getHeaderConfig, getScreen, getInstance, type LayoutElement, type NavItem } from "../api/viewService"; import { WORKFLOW_ID, ACTIVITY_IDS, STATE_IDS, SCREEN_UUIDS, RDBMS_RV_SCREEN_UUID, RDBMS_DV_SCREEN_UUID } from "../config"; @@ -210,7 +211,16 @@ export default function AppShell() { )} - {detailScreenLayout ? : } + {/* Workflow instances get the composed invoice page — hierarchy, + match evidence, source document, AI trail, history. An + RDBMS-backed detail screen is not a workflow instance (it has + no audit trail and no AI decisions), so it keeps the generic + screen/field renderer. */} + {activeScreenId && RDBMS_SCREENS[activeScreenId] + ? (detailScreenLayout + ? + : ) + : } ) : loading ? ( <> diff --git a/src/components/AuditTimeline.tsx b/src/components/AuditTimeline.tsx new file mode 100644 index 0000000..fcc5bf2 --- /dev/null +++ b/src/components/AuditTimeline.tsx @@ -0,0 +1,199 @@ +// AuditTimeline — the invoice's history as a vertical timeline. +// +// Built from GET /app/{id}/view/audit, which returns one row per performed +// activity with the actor (user_id + roles), the resulting state, the timestamp +// and the submitted payload. A single status pill can't explain how an invoice +// reached its state; this can — including which steps the AI took versus a +// person, and how long each hop waited. +// +// The helper for this endpoint existed in viewService.ts and was never called +// from anywhere in the UI. + +import { useState } from "react"; +import { + Sparkles, User, ChevronRight, FileUp, HelpCircle, MessageSquareReply, + Gavel, CheckCircle2, XCircle, PauseCircle, Clock, +} from "lucide-react"; +import type { AuditEntry } from "../api/viewService"; +import { ACTIVITY_IDS } from "../config"; +import { + activityLabel, stateMeta, resolveActor, fmtDateTime, fmtRelative, fmtGap, + TONE_DOT, TONE_PILL, fmtAmount, +} from "../lib/workflowMeta"; + +const ACTIVITY_ICON: Record = { + [ACTIVITY_IDS.SUBMIT_INVOICE]: , + [ACTIVITY_IDS.REQUEST_CLARIFICATION]: , + [ACTIVITY_IDS.PROVIDE_CLARIFICATION]: , + [ACTIVITY_IDS.SUBMIT_RECOMMENDATION]: , + [ACTIVITY_IDS.APPROVE_PAYMENT]: , + [ACTIVITY_IDS.REJECT_PAYMENT]: , + [ACTIVITY_IDS.HOLD_PAYMENT]: , +}; + +// Per-activity headline: the one or two values that matter at a glance, so the +// timeline reads as a story instead of a list of generic event names. +function summarise(e: AuditEntry): string | null { + const d = (e.data ?? {}) as Record; + switch (e.activity_id) { + case ACTIVITY_IDS.SUBMIT_INVOICE: + return [d.invoice_number, d.po_number, d.total_amount != null ? fmtAmount(d.total_amount, d.field_5 || "INR") : null] + .filter(Boolean).join(" · ") || null; + case ACTIVITY_IDS.REQUEST_CLARIFICATION: + return d.target_department ? `Asked ${String(d.target_department).replace(/_/g, " ")}` : null; + case ACTIVITY_IDS.PROVIDE_CLARIFICATION: + return d.response_text ? String(d.response_text) : null; + case ACTIVITY_IDS.SUBMIT_RECOMMENDATION: + return [ + d.recommendation ? String(d.recommendation).toUpperCase() : null, + d.recommended_amount != null ? fmtAmount(d.recommended_amount) : null, + d.ai_confidence != null ? `${Math.round(Number(d.ai_confidence) * 100)}% confidence` : null, + ].filter(Boolean).join(" · ") || null; + case ACTIVITY_IDS.APPROVE_PAYMENT: + return d.approved_amount != null ? fmtAmount(d.approved_amount) : null; + case ACTIVITY_IDS.REJECT_PAYMENT: + return d.rejection_reason ? String(d.rejection_reason) : null; + case ACTIVITY_IDS.HOLD_PAYMENT: + return d.hold_reason ? String(d.hold_reason) : null; + default: + return null; + } +} + +// Fields already shown in the headline, or internal — don't repeat them in the +// expanded payload. +const HIDE_KEYS = new Set([ + "invoice_pdf", "analysis_so_far", "_nodes", "_http", "_responsePayload", +]); + +function PayloadGrid({ data }: { data: Record }) { + const rows = Object.entries(data).filter( + ([k, v]) => !HIDE_KEYS.has(k) && v !== null && v !== "" && typeof v !== "object", + ); + if (rows.length === 0) return null; + return ( +
+ {rows.map(([k, v]) => ( +
+
+ {k.replace(/_/g, " ")} +
+
{String(v)}
+
+ ))} +
+ ); +} + +export default function AuditTimeline({ entries }: { entries: AuditEntry[] }) { + const [expanded, setExpanded] = useState>(new Set()); + + const toggle = (id: number) => + setExpanded((prev) => { + const next = new Set(prev); + next.has(id) ? next.delete(id) : next.add(id); + return next; + }); + + if (entries.length === 0) { + return ( +
+

History

+

No activity recorded yet.

+
+ ); + } + + return ( +
+
+

+ History +

+ + {entries.length} {entries.length === 1 ? "step" : "steps"} + +
+ +
    + {entries.map((e, i) => { + const actor = resolveActor(e.user_roles, e.user_id); + const st = stateMeta(e.execution_state); + const isLast = i === entries.length - 1; + const gap = i > 0 ? fmtGap(entries[i - 1].created_at, e.created_at) : ""; + const headline = summarise(e); + const isOpen = expanded.has(e.id); + + return ( +
  1. + {/* Rail */} + {!isLast && ( + + )} + {/* Node */} + + {ACTIVITY_ICON[e.activity_id] ?? } + + +
    +
    +
    + + {activityLabel(e.activity_id)} + + + {actor.isAI ? : }{actor.name} + +
    + + {headline && ( +

    + {headline} +

    + )} + +
    + + + {st.label} + + +
    + + {isOpen && } />} +
    + +
    +
    + {fmtRelative(e.created_at)} +
    + {gap && ( +
    + +{gap} +
    + )} +
    +
    +
  2. + ); + })} +
+
+ ); +} diff --git a/src/components/InvoiceDetail.tsx b/src/components/InvoiceDetail.tsx new file mode 100644 index 0000000..eced84d --- /dev/null +++ b/src/components/InvoiceDetail.tsx @@ -0,0 +1,280 @@ +// InvoiceDetail — the invoice page, given a hierarchy. +// +// It used to render a flat grid of every field the detail view returned: 29 for +// Invoice Detail, 21 for Finance Review Detail. Grouped, but with no ranking, so +// the reviewer's actual decision inputs — the recommendation, its confidence, +// the match result, the amount — sat among twenty peers of equal weight. +// +// Order here follows the decision the viewer has to make: +// +// 1. Identity + status who/what/where in the flow +// 2. AI recommendation the answer, up front +// 3. 3-way match evidence why that answer is defensible +// 4. Clarification Q&A the human exchange, if there was one +// 5. Source document the PDF being reasoned about +// 6. AI review the full reasoning trail, on demand +// 7. History how it got here +// 8. All fields the original grid, collapsed +// +// Everything derives from one fetch pass (useInstanceDetail). + +import { useState } from "react"; +import { + ChevronDown, ChevronRight, Sparkles, MessageSquare, CheckCircle2, + XCircle, PauseCircle, HelpCircle, Loader2, AlertCircle, ListTree, +} from "lucide-react"; +import DetailViewPanel from "./DetailViewPanel"; +import ThreeWayMatchCard from "./ThreeWayMatchCard"; +import InvoiceDocumentCard from "./InvoiceDocumentCard"; +import AIActivityPanel from "./AIActivityPanel"; +import AuditTimeline from "./AuditTimeline"; +import { useInstanceDetail } from "../hooks/useInstanceDetail"; +import { stateMeta, TONE_PILL, TONE_DOT, fmtAmount, fmtDateTime } from "../lib/workflowMeta"; + +interface Props { + instanceId: string; + /** dv/screen uuid used for the collapsed full-field grid. */ + viewId: number | string; + /** Current state UUID, from the instance. */ + stateId?: string | null; +} + +const REC_STYLE: Record = { + approve: { tone: "text-emerald-700 bg-emerald-50 ring-emerald-200 dark:text-emerald-300 dark:bg-emerald-950/40 dark:ring-emerald-800/60", icon: , label: "Approve" }, + partial_approve: { tone: "text-amber-700 bg-amber-50 ring-amber-200 dark:text-amber-300 dark:bg-amber-950/40 dark:ring-amber-800/60", icon: , label: "Partial approve" }, + reject: { tone: "text-red-700 bg-red-50 ring-red-200 dark:text-red-300 dark:bg-red-950/40 dark:ring-red-800/60", icon: , label: "Reject" }, + hold: { tone: "text-slate-600 bg-slate-100 ring-slate-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700", icon: , label: "Hold" }, +}; + +const str = (v: unknown): string | null => { + if (v == null) return null; + const s = String(v).trim(); + return s === "" ? null : s; +}; + +export default function InvoiceDetail({ instanceId, viewId, stateId }: Props) { + const d = useInstanceDetail(instanceId); + const [showAll, setShowAll] = useState(false); + + if (d.loading) { + return ( +
+ + Loading invoice… +
+ ); + } + + if (d.error) { + return ( +
+ {d.error} +
+ ); + } + + const s = d.submitted; + const cur = String(s.field_5 || "INR"); + const st = stateMeta(stateId); + + const recommendation = str(s.recommendation)?.toLowerCase(); + const rec = recommendation ? REC_STYLE[recommendation] : undefined; + const confidence = s.ai_confidence != null ? Number(s.ai_confidence) : undefined; + + const question = str(s.clarification_question); + const answer = str(s.response_text); + + return ( +
+ {/* ── 1. Identity + status ─────────────────────────────────────────── */} +
+
+
+
+

+ {str(s.invoice_number) ?? `Invoice #${instanceId}`} +

+ + {st.label} + +
+

+ {str(s.vendor_name) ?? "Unknown vendor"} + {str(s.vendor_id) ? ` · ${str(s.vendor_id)}` : ""} +

+
+
+
+ {fmtAmount(s.total_amount, cur)} +
+ {s.tax_amount != null && ( +
+ incl. tax {fmtAmount(s.tax_amount, cur)} +
+ )} +
+
+ +
+ + + + + +
+ + {str(s.notes) && ( +

+ Vendor notes: {str(s.notes)} +

+ )} +
+ + {/* ── 2. AI recommendation ─────────────────────────────────────────── */} + {rec && ( +
+
+ +

AI recommendation

+
+
+ + {rec.icon}{rec.label} + + {s.recommended_amount != null && ( +
+ Recommended + + {fmtAmount(s.recommended_amount, cur)} + +
+ )} + {confidence != null && Number.isFinite(confidence) && ( + + {Math.round(confidence * 100)}% confidence + + )} +
+ {str(s.ai_reasoning) && ( +

+ {str(s.ai_reasoning)} +

+ )} +
+ )} + + {/* ── 3. Match evidence ────────────────────────────────────────────── */} + + + {/* ── 4. Clarification exchange ────────────────────────────────────── */} + {(question || answer) && ( +
+

+ Clarification + {str(s.target_department) && ( + + · {String(s.target_department).replace(/_/g, " ")} + + )} +

+
+ {question && ( + + )} + {answer && ( + + )} + {question && !answer && ( +
+ Awaiting a response. +
+ )} +
+
+ )} + + {/* ── 5. Source document ───────────────────────────────────────────── */} + + + {/* ── 6. AI reasoning trail ────────────────────────────────────────── */} + + + {/* ── 7. History ───────────────────────────────────────────────────── */} + + + {/* ── 8. Everything else, collapsed ────────────────────────────────── */} +
+ + {showAll && ( +
+ +
+ )} +
+
+ ); +} + +function Meta({ label, value }: { label: string; value?: string | null }) { + if (!value) return null; + return ( +
+
{label}
+
{value}
+
+ ); +} + +function Bubble({ who, text, context, ai }: { who: string; text: string; context?: string | null; ai?: boolean }) { + return ( +
+
+ {who} +
+

{text}

+ {context && ( +

+ {context} +

+ )} +
+ ); +} diff --git a/src/components/InvoiceDocumentCard.tsx b/src/components/InvoiceDocumentCard.tsx new file mode 100644 index 0000000..997bdc5 --- /dev/null +++ b/src/components/InvoiceDocumentCard.tsx @@ -0,0 +1,147 @@ +// InvoiceDocumentCard — the source invoice PDF, viewable by whoever is looking +// at the record. +// +// Before this, `invoice_pdf` was write-only: the vendor uploaded it, OCR read +// it, and then it appeared in no record view and no detail view — so the +// department answering a clarification and the reviewer approving payment +// couldn't open the document they were reasoning about. +// +// The /download endpoint sets Content-Disposition: attachment, so a plain +// would always download. Fetching a Blob and building an object URL instead +// lets the same bytes be shown inline AND opened/downloaded on demand. + +import { useEffect, useRef, useState } from "react"; +import { FileText, Download, ExternalLink, AlertCircle, Loader2, Eye } from "lucide-react"; +import { downloadFieldFile, type FileMeta } from "../api/viewService"; +import { fmtBytes } from "../lib/workflowMeta"; + +interface Props { + file: FileMeta | null; + /** Render the preview open on mount (reviewers want it; list-side callers may not). */ + defaultOpen?: boolean; +} + +export default function InvoiceDocumentCard({ file, defaultOpen = false }: Props) { + const [url, setUrl] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [open, setOpen] = useState(defaultOpen); + const urlRef = useRef(null); + + // Fetch once, on first expand. Object URLs must be revoked or the blob leaks + // for the lifetime of the document. + useEffect(() => { + if (!file || !open || urlRef.current) return; + let cancelled = false; + setLoading(true); + setError(null); + downloadFieldFile(file.blob_path, file.original_name) + .then((blob) => { + if (cancelled) return; + const objUrl = URL.createObjectURL(blob); + urlRef.current = objUrl; + setUrl(objUrl); + }) + .catch((e: any) => { if (!cancelled) setError(e?.message || "Could not load the document"); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [file, open]); + + useEffect(() => () => { + if (urlRef.current) URL.revokeObjectURL(urlRef.current); + }, []); + + const save = () => { + if (!url || !file) return; + const a = document.createElement("a"); + a.href = url; + a.download = file.original_name || "invoice.pdf"; + a.click(); + }; + + if (!file) { + return ( +
+
+ + No invoice document was attached to this submission. +
+
+ ); + } + + const isPdf = (file.mime_type || "").includes("pdf"); + + return ( +
+ + + {open && ( +
+ {loading && ( +
+ + Loading document… +
+ )} + {error && ( +
+ {error} +
+ )} + {url && !loading && !error && ( + isPdf ? ( +