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>
155 lines
7.5 KiB
TypeScript
155 lines
7.5 KiB
TypeScript
// ThreeWayMatchCard — makes the AI's recommendation auditable.
|
|
//
|
|
// The employee reconciles invoice vs purchase order vs goods receipt vs payment
|
|
// history through four agentic tools, then writes its findings as prose into two
|
|
// fields. Both were previously invisible in the UI: `match_summary` was buried
|
|
// among ~29 flat fields, and `analysis_so_far` was explicitly dropped by
|
|
// DetailViewPanel's SKIP_KEYS because it looked like raw JSON.
|
|
//
|
|
// They're actually semi-structured, straight from the deployed data:
|
|
//
|
|
// match_summary "Price: MATCH (₹550/unit per contract amendment …).
|
|
// Quantity: MATCH (200 units invoiced = 200 units received
|
|
// per GR-3001). Goods Receipt: VALID (…). Duplicate Check:
|
|
// CLEAN (₹0 prior payments). Contract: ACTIVE with …"
|
|
//
|
|
// analysis_so_far "PO-3001: 200 units @ ₹500/unit = ₹100,000. GR-3001: 200
|
|
// units received, QC passed. Contract CNT-300: active,
|
|
// ₹500/unit, 2% tolerance. Invoice: 200 units @ ₹550/unit =
|
|
// ₹110,000. Payment history: clean (₹0 paid). Issue: 10%
|
|
// price variance exceeds 2% tolerance."
|
|
//
|
|
// So each parses into label → value pairs, with match_summary additionally
|
|
// carrying a verdict keyword the reviewer can scan in one pass.
|
|
|
|
import { CheckCircle2, XCircle, AlertTriangle, MinusCircle, Scale } from "lucide-react";
|
|
import { fmtAmount } from "../lib/workflowMeta";
|
|
import { parseMatchSummary, parseEvidence, overallTone, type Tone } from "../lib/matchSummary";
|
|
|
|
const TONE_STYLE: Record<Tone, { chip: string; icon: React.ReactNode }> = {
|
|
good: { chip: "text-emerald-700 bg-emerald-50 ring-1 ring-emerald-200 dark:text-emerald-300 dark:bg-emerald-950/40 dark:ring-emerald-800/60", icon: <CheckCircle2 size={12} /> },
|
|
bad: { chip: "text-red-700 bg-red-50 ring-1 ring-red-200 dark:text-red-300 dark:bg-red-950/40 dark:ring-red-800/60", icon: <XCircle size={12} /> },
|
|
warn: { chip: "text-amber-700 bg-amber-50 ring-1 ring-amber-200 dark:text-amber-300 dark:bg-amber-950/40 dark:ring-amber-800/60", icon: <AlertTriangle size={12} /> },
|
|
neutral: { chip: "text-slate-600 bg-slate-100 ring-1 ring-slate-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700", icon: <MinusCircle size={12} /> },
|
|
};
|
|
|
|
interface Props {
|
|
matchSummary?: string;
|
|
analysis?: string;
|
|
/** The invoice's own submitted figures, for the reference row. */
|
|
invoice?: {
|
|
invoice_number?: unknown;
|
|
po_number?: unknown;
|
|
total_amount?: unknown;
|
|
tax_amount?: unknown;
|
|
currency?: unknown;
|
|
};
|
|
}
|
|
|
|
export default function ThreeWayMatchCard({ matchSummary, analysis, invoice }: Props) {
|
|
const verdicts = parseMatchSummary(matchSummary);
|
|
const evidence = parseEvidence(analysis);
|
|
|
|
if (verdicts.length === 0 && evidence.length === 0) {
|
|
return (
|
|
<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-1">
|
|
<Scale size={14} className="text-slate-400" />3-Way Match
|
|
</h3>
|
|
<p className="text-[12.5px] text-slate-400 dark:text-zinc-500">
|
|
The AI hasn't reconciled this invoice against its PO and goods receipt yet.
|
|
</p>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
const worst = overallTone(verdicts);
|
|
const headline =
|
|
worst === "bad" ? "Discrepancy found" : worst === "warn" ? "Needs attention" : "All checks passed";
|
|
|
|
const cur = String(invoice?.currency || "INR");
|
|
|
|
return (
|
|
<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 justify-between gap-3 mb-3 flex-wrap">
|
|
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 flex items-center gap-2">
|
|
<Scale size={14} className="text-slate-400" />3-Way Match
|
|
</h3>
|
|
{verdicts.length > 0 && (
|
|
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-semibold ${TONE_STYLE[worst].chip}`}>
|
|
{TONE_STYLE[worst].icon}{headline}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Reference figures from the invoice itself */}
|
|
{invoice && (invoice.invoice_number || invoice.po_number || invoice.total_amount != null) && (
|
|
<div className="flex items-center gap-5 flex-wrap mb-3 pb-3 border-b border-slate-100 dark:border-zinc-800">
|
|
{invoice.invoice_number ? (
|
|
<Ref label="Invoice" value={String(invoice.invoice_number)} />
|
|
) : null}
|
|
{invoice.po_number ? <Ref label="Purchase order" value={String(invoice.po_number)} /> : null}
|
|
{invoice.total_amount != null ? (
|
|
<Ref label="Invoiced" value={fmtAmount(invoice.total_amount, cur)} strong />
|
|
) : null}
|
|
{invoice.tax_amount != null ? <Ref label="Tax" value={fmtAmount(invoice.tax_amount, cur)} /> : null}
|
|
</div>
|
|
)}
|
|
|
|
{/* Per-dimension verdicts */}
|
|
{verdicts.length > 0 && (
|
|
<ul className="space-y-2">
|
|
{verdicts.map((v, i) => (
|
|
<li key={`${v.label}-${i}`} className="flex items-start gap-2.5">
|
|
<span className={`shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-wide mt-0.5 ${TONE_STYLE[v.tone].chip}`}>
|
|
{TONE_STYLE[v.tone].icon}{v.verdict || "—"}
|
|
</span>
|
|
<div className="min-w-0">
|
|
<div className="text-[12.5px] font-medium text-slate-700 dark:text-zinc-200">{v.label}</div>
|
|
{v.detail && (
|
|
<div className="text-[12px] text-slate-500 dark:text-zinc-400 leading-relaxed">{v.detail}</div>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
|
|
{/* Supporting evidence the AI gathered from PO / GR / contract / payments */}
|
|
{evidence.length > 0 && (
|
|
<div className={verdicts.length > 0 ? "mt-3 pt-3 border-t border-slate-100 dark:border-zinc-800" : ""}>
|
|
<div className="text-[10.5px] uppercase tracking-wide text-slate-400 dark:text-zinc-600 mb-2">
|
|
Evidence gathered
|
|
</div>
|
|
<dl className="grid sm:grid-cols-2 gap-x-6 gap-y-2">
|
|
{evidence.map((e, i) => (
|
|
<div
|
|
key={`${e.label}-${i}`}
|
|
className={e.isIssue ? "sm:col-span-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 px-2.5 py-1.5" : ""}
|
|
>
|
|
<dt className={`text-[10.5px] uppercase tracking-wide ${e.isIssue ? "text-amber-700 dark:text-amber-400" : "text-slate-400 dark:text-zinc-600"}`}>
|
|
{e.label}
|
|
</dt>
|
|
<dd className={`text-[12.5px] ${e.isIssue ? "text-amber-800 dark:text-amber-200 font-medium" : "text-slate-700 dark:text-zinc-200"}`}>
|
|
{e.value}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Ref({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
|
|
return (
|
|
<div>
|
|
<div className="text-[10px] uppercase tracking-wide text-slate-400 dark:text-zinc-600">{label}</div>
|
|
<div className={`${strong ? "text-[14px] font-bold" : "text-[12.5px] font-medium"} text-slate-800 dark:text-zinc-100 tabular-nums`}>
|
|
{value}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|