p2p/src/components/InvoiceDetail.tsx
Yashas 921ff993c4 show the AI's work on the invoice page: timeline, decisions, match evidence, PDF
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) <noreply@anthropic.com>
2026-07-29 16:45:26 +05:30

281 lines
13 KiB
TypeScript

// 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<string, { tone: string; icon: React.ReactNode; label: string }> = {
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: <CheckCircle2 size={13} />, 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: <PauseCircle size={13} />, 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: <XCircle size={13} />, 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: <PauseCircle size={13} />, 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 (
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 py-16 flex flex-col items-center gap-2">
<Loader2 size={20} className="animate-spin text-violet-500" />
<span className="text-[12px] text-slate-400 dark:text-zinc-500">Loading invoice</span>
</div>
);
}
if (d.error) {
return (
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-red-200 dark:border-red-900 px-5 py-4 flex items-start gap-2 text-[13px] text-red-600 dark:text-red-400">
<AlertCircle size={15} className="shrink-0 mt-0.5" />{d.error}
</div>
);
}
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 (
<div className="space-y-4">
{/* ── 1. Identity + status ─────────────────────────────────────────── */}
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<h2 className="text-[18px] font-bold text-slate-900 dark:text-zinc-50 tracking-tight">
{str(s.invoice_number) ?? `Invoice #${instanceId}`}
</h2>
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-semibold ${TONE_PILL[st.tone]}`}>
<span className={`w-1.5 h-1.5 rounded-full ${TONE_DOT[st.tone]}`} />{st.label}
</span>
</div>
<p className="text-[13px] text-slate-500 dark:text-zinc-400 mt-0.5">
{str(s.vendor_name) ?? "Unknown vendor"}
{str(s.vendor_id) ? ` · ${str(s.vendor_id)}` : ""}
</p>
</div>
<div className="text-right">
<div className="text-[22px] font-bold tabular-nums text-slate-900 dark:text-zinc-50 leading-none">
{fmtAmount(s.total_amount, cur)}
</div>
{s.tax_amount != null && (
<div className="text-[11px] text-slate-400 dark:text-zinc-500 mt-1">
incl. tax {fmtAmount(s.tax_amount, cur)}
</div>
)}
</div>
</div>
<div className="flex items-center gap-6 flex-wrap mt-3.5 pt-3.5 border-t border-slate-100 dark:border-zinc-800">
<Meta label="Purchase order" value={str(s.po_number)} />
<Meta label="Invoice date" value={str(s.invoice_date)} />
<Meta label="Payment terms" value={str(s.payment_terms)} />
<Meta label="Currency" value={cur} />
<Meta label="Record" value={`#${instanceId}`} />
</div>
{str(s.notes) && (
<p className="mt-3 text-[12.5px] text-slate-500 dark:text-zinc-400">
<span className="text-slate-400 dark:text-zinc-600">Vendor notes: </span>{str(s.notes)}
</p>
)}
</section>
{/* ── 2. AI recommendation ─────────────────────────────────────────── */}
{rec && (
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
<div className="flex items-center gap-2 mb-2.5">
<Sparkles size={14} className="text-violet-500" />
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100">AI recommendation</h3>
</div>
<div className="flex items-center gap-3 flex-wrap">
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[12.5px] font-bold ring-1 ${rec.tone}`}>
{rec.icon}{rec.label}
</span>
{s.recommended_amount != null && (
<div>
<span className="text-[11px] text-slate-400 dark:text-zinc-500">Recommended </span>
<span className="text-[14px] font-bold tabular-nums text-slate-800 dark:text-zinc-100">
{fmtAmount(s.recommended_amount, cur)}
</span>
</div>
)}
{confidence != null && Number.isFinite(confidence) && (
<span className="text-[11px] text-slate-400 dark:text-zinc-500">
{Math.round(confidence * 100)}% confidence
</span>
)}
</div>
{str(s.ai_reasoning) && (
<p className="text-[12.5px] leading-relaxed text-slate-600 dark:text-zinc-300 mt-2.5">
{str(s.ai_reasoning)}
</p>
)}
</section>
)}
{/* ── 3. Match evidence ────────────────────────────────────────────── */}
<ThreeWayMatchCard
matchSummary={str(s.match_summary) ?? undefined}
analysis={str(s.analysis_so_far) ?? undefined}
invoice={{
invoice_number: s.invoice_number,
po_number: s.po_number,
total_amount: s.total_amount,
tax_amount: s.tax_amount,
currency: cur,
}}
/>
{/* ── 4. Clarification exchange ────────────────────────────────────── */}
{(question || answer) && (
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 flex items-center gap-2 mb-3">
<MessageSquare size={14} className="text-slate-400" />Clarification
{str(s.target_department) && (
<span className="font-normal text-slate-400 dark:text-zinc-500">
· {String(s.target_department).replace(/_/g, " ")}
</span>
)}
</h3>
<div className="space-y-3">
{question && (
<Bubble
who="Priya · AI Finance"
ai
text={question}
context={str(s.clarification_context)}
/>
)}
{answer && (
<Bubble
who={str(s.target_department)?.replace(/_/g, " ") ?? "Department"}
text={answer}
context={str(s.response_notes)}
/>
)}
{question && !answer && (
<div className="flex items-center gap-1.5 text-[12px] text-amber-700 dark:text-amber-400">
<HelpCircle size={12} />Awaiting a response.
</div>
)}
</div>
</section>
)}
{/* ── 5. Source document ───────────────────────────────────────────── */}
<InvoiceDocumentCard file={d.invoicePdf} />
{/* ── 6. AI reasoning trail ────────────────────────────────────────── */}
<AIActivityPanel
decisions={d.decisions}
escalations={d.escalations}
traces={d.traces}
unavailable={d.aiUnavailable}
/>
{/* ── 7. History ───────────────────────────────────────────────────── */}
<AuditTimeline entries={d.audit} />
{/* ── 8. Everything else, collapsed ────────────────────────────────── */}
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60">
<button
type="button"
onClick={() => setShowAll((v) => !v)}
className="w-full px-5 py-3.5 flex items-center gap-2 text-[13px] font-semibold text-slate-700 dark:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-800/60 rounded-2xl transition-colors"
>
{showAll ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
<ListTree size={14} className="text-slate-400" />
All invoice fields
</button>
{showAll && (
<div className="px-5 pb-5 pt-1 border-t border-slate-100 dark:border-zinc-800">
<DetailViewPanel viewId={viewId} instanceId={instanceId} />
</div>
)}
</section>
</div>
);
}
function Meta({ label, value }: { label: string; value?: string | null }) {
if (!value) return null;
return (
<div>
<div className="text-[10px] uppercase tracking-wide text-slate-400 dark:text-zinc-600">{label}</div>
<div className="text-[12.5px] font-medium text-slate-700 dark:text-zinc-200">{value}</div>
</div>
);
}
function Bubble({ who, text, context, ai }: { who: string; text: string; context?: string | null; ai?: boolean }) {
return (
<div className={`rounded-xl px-3.5 py-2.5 ${
ai
? "bg-violet-50/70 dark:bg-violet-950/25 border border-violet-100 dark:border-violet-900/40"
: "bg-slate-50 dark:bg-zinc-800/50 border border-slate-100 dark:border-zinc-700/60"
}`}>
<div className={`text-[10.5px] font-semibold uppercase tracking-wide mb-1 ${
ai ? "text-violet-600 dark:text-violet-400" : "text-slate-500 dark:text-zinc-400"
}`}>
{who}
</div>
<p className="text-[12.5px] leading-relaxed text-slate-700 dark:text-zinc-200 whitespace-pre-wrap">{text}</p>
{context && (
<p className="text-[11.5px] leading-relaxed text-slate-500 dark:text-zinc-400 mt-1.5 pt-1.5 border-t border-slate-200/70 dark:border-zinc-700/50">
{context}
</p>
)}
</div>
);
}