feat: surface the AI's work, the audit trail and the invoice PDF in our UI
Ports the data that arrived with the merge into the rebuilt detail view, so
nothing the API returns is left unrendered and there is one visual language.
- SourceDocument: invoice_pdf was write-only — uploaded, OCR'd, then shown
nowhere. Fetched as a Blob (the endpoint forces an attachment) and rendered
inline via an object URL, revoked on unmount
- AuditTrail: the history as a timeline — actor AI-vs-human, resulting state,
per-activity headline and how long each hop waited
- AiActivity: decisions with status/confidence/model, the model's plan,
reasoning and self-check, escalations, and a per-run trace waterfall with
durations, tokens and cost
- adopts lib/matchSummary for both free-text AI fields: it parses entity
labels our inline version dropped ("PO-3001:", "GR:"), treats
PARTIAL/MISSING as warnings and "Payments: NONE" as neutral, and turns
analysis_so_far into evidence rows instead of a paragraph
- drops the five pre-migration components these replace, plus the TONE_PILL /
TONE_DOT class maps they were the only users of — the last hardcoded
slate/zinc palette in the app. CSS 115.8 -> 98.1 kB
STATE_META stays: keying display metadata off state UUIDs beats matching
served names, and folding it into lib/workflow.ts is the next step.
This commit is contained in:
parent
e32bc729c5
commit
a52ff32eb4
@ -1,343 +0,0 @@
|
|||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
260
src/components/AiActivity.tsx
Normal file
260
src/components/AiActivity.tsx
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
// What the AI employee actually did on this instance.
|
||||||
|
//
|
||||||
|
// Sourced from /monitor/instances/{id}/log (decisions + escalations) and
|
||||||
|
// /trace (per-run step waterfall). Both 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 this page breaking — hence `unavailable`
|
||||||
|
// rendering as a note rather than an error.
|
||||||
|
//
|
||||||
|
// Presentation is on our theme tokens; the decision↔trace linking, ordering and
|
||||||
|
// token/cost roll-up are the logic that came with the endpoints.
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { AlertTriangle, ChevronDown, Coins, Hash, Sparkles } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { AIDecision, AIEscalation, AITrace } from "@/api/viewService";
|
||||||
|
import { activityLabel, fmtDuration } from "@/lib/workflowMeta";
|
||||||
|
import { formatDate, formatTime } from "@/lib/format";
|
||||||
|
|
||||||
|
const STATUS_TONE: Record<string, string> = {
|
||||||
|
submitted: "bg-state-approved/12 text-state-approved",
|
||||||
|
escalated: "bg-state-clarification/12 text-state-clarification",
|
||||||
|
submitting: "bg-state-review/12 text-state-review",
|
||||||
|
failed: "bg-state-rejected/12 text-state-rejected",
|
||||||
|
error: "bg-state-rejected/12 text-state-rejected",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
decisions: AIDecision[];
|
||||||
|
escalations: AIEscalation[];
|
||||||
|
traces: AITrace[];
|
||||||
|
unavailable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiActivity({ 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;
|
||||||
|
let 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]);
|
||||||
|
|
||||||
|
if (unavailable) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
The AI monitoring service isn’t reachable, so decisions and traces can’t be shown.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (ordered.length === 0 && escalations.length === 0) {
|
||||||
|
return (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
The AI employee hasn’t acted on this invoice.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{(totals.tokens > 0 || totals.cost > 0) && (
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-2xs text-muted-foreground">
|
||||||
|
{totals.tokens > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Hash className="size-2.5" aria-hidden />
|
||||||
|
{totals.tokens.toLocaleString("en-IN")} tokens
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{totals.cost > 0 && (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<Coins className="size-2.5" aria-hidden />${totals.cost.toFixed(4)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ol className="space-y-3">
|
||||||
|
{ordered.map((d) => (
|
||||||
|
<Decision key={d.id} decision={d} trace={traceByDecision.get(d.id)} />
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{escalations.length > 0 && (
|
||||||
|
<div className="mt-4 space-y-2 border-t border-border/60 pt-3">
|
||||||
|
{escalations.map((e) => (
|
||||||
|
<div key={e.id} className="flex items-start gap-2">
|
||||||
|
<AlertTriangle
|
||||||
|
className="mt-0.5 size-3.5 shrink-0 text-state-clarification"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-base font-semibold text-state-clarification">
|
||||||
|
Escalated to a human
|
||||||
|
{e.recommended_action && ` · ${e.recommended_action}`}
|
||||||
|
</p>
|
||||||
|
{(e.reasoning || e.reason) && (
|
||||||
|
<p className="mt-0.5 text-sm text-muted-foreground">{e.reasoning || e.reason}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Decision({ decision: d, trace }: { decision: AIDecision; trace?: AITrace }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const pct = d.reflexion?.adjusted_confidence ?? d.confidence;
|
||||||
|
const time = formatTime(d.created_at);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||||
|
<Sparkles className="size-3 shrink-0 translate-y-px text-primary" aria-hidden />
|
||||||
|
<span className="text-base font-semibold text-foreground">
|
||||||
|
{activityLabel(d.activity_id)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-1.5 py-0.5 text-2xs font-bold uppercase tracking-wider",
|
||||||
|
STATUS_TONE[d.status] ?? "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{d.status}
|
||||||
|
</span>
|
||||||
|
{pct != null && (
|
||||||
|
<span className="text-xs font-semibold tabular-nums text-muted-foreground">
|
||||||
|
{Math.round(pct * 100)}% confidence
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-0.5 text-2xs tabular-nums text-muted-foreground/70">
|
||||||
|
{formatDate(d.created_at)}
|
||||||
|
{time && ` · ${time}`}
|
||||||
|
{d.model && ` · ${d.model}`}
|
||||||
|
{d.duration_ms != null && ` · ${fmtDuration(d.duration_ms)}`}
|
||||||
|
{d.llm_calls != null && d.llm_calls > 0 && ` · ${d.llm_calls} LLM calls`}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{d.error_message && (
|
||||||
|
<p className="mt-1 text-sm text-destructive">{d.error_message}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(d.plan || d.reasoning || d.reflexion?.concerns || trace) && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
aria-expanded={open}
|
||||||
|
className="mt-1.5 flex items-center gap-1 rounded text-xs font-semibold text-primary hover:underline"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={cn("size-3 transition-transform", open && "rotate-180")}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
{open ? "Hide" : "Show"} the model’s work
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2 space-y-2.5 border-l-2 border-primary/20 pl-3">
|
||||||
|
{d.plan && <Passage label="Plan" text={d.plan} />}
|
||||||
|
{d.reasoning && <Passage label="Reasoning" text={d.reasoning} />}
|
||||||
|
{d.reflexion?.concerns && (
|
||||||
|
<Passage
|
||||||
|
label="Self-check"
|
||||||
|
text={d.reflexion.concerns}
|
||||||
|
tone={d.reflexion.should_escalate ? "warn" : undefined}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{d.knowledge_sources && d.knowledge_sources.length > 0 && (
|
||||||
|
<Passage label="Knowledge used" text={d.knowledge_sources.join(", ")} />
|
||||||
|
)}
|
||||||
|
{trace && <Waterfall trace={trace} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Passage({ label, text, tone }: { label: string; text: string; tone?: "warn" }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-2xs font-semibold uppercase tracking-wider",
|
||||||
|
tone === "warn" ? "text-state-clarification" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 whitespace-pre-line text-sm leading-relaxed text-foreground/90">
|
||||||
|
{text.replace(/[ \t]{2,}/g, " ")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Step timings for one run, as bars proportional to the slowest step. */
|
||||||
|
function Waterfall({ trace }: { trace: AITrace }) {
|
||||||
|
const events = [...trace.events].sort((a, b) => a.seq - b.seq);
|
||||||
|
const max = Math.max(...events.map((e) => e.duration_ms ?? 0), 1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="text-2xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
Run trace
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1 space-y-1">
|
||||||
|
{events.map((e) => (
|
||||||
|
<li key={e.seq} className="flex items-center gap-2 text-2xs">
|
||||||
|
<span
|
||||||
|
className="min-w-0 flex-1 truncate text-muted-foreground"
|
||||||
|
style={{ paddingLeft: e.parent_seq != null ? 10 : 0 }}
|
||||||
|
>
|
||||||
|
{e.name || e.type}
|
||||||
|
</span>
|
||||||
|
<span className="h-1.5 w-24 shrink-0 overflow-hidden rounded-full bg-muted">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"block h-full rounded-full",
|
||||||
|
e.error || e.status === "error" ? "bg-state-rejected" : "bg-primary/60",
|
||||||
|
)}
|
||||||
|
style={{ width: `${Math.max(((e.duration_ms ?? 0) / max) * 100, 2)}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="w-12 shrink-0 text-right tabular-nums text-muted-foreground">
|
||||||
|
{fmtDuration(e.duration_ms)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,199 +0,0 @@
|
|||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
137
src/components/AuditTrail.tsx
Normal file
137
src/components/AuditTrail.tsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
// The invoice's history as a 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 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.
|
||||||
|
//
|
||||||
|
// Presentation is on our theme tokens; the actor resolution, per-activity
|
||||||
|
// headline and gap arithmetic are the logic that came with the endpoint.
|
||||||
|
|
||||||
|
import {
|
||||||
|
CheckCircle2,
|
||||||
|
FileUp,
|
||||||
|
Gavel,
|
||||||
|
HelpCircle,
|
||||||
|
MessageSquareReply,
|
||||||
|
PauseCircle,
|
||||||
|
Sparkles,
|
||||||
|
User,
|
||||||
|
XCircle,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { AuditEntry } from "@/api/viewService";
|
||||||
|
import { ACTIVITY_IDS } from "@/config";
|
||||||
|
import { activityLabel, fmtGap, resolveActor } from "@/lib/workflowMeta";
|
||||||
|
import { formatCurrency, formatDate, formatTime } from "@/lib/format";
|
||||||
|
import { stateById } from "@/lib/workflow";
|
||||||
|
import StatusBadge from "./StatusBadge";
|
||||||
|
|
||||||
|
const ACTIVITY_ICON: Record<string, LucideIcon> = {
|
||||||
|
[ACTIVITY_IDS.SUBMIT_INVOICE]: FileUp,
|
||||||
|
[ACTIVITY_IDS.REQUEST_CLARIFICATION]: HelpCircle,
|
||||||
|
[ACTIVITY_IDS.PROVIDE_CLARIFICATION]: MessageSquareReply,
|
||||||
|
[ACTIVITY_IDS.SUBMIT_RECOMMENDATION]: Gavel,
|
||||||
|
[ACTIVITY_IDS.APPROVE_PAYMENT]: CheckCircle2,
|
||||||
|
[ACTIVITY_IDS.REJECT_PAYMENT]: XCircle,
|
||||||
|
[ACTIVITY_IDS.HOLD_PAYMENT]: PauseCircle,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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>;
|
||||||
|
const money = (v: unknown) => (v != null ? formatCurrency(Number(v)) : null);
|
||||||
|
|
||||||
|
switch (e.activity_id) {
|
||||||
|
case ACTIVITY_IDS.SUBMIT_INVOICE:
|
||||||
|
return (
|
||||||
|
[d.invoice_number, d.po_number, money(d.total_amount)].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).replace(/[ \t]{2,}/g, " ") : null;
|
||||||
|
case ACTIVITY_IDS.SUBMIT_RECOMMENDATION:
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
d.recommendation ? String(d.recommendation).toUpperCase() : null,
|
||||||
|
money(d.recommended_amount),
|
||||||
|
d.ai_confidence != null ? `${Math.round(Number(d.ai_confidence) * 100)}% confidence` : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ") || null
|
||||||
|
);
|
||||||
|
case ACTIVITY_IDS.APPROVE_PAYMENT:
|
||||||
|
return money(d.approved_amount);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditTrail({ audit }: { audit: AuditEntry[] }) {
|
||||||
|
if (audit.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">No activity recorded yet.</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ol className="space-y-4">
|
||||||
|
{audit.map((e, i) => {
|
||||||
|
const actor = resolveActor(e.user_roles, e.user_id);
|
||||||
|
const Icon = ACTIVITY_ICON[e.activity_id] ?? Gavel;
|
||||||
|
const state = stateById(e.execution_state);
|
||||||
|
const summary = summarise(e);
|
||||||
|
const gap = i > 0 ? fmtGap(audit[i - 1].created_at, e.created_at) : "";
|
||||||
|
const time = formatTime(e.created_at);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={e.id} className="flex gap-3">
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"flex size-7 shrink-0 items-center justify-center rounded-full",
|
||||||
|
actor.isAI ? "bg-primary/12 text-primary" : "bg-muted text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5" aria-hidden />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||||
|
<span className="text-base font-semibold text-foreground">
|
||||||
|
{activityLabel(e.activity_id)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
{actor.isAI && <Sparkles className="size-2.5 text-primary" aria-hidden />}
|
||||||
|
{!actor.isAI && <User className="size-2.5" aria-hidden />}
|
||||||
|
{actor.name}
|
||||||
|
</span>
|
||||||
|
{state && <StatusBadge value={state.name} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary && (
|
||||||
|
<p className="mt-0.5 line-clamp-2 text-sm text-muted-foreground">{summary}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-0.5 text-2xs tabular-nums text-muted-foreground/70">
|
||||||
|
{formatDate(e.created_at)}
|
||||||
|
{time && ` · ${time}`}
|
||||||
|
{/* How long this step waited on the one before it. */}
|
||||||
|
{gap && ` · +${gap}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,7 +8,9 @@ import {
|
|||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
CircleSlash,
|
CircleSlash,
|
||||||
|
FileText,
|
||||||
Gavel,
|
Gavel,
|
||||||
|
History,
|
||||||
Landmark,
|
Landmark,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Minus,
|
Minus,
|
||||||
@ -20,12 +22,24 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { getDVScreen, getDetailView } from "@/api/viewService";
|
import { getDVScreen, getDetailView } from "@/api/viewService";
|
||||||
|
import { useInstanceDetail } from "@/hooks/useInstanceDetail";
|
||||||
import { DetailSkeleton } from "./Skeleton";
|
import { DetailSkeleton } from "./Skeleton";
|
||||||
import StatusBadge from "./StatusBadge";
|
import StatusBadge from "./StatusBadge";
|
||||||
import WorkflowStepper from "./WorkflowStepper";
|
import WorkflowStepper from "./WorkflowStepper";
|
||||||
|
import SourceDocument from "./SourceDocument";
|
||||||
|
import AuditTrail from "./AuditTrail";
|
||||||
|
import AiActivity from "./AiActivity";
|
||||||
import { colorForName } from "@/lib/palette";
|
import { colorForName } from "@/lib/palette";
|
||||||
|
import {
|
||||||
|
overallTone,
|
||||||
|
parseEvidence,
|
||||||
|
parseMatchSummary,
|
||||||
|
type Tone,
|
||||||
|
type Verdict,
|
||||||
|
} from "@/lib/matchSummary";
|
||||||
import { formatCurrency, formatDate, formatLabel, formatRelative, formatValue } from "@/lib/format";
|
import { formatCurrency, formatDate, formatLabel, formatRelative, formatValue } from "@/lib/format";
|
||||||
|
|
||||||
interface Field {
|
interface Field {
|
||||||
@ -140,43 +154,11 @@ const BAND_STYLE = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
// `match_summary` and `analysis_so_far` parsing lives in lib/matchSummary.ts.
|
||||||
* `match_summary` arrives as prose in a fixed shape:
|
// That parser handles cases mine did not: entity labels with digits and
|
||||||
* "Price: MATCH (₹550/unit …). Quantity: MISMATCH (0 received vs 300 billed). …"
|
// hyphens ("PO-3001:", "GR:"), a `warn` tone for PARTIAL/MISSING/PENDING, and
|
||||||
* Splitting it into a checklist turns the 3-way match result into something
|
// "Payments: NONE" as neutral rather than a warning — NONE is the *good*
|
||||||
* scannable, which is the whole point of the field.
|
// outcome for a duplicate check.
|
||||||
*/
|
|
||||||
const GOOD = ["MATCH", "EXISTS", "VALID", "CLEAN", "CLEAR", "ACTIVE", "NONE", "PASSED"];
|
|
||||||
const BAD = ["MISMATCH", "MISSING", "FAIL", "FAILED", "DUPLICATE"];
|
|
||||||
|
|
||||||
interface MatchCheck {
|
|
||||||
label: string;
|
|
||||||
verdict: string;
|
|
||||||
detail: string;
|
|
||||||
tone: "good" | "bad" | "neutral";
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseMatchSummary(summary: string): MatchCheck[] {
|
|
||||||
return summary
|
|
||||||
.split(/(?<=\))\s*\.\s*|\.\s+(?=[A-Z][A-Za-z ]{2,20}:)/)
|
|
||||||
.map((part) => part.trim().replace(/\.$/, ""))
|
|
||||||
.filter(Boolean)
|
|
||||||
.flatMap((part) => {
|
|
||||||
const m = part.match(/^([^:]{2,24}):\s*(.+)$/);
|
|
||||||
if (!m) return [];
|
|
||||||
const [, label, rest] = m;
|
|
||||||
const verdictMatch = rest.match(/^([A-Z]{3,}(?:\s[A-Z]{3,})?)\b/);
|
|
||||||
const verdict = verdictMatch ? verdictMatch[1] : "";
|
|
||||||
const detail = (verdict ? rest.slice(verdict.length) : rest)
|
|
||||||
.replace(/^\s*[(\-–]\s*/, "")
|
|
||||||
.replace(/\)\s*$/, "")
|
|
||||||
.trim();
|
|
||||||
const tone = GOOD.includes(verdict) ? "good" : BAD.includes(verdict) ? "bad" : "neutral";
|
|
||||||
// No shouted verdict (e.g. "Payment terms: Minor favorable variance") —
|
|
||||||
// leave it empty so the row doesn't print the sentence twice.
|
|
||||||
return [{ label: label.trim(), verdict, detail: verdict ? detail : rest, tone }];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@ -201,6 +183,10 @@ export default function DetailViewPanel({ viewId, instanceId, onTitle }: Props)
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Audit trail, AI decisions/traces and the uploaded PDF — one pass, shared by
|
||||||
|
// the three blocks below.
|
||||||
|
const instance = useInstanceDetail(instanceId);
|
||||||
|
|
||||||
const titleRef = useRef<string | null>(null);
|
const titleRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const fields: Field[] = data?.fields ?? [];
|
const fields: Field[] = data?.fields ?? [];
|
||||||
@ -320,6 +306,38 @@ export default function DetailViewPanel({ viewId, instanceId, onTitle }: Props)
|
|||||||
{decisionFields.length > 0 && (
|
{decisionFields.length > 0 && (
|
||||||
<Decision row={row} fields={decisionFields} state={stateName} />
|
<Decision row={row} fields={decisionFields} state={stateName} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* The document the whole record is about — write-only until now. */}
|
||||||
|
{(instance.invoicePdf || instance.loading) && (
|
||||||
|
<Block label="Source Document" icon={FileText}>
|
||||||
|
{instance.loading && !instance.invoicePdf ? (
|
||||||
|
<Skeleton className="h-14 w-full rounded-lg" />
|
||||||
|
) : (
|
||||||
|
<SourceDocument file={instance.invoicePdf} />
|
||||||
|
)}
|
||||||
|
</Block>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Block label="AI Activity" icon={Bot}>
|
||||||
|
{instance.loading ? (
|
||||||
|
<Skeleton className="h-16 w-full rounded-lg" />
|
||||||
|
) : (
|
||||||
|
<AiActivity
|
||||||
|
decisions={instance.decisions}
|
||||||
|
escalations={instance.escalations}
|
||||||
|
traces={instance.traces}
|
||||||
|
unavailable={instance.aiUnavailable}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Block>
|
||||||
|
|
||||||
|
<Block label="History" icon={History}>
|
||||||
|
{instance.loading ? (
|
||||||
|
<Skeleton className="h-20 w-full rounded-lg" />
|
||||||
|
) : (
|
||||||
|
<AuditTrail audit={instance.audit} />
|
||||||
|
)}
|
||||||
|
</Block>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ══ Rail ═════════════════════════════════════════════════════════ */}
|
{/* ══ Rail ═════════════════════════════════════════════════════════ */}
|
||||||
@ -374,13 +392,16 @@ function AiAssessment({
|
|||||||
}) {
|
}) {
|
||||||
const verdict = VERDICT[text(row, "recommendation").toLowerCase()];
|
const verdict = VERDICT[text(row, "recommendation").toLowerCase()];
|
||||||
const pct = has(row, "ai_confidence") ? toPercent(row.ai_confidence) : null;
|
const pct = has(row, "ai_confidence") ? toPercent(row.ai_confidence) : null;
|
||||||
const checks = has(row, "match_summary") ? parseMatchSummary(text(row, "match_summary")) : [];
|
const checks = parseMatchSummary(text(row, "match_summary"));
|
||||||
const failed = checks.filter((c) => c.tone === "bad").length;
|
const failed = checks.filter((c) => c.tone === "bad" || c.tone === "warn").length;
|
||||||
|
const worst = overallTone(checks);
|
||||||
// Failures lead — a mismatch buried below four passes is the one thing a
|
// Failures lead — a mismatch buried below four passes is the one thing a
|
||||||
// reviewer must not skim past.
|
// reviewer must not skim past.
|
||||||
const ordered = [...checks].sort(
|
const rank = (t: Tone) => (t === "bad" ? 0 : t === "warn" ? 1 : 2);
|
||||||
(a, b) => (a.tone === "bad" ? 0 : 1) - (b.tone === "bad" ? 0 : 1),
|
const ordered = [...checks].sort((a, b) => rank(a.tone) - rank(b.tone));
|
||||||
);
|
// The evidence the verdict was reached from. Previously dropped outright by
|
||||||
|
// SKIP_KEYS for looking like a JSON blob, then shown as raw prose.
|
||||||
|
const evidence = parseEvidence(text(row, "analysis_so_far"));
|
||||||
const recommended = has(row, "recommended_amount") ? Number(row.recommended_amount) : null;
|
const recommended = has(row, "recommended_amount") ? Number(row.recommended_amount) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -429,7 +450,14 @@ function AiAssessment({
|
|||||||
{/* Headline count first — the reason to read the list at all is to
|
{/* Headline count first — the reason to read the list at all is to
|
||||||
find out whether anything failed. */}
|
find out whether anything failed. */}
|
||||||
{failed > 0 ? (
|
{failed > 0 ? (
|
||||||
<span className="rounded-full bg-state-rejected/12 px-2 py-0.5 text-2xs font-bold uppercase tracking-wider text-state-rejected">
|
<span
|
||||||
|
className={cn(
|
||||||
|
"rounded-full px-2 py-0.5 text-2xs font-bold uppercase tracking-wider",
|
||||||
|
worst === "bad"
|
||||||
|
? "bg-state-rejected/12 text-state-rejected"
|
||||||
|
: "bg-state-clarification/12 text-state-clarification",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{failed} {failed === 1 ? "issue" : "issues"}
|
{failed} {failed === 1 ? "issue" : "issues"}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
@ -454,17 +482,40 @@ function AiAssessment({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Prose, not a JSON blob — it was hidden outright before. Collapsed by
|
{/* The gathered evidence — PO, goods receipt, contract, payment history
|
||||||
default because it restates the reasoning in shorthand. */}
|
and the specific discrepancy. Parsed into rows so the verdict above is
|
||||||
{has(row, "analysis_so_far") && (
|
auditable instead of a wall of prose. */}
|
||||||
<details className="group mt-3">
|
{evidence.length > 0 && (
|
||||||
|
<details className="group mt-3" open>
|
||||||
<summary className="flex cursor-pointer items-center gap-1 text-xs font-semibold text-primary hover:underline">
|
<summary className="flex cursor-pointer items-center gap-1 text-xs font-semibold text-primary hover:underline">
|
||||||
<ChevronDown className="size-3 transition-transform group-open:rotate-180" aria-hidden />
|
<ChevronDown
|
||||||
{byKey.get("analysis_so_far")?.output_label ?? "Analysis So Far"}
|
className="size-3 transition-transform group-open:rotate-180"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
Evidence gathered
|
||||||
</summary>
|
</summary>
|
||||||
<p className="mt-2 text-base leading-relaxed text-foreground/90">
|
<dl className="mt-2.5 space-y-1.5">
|
||||||
{text(row, "analysis_so_far")}
|
{evidence.map((e) => (
|
||||||
</p>
|
<div key={e.label} className="flex flex-wrap gap-x-2 text-base">
|
||||||
|
<dt
|
||||||
|
className={cn(
|
||||||
|
"shrink-0 font-semibold",
|
||||||
|
e.isIssue ? "text-state-rejected" : "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{e.label}
|
||||||
|
</dt>
|
||||||
|
<dd
|
||||||
|
className={cn(
|
||||||
|
"min-w-0 flex-1",
|
||||||
|
e.isIssue ? "text-state-rejected" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{e.value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
</details>
|
</details>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@ -718,17 +769,26 @@ function FieldRow({ field, value }: { field: Field; value: unknown }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const MATCH_TONE = {
|
const MATCH_TONE: Record<Tone, { text: string; disc: string; icon: LucideIcon }> = {
|
||||||
good: { text: "text-state-approved", disc: "bg-state-approved/12 text-state-approved", icon: Check },
|
good: {
|
||||||
|
text: "text-state-approved",
|
||||||
|
disc: "bg-state-approved/12 text-state-approved",
|
||||||
|
icon: Check,
|
||||||
|
},
|
||||||
bad: { text: "text-state-rejected", disc: "bg-state-rejected/12 text-state-rejected", icon: X },
|
bad: { text: "text-state-rejected", disc: "bg-state-rejected/12 text-state-rejected", icon: X },
|
||||||
|
warn: {
|
||||||
|
text: "text-state-clarification",
|
||||||
|
disc: "bg-state-clarification/12 text-state-clarification",
|
||||||
|
icon: TriangleAlert,
|
||||||
|
},
|
||||||
neutral: { text: "text-muted-foreground", disc: "bg-muted text-muted-foreground", icon: Minus },
|
neutral: { text: "text-muted-foreground", disc: "bg-muted text-muted-foreground", icon: Minus },
|
||||||
} as const;
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One 3-way-match check. Two lines: verdict on the first so the column of
|
* One 3-way-match check. Two lines: verdict on the first so the column of
|
||||||
* MATCH/MISMATCH scans vertically, supporting detail muted beneath it.
|
* MATCH/MISMATCH scans vertically, supporting detail muted beneath it.
|
||||||
*/
|
*/
|
||||||
function MatchRow({ check }: { check: MatchCheck }) {
|
function MatchRow({ check }: { check: Verdict }) {
|
||||||
const tone = MATCH_TONE[check.tone];
|
const tone = MATCH_TONE[check.tone];
|
||||||
return (
|
return (
|
||||||
<li className="flex items-start gap-2.5">
|
<li className="flex items-start gap-2.5">
|
||||||
|
|||||||
@ -1,280 +0,0 @@
|
|||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
148
src/components/SourceDocument.tsx
Normal file
148
src/components/SourceDocument.tsx
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
// The source invoice PDF, viewable by whoever is looking at the record.
|
||||||
|
//
|
||||||
|
// `invoice_pdf` is write-only everywhere else: the vendor uploads it, OCR reads
|
||||||
|
// it, and it appears in no record view and no detail view — so the department
|
||||||
|
// answering a clarification and the reviewer approving payment can't open the
|
||||||
|
// document they're 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 render inline AND be opened or saved on demand.
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { AlertCircle, Download, ExternalLink, Eye, FileText, Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { downloadFieldFile, type FileMeta } from "@/api/viewService";
|
||||||
|
import { formatBytes } from "@/lib/format";
|
||||||
|
|
||||||
|
export default function SourceDocument({ file }: { file: FileMeta | null }) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return (
|
||||||
|
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<FileText className="size-3.5 shrink-0" aria-hidden />
|
||||||
|
No document was attached to this submission.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPdf = (file.mime_type || "").includes("pdf");
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (!url) return;
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = file.original_name || "invoice.pdf";
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||||
|
<FileText className="size-4 text-primary" aria-hidden />
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="truncate text-md font-semibold text-foreground">
|
||||||
|
{file.original_name || "Invoice document"}
|
||||||
|
</p>
|
||||||
|
<p className="text-2xs text-muted-foreground">
|
||||||
|
{formatBytes(file.size_bytes)}
|
||||||
|
{isPdf ? " · PDF" : file.mime_type ? ` · ${file.mime_type}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Button variant="outline" size="sm" className="h-8 gap-1.5" onClick={() => setOpen((o) => !o)}>
|
||||||
|
<Eye className="size-3.5" aria-hidden />
|
||||||
|
{open ? "Hide" : "View"}
|
||||||
|
</Button>
|
||||||
|
{url && (
|
||||||
|
<>
|
||||||
|
<Button asChild variant="ghost" size="sm" className="h-8 gap-1.5">
|
||||||
|
<a href={url} target="_blank" rel="noreferrer">
|
||||||
|
<ExternalLink className="size-3.5" aria-hidden />
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 gap-1.5" onClick={save}>
|
||||||
|
<Download className="size-3.5" aria-hidden />
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-3 overflow-hidden rounded-lg border border-border/60">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex h-64 flex-col items-center justify-center gap-2 bg-muted/30">
|
||||||
|
<Loader2 className="size-4 animate-spin text-primary" aria-hidden />
|
||||||
|
<span className="text-xs text-muted-foreground">Loading document…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="flex items-start gap-2 px-3 py-2.5 text-sm text-destructive">
|
||||||
|
<AlertCircle className="mt-0.5 size-3.5 shrink-0" aria-hidden />
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{url &&
|
||||||
|
!loading &&
|
||||||
|
!error &&
|
||||||
|
(isPdf ? (
|
||||||
|
<iframe
|
||||||
|
src={url}
|
||||||
|
title={file.original_name || "Invoice"}
|
||||||
|
className="h-[560px] w-full bg-muted/30"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img
|
||||||
|
src={url}
|
||||||
|
alt={file.original_name || "Invoice"}
|
||||||
|
className="max-h-[560px] w-full bg-muted/30 object-contain"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,154 +0,0 @@
|
|||||||
// 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -42,6 +42,13 @@ export function formatRelative(value: string): string | null {
|
|||||||
return rtf.format(Math.round(diffDays / 30), "month");
|
return rtf.format(Math.round(diffDays / 30), "month");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatBytes(n?: number): string {
|
||||||
|
if (n == null || !Number.isFinite(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`;
|
||||||
|
}
|
||||||
|
|
||||||
export function formatLabel(label: string): string {
|
export function formatLabel(label: string): string {
|
||||||
return label.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
return label.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,21 +47,11 @@ export const STATE_META: Record<string, StateMeta> = {
|
|||||||
export const stateMeta = (uid?: string | null): StateMeta =>
|
export const stateMeta = (uid?: string | null): StateMeta =>
|
||||||
(uid && STATE_META[uid]) || { label: "—", tone: "slate" };
|
(uid && STATE_META[uid]) || { label: "—", tone: "slate" };
|
||||||
|
|
||||||
/** Tailwind classes per tone, for pills and dots. Light + dark. */
|
// TONE_PILL / TONE_DOT lived here as hardcoded slate/zinc/amber class strings.
|
||||||
export const TONE_PILL: Record<StateTone, string> = {
|
// They were the only remaining pre-migration palette in the app; state colour
|
||||||
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",
|
// now resolves through lib/workflow.ts tones and StatusBadge. STATE_META is
|
||||||
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",
|
// kept because keying display metadata off state UUIDs is more robust than
|
||||||
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",
|
// matching served names, and folding it into lib/workflow.ts is the next step.
|
||||||
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
|
// Actors
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user