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/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 ? ( +