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>
This commit is contained in:
Yashas 2026-07-29 16:45:26 +05:30
parent dc2e281347
commit 921ff993c4
10 changed files with 1633 additions and 1 deletions

View File

@ -491,8 +491,10 @@ export interface AuditEntry {
id: number;
user_id: string;
user_roles: string[];
user_groups?: string[];
activity_id: string;
data: Record<string, unknown>;
/** 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<AuditEntry[]> {
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<string, unknown>;
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<AIInstanceLog> {
return request("GET", `/monitor/instances/${encodeURIComponent(instanceId)}/log`);
}
export function getAIInstanceTrace(instanceId: string): Promise<AIInstanceTrace> {
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<Blob> {
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.

View File

@ -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<string, { label: string; bar: string; chip: string }> = {
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<string, string> = {
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 <span className={`px-1.5 py-0.5 rounded text-[10.5px] font-bold ${cls}`}>{pct}%</span>;
}
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<number | null>(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 (
<div className="mt-2">
<div className="flex items-center gap-4 mb-2 text-[10.5px] text-slate-400 dark:text-zinc-500">
<span className="inline-flex items-center gap-1"><Layers size={10} />{trace.events.length} steps</span>
<span className="inline-flex items-center gap-1"><Timer size={10} />{fmtDuration(span)}</span>
{totals.tokens > 0 && (
<span className="inline-flex items-center gap-1"><Hash size={10} />{totals.tokens.toLocaleString()} tokens</span>
)}
{fmtCost(totals.cost) && (
<span className="inline-flex items-center gap-1"><Coins size={10} />{fmtCost(totals.cost)}</span>
)}
</div>
<ol className="space-y-1">
{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 (
<li key={ev.seq}>
<button
type="button"
onClick={() => setOpenSeq(isOpen ? null : ev.seq)}
className="w-full text-left group"
>
<div className="flex items-center gap-2">
<span className={`shrink-0 px-1.5 py-0.5 rounded text-[9.5px] font-bold uppercase tracking-wide ${m.chip}`}>
{m.label}
</span>
<span className="text-[11.5px] text-slate-600 dark:text-zinc-300 truncate max-w-[190px] group-hover:text-violet-600 dark:group-hover:text-violet-400">
{ev.name}
</span>
{/* Waterfall lane */}
<span className="flex-1 h-[7px] bg-slate-100 dark:bg-zinc-800 rounded-full relative overflow-hidden min-w-[60px]">
<span
className={`absolute top-0 bottom-0 rounded-full ${failed ? "bg-red-500" : m.bar}`}
style={{ left: `${left}%`, width: `${width}%` }}
/>
</span>
<span className="shrink-0 text-[10.5px] tabular-nums text-slate-400 dark:text-zinc-500 w-14 text-right">
{fmtDuration(dur)}
</span>
</div>
</button>
{isOpen && (
<div className="ml-2 mt-1.5 mb-2 pl-3 border-l-2 border-slate-100 dark:border-zinc-800 space-y-2">
{(ev.tokens_in || ev.tokens_out) && (
<p className="text-[10.5px] text-slate-400 dark:text-zinc-500">
{(ev.tokens_in ?? 0).toLocaleString()} in · {(ev.tokens_out ?? 0).toLocaleString()} out
{fmtCost(ev.cost) ? ` · ${fmtCost(ev.cost)}` : ""}
</p>
)}
{ev.error && (
<p className="text-[11.5px] text-red-600 dark:text-red-400">{ev.error}</p>
)}
{ev.input != null && (
<Snippet label="Input" text={asText(ev.input)} />
)}
{ev.output != null && (
<Snippet label={ev.type === "reflexion" ? "Self-critique" : ev.type === "plan" ? "Plan" : "Output"} text={asText(ev.output)} />
)}
</div>
)}
</li>
);
})}
</ol>
</div>
);
}
function Snippet({ label, text }: { label: string; text: string }) {
if (!text.trim()) return null;
return (
<div>
<div className="text-[10px] uppercase tracking-wide text-slate-400 dark:text-zinc-600 mb-0.5">{label}</div>
<pre className="text-[11px] leading-relaxed text-slate-600 dark:text-zinc-300 bg-slate-50 dark:bg-zinc-800/60 rounded-lg px-2.5 py-2 max-h-52 overflow-auto whitespace-pre-wrap break-words">
{text}
</pre>
</div>
);
}
// ---------------------------------------------------------------------------
// Decision card
// ---------------------------------------------------------------------------
function DecisionCard({ d, trace }: { d: AIDecision; trace?: AITrace }) {
const [open, setOpen] = useState(false);
const escalated = d.reflexion?.should_escalate;
return (
<div className="rounded-xl border border-slate-200/80 dark:border-zinc-700/60 px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100">
{activityLabel(d.activity_id)}
</span>
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold uppercase ${STATUS_CHIP[d.status] ?? "bg-slate-100 text-slate-600 dark:bg-zinc-800 dark:text-zinc-400"}`}>
{d.status}
</span>
<ConfidencePill value={d.confidence} />
</div>
<div className="text-[10.5px] text-slate-400 dark:text-zinc-500 mt-0.5">
{d.instance_state ? `saw "${d.instance_state}"` : null}
{d.model ? ` · ${d.model}` : ""}
</div>
</div>
<span className="text-[11px] text-slate-400 dark:text-zinc-500 shrink-0" title={fmtDateTime(d.created_at)}>
{fmtRelative(d.created_at)}
</span>
</div>
{d.reasoning && (
<p className="text-[12.5px] leading-relaxed text-slate-600 dark:text-zinc-300 mt-2">
{d.reasoning}
</p>
)}
{d.error_message && (
<div className="mt-2 flex items-start gap-1.5 text-[11.5px] text-red-600 dark:text-red-400">
<AlertTriangle size={12} className="shrink-0 mt-0.5" />
<span className="break-words">{d.error_message}</span>
</div>
)}
{escalated && (
<div className="mt-2 flex items-center gap-1.5 text-[11.5px] text-amber-700 dark:text-amber-400">
<AlertTriangle size={12} />Flagged for human escalation
</div>
)}
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="mt-2 text-[11px] text-slate-400 dark:text-zinc-500 hover:text-violet-600 dark:hover:text-violet-400 inline-flex items-center gap-1 transition-colors"
>
{open ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
{open ? "Hide reasoning trail" : "Reasoning trail"}
</button>
{open && (
<div className="mt-2 space-y-2.5">
{d.plan && <Snippet label="Plan" text={d.plan} />}
{d.reflexion?.concerns ? <Snippet label="Concerns" text={d.reflexion.concerns} /> : null}
{d.reflexion?.adjusted_confidence != null && (
<p className="text-[11px] text-slate-400 dark:text-zinc-500">
Adjusted confidence after self-check: {Math.round(d.reflexion.adjusted_confidence * 100)}%
</p>
)}
{trace && <Waterfall trace={trace} />}
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// 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<number, AITrace>();
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 (
<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 mb-3 gap-3 flex-wrap">
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 flex items-center gap-2">
<Sparkles size={14} className="text-violet-500" />
AI Review
<span className="font-normal text-slate-400 dark:text-zinc-500">· Priya · AI Finance</span>
</h3>
<div className="flex items-center gap-3 text-[10.5px] text-slate-400 dark:text-zinc-500">
{ordered.length > 0 && <span>{ordered.length} {ordered.length === 1 ? "pass" : "passes"}</span>}
{totals.tokens > 0 && <span>{totals.tokens.toLocaleString()} tokens</span>}
{fmtCost(totals.cost) && <span>{fmtCost(totals.cost)}</span>}
</div>
</div>
{unavailable ? (
<p className="text-[12.5px] text-slate-400 dark:text-zinc-500">
AI activity is unavailable right now the monitoring service couldn't be reached.
</p>
) : ordered.length === 0 ? (
<p className="text-[12.5px] text-slate-400 dark:text-zinc-500">
No AI review recorded for this invoice yet.
</p>
) : (
<div className="space-y-2.5">
{ordered.map((d) => (
<DecisionCard key={d.id} d={d} trace={traceByDecision.get(d.id)} />
))}
</div>
)}
{escalations.length > 0 && (
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-zinc-800">
<h4 className="text-[11px] uppercase tracking-wide text-amber-600 dark:text-amber-400 mb-2 flex items-center gap-1.5">
<AlertTriangle size={11} />Escalations
</h4>
<div className="space-y-2">
{escalations.map((e) => (
<div key={e.id} className="text-[12px] text-slate-600 dark:text-zinc-300">
<span className="font-medium">{e.recommended_action || "Escalated"}</span>
{e.reason ? <> {e.reason}</> : null}
<span className="text-slate-400 dark:text-zinc-500"> · {fmtRelative(e.created_at)}</span>
</div>
))}
</div>
</div>
)}
</section>
);
}

View File

@ -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() {
)}
</div>
{detailScreenLayout ? <ScreenRenderer layout={detailScreenLayout} instanceId={detailInstanceId} /> : <DetailViewPanel viewId={detailViewId} instanceId={detailInstanceId} />}
{/* 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
? <ScreenRenderer layout={detailScreenLayout} instanceId={detailInstanceId} />
: <DetailViewPanel viewId={detailViewId} instanceId={detailInstanceId} />)
: <InvoiceDetail instanceId={detailInstanceId} viewId={detailViewId} stateId={instanceState} />}
</div>
) : loading ? (
<><DashboardStats /><TableSkeleton rows={6} cols={6} /></>

View File

@ -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<string, React.ReactNode> = {
[ACTIVITY_IDS.SUBMIT_INVOICE]: <FileUp size={13} />,
[ACTIVITY_IDS.REQUEST_CLARIFICATION]: <HelpCircle size={13} />,
[ACTIVITY_IDS.PROVIDE_CLARIFICATION]: <MessageSquareReply size={13} />,
[ACTIVITY_IDS.SUBMIT_RECOMMENDATION]: <Gavel size={13} />,
[ACTIVITY_IDS.APPROVE_PAYMENT]: <CheckCircle2 size={13} />,
[ACTIVITY_IDS.REJECT_PAYMENT]: <XCircle size={13} />,
[ACTIVITY_IDS.HOLD_PAYMENT]: <PauseCircle size={13} />,
};
// 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<string, any>;
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<string, unknown> }) {
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 (
<dl className="mt-2.5 grid grid-cols-2 gap-x-5 gap-y-1.5">
{rows.map(([k, v]) => (
<div key={k} className="min-w-0">
<dt className="text-[10.5px] uppercase tracking-wide text-slate-400 dark:text-zinc-600">
{k.replace(/_/g, " ")}
</dt>
<dd className="text-[12.5px] text-slate-700 dark:text-zinc-200 break-words">{String(v)}</dd>
</div>
))}
</dl>
);
}
export default function AuditTimeline({ entries }: { entries: AuditEntry[] }) {
const [expanded, setExpanded] = useState<Set<number>>(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 (
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-6">
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 mb-1">History</h3>
<p className="text-[12.5px] text-slate-400 dark:text-zinc-500">No activity recorded yet.</p>
</section>
);
}
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 mb-4">
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 flex items-center gap-2">
<Clock size={14} className="text-slate-400" />History
</h3>
<span className="text-[11px] text-slate-400 dark:text-zinc-500">
{entries.length} {entries.length === 1 ? "step" : "steps"}
</span>
</div>
<ol className="relative">
{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 (
<li key={e.id} className="relative pl-9 pb-5 last:pb-0">
{/* Rail */}
{!isLast && (
<span className="absolute left-[11px] top-6 bottom-0 w-px bg-slate-200 dark:bg-zinc-700" />
)}
{/* Node */}
<span
className={`absolute left-0 top-0.5 w-[23px] h-[23px] rounded-full flex items-center justify-center ring-4 ring-white dark:ring-zinc-900 ${
actor.isAI
? "bg-violet-100 text-violet-600 dark:bg-violet-950/60 dark:text-violet-300"
: "bg-slate-100 text-slate-500 dark:bg-zinc-800 dark:text-zinc-400"
}`}
>
{ACTIVITY_ICON[e.activity_id] ?? <ChevronRight size={13} />}
</span>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100">
{activityLabel(e.activity_id)}
</span>
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10.5px] font-medium ${
actor.isAI
? "text-violet-700 bg-violet-50 dark:text-violet-300 dark:bg-violet-950/50"
: "text-slate-500 bg-slate-100 dark:text-zinc-400 dark:bg-zinc-800"
}`}>
{actor.isAI ? <Sparkles size={9} /> : <User size={9} />}{actor.name}
</span>
</div>
{headline && (
<p className="text-[12.5px] text-slate-600 dark:text-zinc-300 mt-1 line-clamp-2">
{headline}
</p>
)}
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10.5px] font-medium ${TONE_PILL[st.tone]}`}>
<span className={`w-1.5 h-1.5 rounded-full ${TONE_DOT[st.tone]}`} />
{st.label}
</span>
<button
type="button"
onClick={() => toggle(e.id)}
className="text-[11px] text-slate-400 dark:text-zinc-500 hover:text-violet-600 dark:hover:text-violet-400 transition-colors"
>
{isOpen ? "Hide details" : "Details"}
</button>
</div>
{isOpen && <PayloadGrid data={(e.data ?? {}) as Record<string, unknown>} />}
</div>
<div className="text-right shrink-0">
<div className="text-[11px] text-slate-400 dark:text-zinc-500 whitespace-nowrap" title={fmtDateTime(e.created_at)}>
{fmtRelative(e.created_at)}
</div>
{gap && (
<div className="text-[10.5px] text-slate-300 dark:text-zinc-600 whitespace-nowrap mt-0.5">
+{gap}
</div>
)}
</div>
</div>
</li>
);
})}
</ol>
</section>
);
}

View File

@ -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<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>
);
}

View File

@ -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 <a>
// 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<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(defaultOpen);
const urlRef = useRef<string | null>(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 (
<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.5 text-[13px] text-slate-400 dark:text-zinc-500">
<FileText size={15} />
No invoice document was attached to this submission.
</div>
</section>
);
}
const isPdf = (file.mime_type || "").includes("pdf");
return (
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 overflow-hidden">
<div className="px-5 py-4 flex items-center gap-3">
<div className="w-9 h-9 shrink-0 rounded-xl bg-violet-50 dark:bg-violet-950/40 flex items-center justify-center">
<FileText size={16} className="text-violet-500" />
</div>
<div className="min-w-0 flex-1">
<div className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 truncate">
{file.original_name || "Invoice document"}
</div>
<div className="text-[11px] text-slate-400 dark:text-zinc-500">
Source document · {fmtBytes(file.size_bytes)}
{isPdf ? " · PDF" : file.mime_type ? ` · ${file.mime_type}` : ""}
</div>
</div>
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="h-8 px-3 text-[12px] font-medium rounded-lg inline-flex items-center gap-1.5 text-slate-600 dark:text-zinc-300 bg-slate-50 dark:bg-zinc-800 hover:bg-slate-100 dark:hover:bg-zinc-700 transition-colors"
>
<Eye size={13} />{open ? "Hide" : "View"}
</button>
{url && (
<>
<a
href={url}
target="_blank"
rel="noreferrer"
className="h-8 px-3 text-[12px] font-medium rounded-lg inline-flex items-center gap-1.5 text-slate-600 dark:text-zinc-300 bg-slate-50 dark:bg-zinc-800 hover:bg-slate-100 dark:hover:bg-zinc-700 transition-colors"
>
<ExternalLink size={13} />Open
</a>
<button
type="button"
onClick={save}
className="h-8 px-3 text-[12px] font-medium rounded-lg inline-flex items-center gap-1.5 text-slate-600 dark:text-zinc-300 bg-slate-50 dark:bg-zinc-800 hover:bg-slate-100 dark:hover:bg-zinc-700 transition-colors"
>
<Download size={13} />Save
</button>
</>
)}
</div>
{open && (
<div className="border-t border-slate-100 dark:border-zinc-800">
{loading && (
<div className="h-[420px] flex flex-col items-center justify-center gap-2 text-slate-400 dark:text-zinc-500">
<Loader2 size={18} className="animate-spin text-violet-500" />
<span className="text-[12px]">Loading document</span>
</div>
)}
{error && (
<div className="px-5 py-4 flex items-start gap-2 text-[12.5px] text-red-600 dark:text-red-400">
<AlertCircle size={14} className="shrink-0 mt-0.5" />{error}
</div>
)}
{url && !loading && !error && (
isPdf ? (
<iframe
src={url}
title={file.original_name || "Invoice"}
className="w-full h-[560px] bg-slate-50 dark:bg-zinc-950"
/>
) : (
<img src={url} alt={file.original_name || "Invoice"} className="w-full max-h-[560px] object-contain bg-slate-50 dark:bg-zinc-950" />
)
)}
</div>
)}
</section>
);
}

View File

@ -0,0 +1,154 @@
// 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>
);
}

View File

@ -0,0 +1,136 @@
// useInstanceDetail — one fetch pass for everything the invoice detail page needs.
//
// The detail page shows four panels that all derive from the same three calls,
// so they're fetched once here and passed down as props rather than each panel
// doing its own round trip.
//
// /app/{id}/view/audit → the activity trail (timeline + submitted data)
// /monitor/instances/{id}/log → the AI employee's decisions + escalations
// /monitor/instances/{id}/trace→ per-run step traces
//
// The two /monitor calls are best-effort: an instance the employee never
// touched has no rows, and ai-employee-service is a separate deployment that
// can be down without the invoice page needing to break. Those failures are
// swallowed into empty results and surfaced as `aiUnavailable`.
import { useCallback, useEffect, useMemo, useState } from "react";
import {
getAuditLog, getAIInstanceLog, getAIInstanceTrace,
type AuditEntry, type AIDecision, type AIEscalation, type AITrace, type FileMeta,
} from "../api/viewService";
import { ACTIVITY_IDS } from "../config";
export interface InstanceDetail {
audit: AuditEntry[];
decisions: AIDecision[];
escalations: AIEscalation[];
traces: AITrace[];
/** True when the AI monitoring service couldn't be reached. */
aiUnavailable: boolean;
loading: boolean;
error: string | null;
refresh: () => void;
// ── derived ───────────────────────────────────────────────────────────────
/** All submitted field values across every activity, later writes winning. */
submitted: Record<string, unknown>;
/** The invoice PDF from the Submit Invoice activity, if one was uploaded. */
invoicePdf: FileMeta | null;
/** Most recent decision that actually went through. */
latestDecision: AIDecision | null;
}
// The audit can carry more than one row for the same activity — the trigger's
// performActivity node commits the submission, which lands alongside the
// original. Collapse on (activity_id, created_at to the second).
function dedupe(entries: AuditEntry[]): AuditEntry[] {
const seen = new Set<string>();
const out: AuditEntry[] = [];
for (const e of entries) {
const key = `${e.activity_id}|${(e.created_at || "").slice(0, 19)}`;
if (seen.has(key)) continue;
seen.add(key);
out.push(e);
}
return out;
}
const asFileMeta = (v: unknown): FileMeta | null => {
const first = Array.isArray(v) ? v[0] : v;
if (first && typeof first === "object" && "blob_path" in (first as any)) return first as FileMeta;
return null;
};
export function useInstanceDetail(instanceId: string | null): InstanceDetail {
const [audit, setAudit] = useState<AuditEntry[]>([]);
const [decisions, setDecisions] = useState<AIDecision[]>([]);
const [escalations, setEscalations] = useState<AIEscalation[]>([]);
const [traces, setTraces] = useState<AITrace[]>([]);
const [aiUnavailable, setAiUnavailable] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tick, setTick] = useState(0);
const refresh = useCallback(() => setTick((t) => t + 1), []);
useEffect(() => {
if (!instanceId) return;
let cancelled = false;
setLoading(true);
setError(null);
// The audit is the only required call — the page is still useful without
// the AI panels, so those resolve to empty instead of rejecting.
Promise.all([
getAuditLog(instanceId),
getAIInstanceLog(instanceId).catch(() => null),
getAIInstanceTrace(instanceId).catch(() => null),
])
.then(([auditRes, logRes, traceRes]) => {
if (cancelled) return;
const sorted = dedupe(
[...(auditRes ?? [])].sort(
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
),
);
setAudit(sorted);
setDecisions(logRes?.decisions ?? []);
setEscalations(logRes?.escalations ?? []);
setTraces(traceRes?.traces ?? []);
setAiUnavailable(logRes === null && traceRes === null);
})
.catch((e: any) => { if (!cancelled) setError(e?.message || "Could not load this invoice"); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [instanceId, tick]);
const submitted = useMemo(() => {
const merged: Record<string, unknown> = {};
for (const e of audit) Object.assign(merged, e.data ?? {});
return merged;
}, [audit]);
const invoicePdf = useMemo(() => {
// Prefer the Submit Invoice activity explicitly; fall back to any entry
// carrying the field, so a re-submission still resolves.
const init = audit.find((e) => e.activity_id === ACTIVITY_IDS.SUBMIT_INVOICE);
return asFileMeta(init?.data?.invoice_pdf) ?? asFileMeta(submitted.invoice_pdf);
}, [audit, submitted]);
const latestDecision = useMemo(() => {
const ok = decisions.filter((d) => d.status === "submitted");
const pool = ok.length ? ok : decisions;
return (
[...pool].sort(
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
)[0] ?? null
);
}, [decisions]);
return {
audit, decisions, escalations, traces, aiUnavailable,
loading, error, refresh,
submitted, invoicePdf, latestDecision,
};
}

82
src/lib/matchSummary.ts Normal file
View File

@ -0,0 +1,82 @@
// Parsers for the AI employee's two free-text reconciliation fields.
//
// Kept separate from the card that renders them so the logic is testable
// without React. Both formats come from the deployed prompt/knowledge, not from
// a schema, so these parsers are deliberately forgiving: an unrecognised chunk
// is dropped rather than throwing, and a missing verdict keyword degrades to a
// neutral chip instead of losing the row.
export type Tone = "good" | "bad" | "warn" | "neutral";
export const VERDICT_TONE: Record<string, Tone> = {
MATCH: "good", VALID: "good", CLEAN: "good", ACTIVE: "good",
PASS: "good", PASSED: "good", OK: "good", EXISTS: "good", APPROVED: "good",
MISMATCH: "bad", INVALID: "bad", FAIL: "bad", FAILED: "bad",
DUPLICATE: "bad", EXPIRED: "bad", EXCEEDED: "bad", VIOLATION: "bad",
PARTIAL: "warn", MISSING: "warn", PENDING: "warn",
UNKNOWN: "warn", UNVERIFIED: "warn",
// "Payments: NONE" means no prior payment was found — the good outcome for a
// duplicate check, so it must not read as a warning.
NONE: "neutral", "N/A": "neutral",
};
/**
* Splits "Label: rest. Label: rest." into pairs.
*
* A sentence boundary only separates records when what follows looks like a new
* "Label:" otherwise a value containing a full stop (a date, "2% tolerance.")
* would be torn in half.
*
* The label pattern has to allow digits and 2-character labels: real values
* include "PO-3001:", "GR-3001:", "Contract CNT-300:" and "GR:". An earlier
* letters-only, 3-char-minimum pattern silently merged the whole evidence
* string into one row and dropped "GR:" entirely.
*/
export function splitLabelled(text: string): Array<{ label: string; rest: string }> {
const out: Array<{ label: string; rest: string }> = [];
const chunks = text
.split(/(?<=\.)\s+(?=[A-Z][A-Za-z0-9][A-Za-z0-9 \-/]{0,28}:)/)
.map((s) => s.trim())
.filter(Boolean);
for (const c of chunks) {
const m = c.match(/^([^:]{2,30}):\s*([\s\S]*)$/);
if (!m) continue;
out.push({ label: m[1].trim(), rest: m[2].trim().replace(/\.$/, "") });
}
return out;
}
export interface Verdict { label: string; verdict: string; detail: string; tone: Tone }
/** "Price: MATCH (₹550/unit per …)" → {label:"Price", verdict:"MATCH", detail:"₹550/unit per …"} */
export function parseMatchSummary(text?: string): Verdict[] {
if (!text?.trim()) return [];
return splitLabelled(text).map(({ label, rest }) => {
// A leading ALL-CAPS token is the verdict; the remainder explains it.
const m = rest.match(/^([A-Z][A-Z/_]{1,14})\b[\s:.\-]*([\s\S]*)$/);
const verdict = m ? m[1] : "";
let detail = (m ? m[2] : rest).trim();
// Most details arrive fully parenthesised — unwrap for cleaner rendering.
detail = detail.replace(/^\(([\s\S]*)\)$/, "$1").trim();
return { label, verdict, detail, tone: VERDICT_TONE[verdict] ?? "neutral" };
});
}
export interface EvidenceRow { label: string; value: string; isIssue: boolean }
/** "PO-3001: 200 units @ ₹500/unit = ₹100,000." → one row per entity. */
export function parseEvidence(text?: string): EvidenceRow[] {
if (!text?.trim()) return [];
return splitLabelled(text).map(({ label, rest }) => ({
label,
value: rest,
isIssue: /^(issue|issues|concern|problem|discrepanc)/i.test(label),
}));
}
/** Worst verdict across the set — drives the card's headline. */
export function overallTone(verdicts: Verdict[]): Tone {
if (verdicts.some((v) => v.tone === "bad")) return "bad";
if (verdicts.some((v) => v.tone === "warn")) return "warn";
return "good";
}

157
src/lib/workflowMeta.ts Normal file
View File

@ -0,0 +1,157 @@
// Display metadata for the P2P workflow.
//
// Everything here keys off UUIDs (ACTIVITY_IDS / STATE_IDS from config.ts)
// rather than off the human-readable names the API returns. That matters: the
// deployed state is named "Financial Review", and several older call sites
// match on the string "Finance Review" and silently fall through to a neutral
// style. Keying on the uuid can't drift.
import { ACTIVITY_IDS, STATE_IDS } from "../config";
// ---------------------------------------------------------------------------
// Activities
// ---------------------------------------------------------------------------
export const ACTIVITY_LABELS: Record<string, string> = {
[ACTIVITY_IDS.SUBMIT_INVOICE]: "Invoice submitted",
[ACTIVITY_IDS.REQUEST_CLARIFICATION]: "Clarification requested",
[ACTIVITY_IDS.PROVIDE_CLARIFICATION]: "Clarification provided",
[ACTIVITY_IDS.SUBMIT_RECOMMENDATION]: "Recommendation submitted",
[ACTIVITY_IDS.APPROVE_PAYMENT]: "Payment approved",
[ACTIVITY_IDS.REJECT_PAYMENT]: "Payment rejected",
[ACTIVITY_IDS.HOLD_PAYMENT]: "Payment held",
};
export const activityLabel = (uid: string): string =>
ACTIVITY_LABELS[uid] ?? "Activity";
// ---------------------------------------------------------------------------
// States
// ---------------------------------------------------------------------------
export type StateTone = "sky" | "violet" | "amber" | "indigo" | "emerald" | "red" | "slate";
interface StateMeta { label: string; tone: StateTone; terminal?: boolean }
// Labels match the deployed workflow exactly (note "Financial Review").
export const STATE_META: Record<string, StateMeta> = {
[STATE_IDS.INVOICE_SUBMITTED]: { label: "Invoice Submitted", tone: "sky" },
[STATE_IDS.AI_ANALYSIS]: { label: "AI Analysis", tone: "violet" },
[STATE_IDS.AWAITING_CLARIFICATION]: { label: "Awaiting Clarification", tone: "amber" },
[STATE_IDS.FINANCE_REVIEW]: { label: "Financial Review", tone: "indigo" },
[STATE_IDS.APPROVED]: { label: "Approved", tone: "emerald", terminal: true },
[STATE_IDS.REJECTED]: { label: "Rejected", tone: "red", terminal: true },
[STATE_IDS.ON_HOLD]: { label: "On Hold", tone: "slate", terminal: true },
};
export const stateMeta = (uid?: string | null): StateMeta =>
(uid && STATE_META[uid]) || { label: "—", tone: "slate" };
/** Tailwind classes per tone, for pills and dots. Light + dark. */
export const TONE_PILL: Record<StateTone, string> = {
sky: "text-sky-700 bg-sky-50 ring-1 ring-sky-200 dark:text-sky-300 dark:bg-sky-950/50 dark:ring-sky-800/60",
violet: "text-violet-700 bg-violet-50 ring-1 ring-violet-200 dark:text-violet-300 dark:bg-violet-950/50 dark:ring-violet-800/60",
amber: "text-amber-700 bg-amber-50 ring-1 ring-amber-200 dark:text-amber-300 dark:bg-amber-950/50 dark:ring-amber-800/60",
indigo: "text-indigo-700 bg-indigo-50 ring-1 ring-indigo-200 dark:text-indigo-300 dark:bg-indigo-950/50 dark:ring-indigo-800/60",
emerald: "text-emerald-700 bg-emerald-50 ring-1 ring-emerald-200 dark:text-emerald-300 dark:bg-emerald-950/50 dark:ring-emerald-800/60",
red: "text-red-700 bg-red-50 ring-1 ring-red-200 dark:text-red-300 dark:bg-red-950/50 dark:ring-red-800/60",
slate: "text-slate-600 bg-slate-100 ring-1 ring-slate-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700",
};
export const TONE_DOT: Record<StateTone, string> = {
sky: "bg-sky-500", violet: "bg-violet-500", amber: "bg-amber-500",
indigo: "bg-indigo-500", emerald: "bg-emerald-500", red: "bg-red-500", slate: "bg-slate-400",
};
// ---------------------------------------------------------------------------
// Actors
// ---------------------------------------------------------------------------
// The audit records user_id + roles, not names. Roles are stable and readable,
// so derive the actor from the role rather than hardcoding a user-id table.
const ROLE_ACTORS: Record<string, { name: string; ai?: boolean }> = {
p2p_ai_finance: { name: "Priya · AI Finance", ai: true },
p2p_vendor: { name: "Vendor" },
p2p_warehouse: { name: "Warehouse" },
p2p_procurement: { name: "Procurement" },
p2p_ap_team: { name: "Accounts Payable" },
p2p_finance_reviewer: { name: "Finance Reviewer" },
};
export interface Actor { name: string; isAI: boolean }
export function resolveActor(roles?: string[] | null, userId?: string): Actor {
const list = Array.isArray(roles) ? roles : [];
// A single-role actor is unambiguous. Multi-role users (e.g. the admin test
// account holds all six) can't be pinned down, so fall back to the user id.
if (list.length === 1) {
const m = ROLE_ACTORS[list[0]];
if (m) return { name: m.name, isAI: !!m.ai };
}
const ai = list.includes("p2p_ai_finance") && list.length === 1;
return { name: userId ? `User ${userId}` : "Unknown", isAI: ai };
}
// ---------------------------------------------------------------------------
// Formatting
// ---------------------------------------------------------------------------
export function fmtDateTime(iso?: string): string {
if (!iso) return "—";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleString(undefined, {
day: "2-digit", month: "short", year: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
export function fmtRelative(iso?: string): string {
if (!iso) return "";
const t = new Date(iso).getTime();
if (Number.isNaN(t)) return "";
const secs = Math.round((Date.now() - t) / 1000);
if (secs < 60) return "just now";
const mins = Math.round(secs / 60);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.round(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.round(hrs / 24);
if (days < 30) return `${days}d ago`;
return fmtDateTime(iso);
}
/** Elapsed time between two ISO stamps, for timeline gaps. */
export function fmtGap(fromIso: string, toIso: string): string {
const a = new Date(fromIso).getTime(), b = new Date(toIso).getTime();
if (Number.isNaN(a) || Number.isNaN(b)) return "";
const secs = Math.round((b - a) / 1000);
if (secs < 1) return "";
if (secs < 60) return `${secs}s`;
const mins = Math.round(secs / 60);
if (mins < 60) return `${mins}m`;
const hrs = Math.round(mins / 60);
if (hrs < 48) return `${hrs}h`;
return `${Math.round(hrs / 24)}d`;
}
export function fmtDuration(ms?: number): string {
if (ms == null) return "—";
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
}
/** Amounts in this app are INR. Keep the code, not the glyph — matches the PDFs. */
export function fmtAmount(v: unknown, currency = "INR"): string {
const n = typeof v === "number" ? v : Number(String(v ?? "").replace(/[^0-9.\-]/g, ""));
if (!Number.isFinite(n)) return "—";
return `${currency} ${n.toLocaleString("en-IN", { maximumFractionDigits: 2 })}`;
}
export function fmtBytes(n?: number): string {
if (!n) return "";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}