Merge branch 'main': keep the rebuilt UI, land the new data layer
AppShell resolved in favour of the rebuilt shell. The incoming hunk routed workflow instances to InvoiceDetail, whose components predate the Tailwind v4 token migration and are written in hardcoded slate/zinc with a second dark palette, so adopting them wholesale would have put two visual languages on one page. Their data layer lands intact and unrendered for now: the monitor/audit/file endpoints in viewService, useInstanceDetail, lib/matchSummary and lib/workflowMeta. Next step is to port that logic into the rebuilt UI — source-document viewer, audit trail, AI decision/trace panels and the structured analysis_so_far evidence are all data we do not surface yet.
This commit is contained in:
commit
e32bc729c5
@ -491,8 +491,10 @@ export interface AuditEntry {
|
|||||||
id: number;
|
id: number;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
user_roles: string[];
|
user_roles: string[];
|
||||||
|
user_groups?: string[];
|
||||||
activity_id: string;
|
activity_id: string;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
|
/** State UUID the instance landed in after this activity. */
|
||||||
execution_state: string;
|
execution_state: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
// Optional human-readable summary line. Populated by useInstanceMeta when
|
// Optional human-readable summary line. Populated by useInstanceMeta when
|
||||||
@ -504,6 +506,128 @@ export function getAuditLog(instanceId: string): Promise<AuditEntry[]> {
|
|||||||
return request("GET", `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(instanceId)}`);
|
return request("GET", `/app/${APP_ID}/view/audit?instance_id=${encodeURIComponent(instanceId)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// AI Employee monitoring (ai-employee-service)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// These are the same endpoints the studio builder's "AI Employee → Logs" tab
|
||||||
|
// uses. They are NOT app-scoped: the ingress maps studio.getzino.in/monitor
|
||||||
|
// straight to ai-employee-service:8090, so the path has no /app/{id} prefix.
|
||||||
|
// The app's own JWT is accepted.
|
||||||
|
|
||||||
|
/** One reasoning pass the employee took, as stored in aiemployee.tbl_ai_decisions. */
|
||||||
|
export interface AIDecision {
|
||||||
|
id: number;
|
||||||
|
ai_user_id: string;
|
||||||
|
instance_id: string;
|
||||||
|
/** The activity the employee SUBMITTED (its chosen next step), not the one it read. */
|
||||||
|
activity_id: string;
|
||||||
|
/** State the instance was in when the employee looked at it. */
|
||||||
|
instance_state?: string;
|
||||||
|
status: "submitted" | "failed" | "error" | "escalated" | "submitting" | string;
|
||||||
|
confidence?: number;
|
||||||
|
model?: string;
|
||||||
|
created_at: string;
|
||||||
|
/** Opening plan the model wrote before acting. */
|
||||||
|
plan?: string;
|
||||||
|
/** Why it chose this action. */
|
||||||
|
reasoning?: string;
|
||||||
|
/** Self-check pass. */
|
||||||
|
reflexion?: {
|
||||||
|
concerns?: string;
|
||||||
|
should_escalate?: boolean;
|
||||||
|
adjusted_confidence?: number;
|
||||||
|
} | null;
|
||||||
|
knowledge_sources?: string[] | null;
|
||||||
|
/** The payload it submitted with the activity. */
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
error_message?: string;
|
||||||
|
cost?: number;
|
||||||
|
llm_calls?: number;
|
||||||
|
duration_ms?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIEscalation {
|
||||||
|
id: number;
|
||||||
|
instance_id: string;
|
||||||
|
recommended_action?: string;
|
||||||
|
reasoning?: string;
|
||||||
|
reason?: string;
|
||||||
|
status?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIInstanceLog {
|
||||||
|
instance_id: string;
|
||||||
|
decisions: AIDecision[];
|
||||||
|
escalations: AIEscalation[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One step inside a run — a tool call, an LLM call, a context gather, etc. */
|
||||||
|
export interface AITraceEvent {
|
||||||
|
seq: number;
|
||||||
|
parent_seq?: number;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
started_at: string;
|
||||||
|
ended_at?: string;
|
||||||
|
duration_ms?: number;
|
||||||
|
input?: unknown;
|
||||||
|
output?: unknown;
|
||||||
|
tokens_in?: number;
|
||||||
|
tokens_out?: number;
|
||||||
|
cost?: number;
|
||||||
|
error?: string;
|
||||||
|
decision_id?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One task the employee processed end-to-end. */
|
||||||
|
export interface AITrace {
|
||||||
|
trace_id: string;
|
||||||
|
events: AITraceEvent[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AIInstanceTrace {
|
||||||
|
instance_id: string;
|
||||||
|
traces: AITrace[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAIInstanceLog(instanceId: string): Promise<AIInstanceLog> {
|
||||||
|
return request("GET", `/monitor/instances/${encodeURIComponent(instanceId)}/log`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAIInstanceTrace(instanceId: string): Promise<AIInstanceTrace> {
|
||||||
|
return request("GET", `/monitor/instances/${encodeURIComponent(instanceId)}/trace`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Field-file download
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches a stored form-field file (e.g. the uploaded invoice PDF) as a Blob.
|
||||||
|
* `blobPath` comes from the FileMeta the upload returned and is echoed back in
|
||||||
|
* the activity's submitted data. Returns a Blob so the caller can either build
|
||||||
|
* an object URL for inline preview or trigger a download — the endpoint sets
|
||||||
|
* Content-Disposition: attachment, so a plain link would always download.
|
||||||
|
*/
|
||||||
|
export async function downloadFieldFile(blobPath: string, originalName?: string): Promise<Blob> {
|
||||||
|
const params = new URLSearchParams({ path: blobPath });
|
||||||
|
if (originalName) params.set("name", originalName);
|
||||||
|
const token = localStorage.getItem(TOKEN_KEY);
|
||||||
|
const res = await fetch(`${baseUrl()}/app/${APP_ID}/download?${params.toString()}`, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 401) {
|
||||||
|
localStorage.removeItem(TOKEN_KEY);
|
||||||
|
window.location.href = `${import.meta.env.BASE_URL}login`;
|
||||||
|
}
|
||||||
|
throw new Error(`Could not load the document (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.blob();
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// RDBMS lookup records — used by the demo calendar to pull rows from the
|
// RDBMS lookup records — used by the demo calendar to pull rows from the
|
||||||
// `mahindra_demo` schema via the lookup-field config on the INIT activity.
|
// `mahindra_demo` schema via the lookup-field config on the INIT activity.
|
||||||
|
|||||||
343
src/components/AIActivityPanel.tsx
Normal file
343
src/components/AIActivityPanel.tsx
Normal file
@ -0,0 +1,343 @@
|
|||||||
|
// AIActivityPanel — the AI employee's reasoning, brought into the app.
|
||||||
|
//
|
||||||
|
// Port of the studio builder's "AI Employee → Logs" tab
|
||||||
|
// (studio-frontend/src/components/monitoring/AIEmployeeLogsPanel.js +
|
||||||
|
// AIEmployeeTrace.jsx), narrowed to a single instance and restyled to match
|
||||||
|
// this app. Same data, same event vocabulary and confidence thresholds — a
|
||||||
|
// client shouldn't have to be shown the builder to see what the AI did.
|
||||||
|
//
|
||||||
|
// Two sources, both instance-scoped:
|
||||||
|
// /monitor/instances/{id}/log → decisions (+ escalations)
|
||||||
|
// /monitor/instances/{id}/trace → per-run step traces
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Sparkles, ChevronDown, ChevronRight, AlertTriangle, Coins, Hash, Timer, Layers,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { AIDecision, AIEscalation, AITrace, AITraceEvent } from "../api/viewService";
|
||||||
|
import { activityLabel, fmtDuration, fmtRelative, fmtDateTime } from "../lib/workflowMeta";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Event styling — mirrors studio's TYPE_META so the two read the same
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const TYPE_META: Record<string, { label: string; bar: string; chip: string }> = {
|
||||||
|
context: { label: "Context", bar: "bg-slate-400", chip: "bg-slate-100 text-slate-600 dark:bg-zinc-800 dark:text-zinc-300" },
|
||||||
|
plan: { label: "Plan", bar: "bg-violet-500", chip: "bg-violet-100 text-violet-700 dark:bg-violet-950/50 dark:text-violet-300" },
|
||||||
|
llm: { label: "LLM", bar: "bg-blue-500", chip: "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300" },
|
||||||
|
tool: { label: "Tool", bar: "bg-cyan-500", chip: "bg-cyan-100 text-cyan-700 dark:bg-cyan-950/50 dark:text-cyan-300" },
|
||||||
|
api: { label: "API", bar: "bg-amber-500", chip: "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300" },
|
||||||
|
knowledge: { label: "Knowledge", bar: "bg-emerald-500", chip: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300" },
|
||||||
|
reflexion: { label: "Reflexion", bar: "bg-fuchsia-500", chip: "bg-fuchsia-100 text-fuchsia-700 dark:bg-fuchsia-950/50 dark:text-fuchsia-300" },
|
||||||
|
decision: { label: "Decision", bar: "bg-emerald-600", chip: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300" },
|
||||||
|
escalation: { label: "Escalation", bar: "bg-amber-600", chip: "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300" },
|
||||||
|
};
|
||||||
|
const FALLBACK = { label: "Step", bar: "bg-slate-400", chip: "bg-slate-100 text-slate-600 dark:bg-zinc-800 dark:text-zinc-300" };
|
||||||
|
const metaFor = (t: string) => TYPE_META[t] ?? FALLBACK;
|
||||||
|
|
||||||
|
const STATUS_CHIP: Record<string, string> = {
|
||||||
|
submitted: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300",
|
||||||
|
submitting: "bg-blue-100 text-blue-700 dark:bg-blue-950/50 dark:text-blue-300",
|
||||||
|
failed: "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300",
|
||||||
|
error: "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300",
|
||||||
|
escalated: "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Same thresholds studio uses: >=90 good, >=70 caution, below that a warning.
|
||||||
|
function ConfidencePill({ value }: { value?: number }) {
|
||||||
|
if (value == null) return null;
|
||||||
|
const pct = Math.round(value * 100);
|
||||||
|
const cls = pct >= 90
|
||||||
|
? "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-300"
|
||||||
|
: pct >= 70
|
||||||
|
? "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-300"
|
||||||
|
: "bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300";
|
||||||
|
return <span className={`px-1.5 py-0.5 rounded text-[10.5px] font-bold ${cls}`}>{pct}%</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmtCost = (c?: number): string | null => {
|
||||||
|
if (!c) return null;
|
||||||
|
return c < 0.0001 ? "<$0.0001" : `$${c.toFixed(4)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const asText = (v: unknown): string =>
|
||||||
|
v == null ? "" : typeof v === "string" ? v : JSON.stringify(v, null, 2);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Trace waterfall
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function Waterfall({ trace }: { trace: AITrace }) {
|
||||||
|
const [openSeq, setOpenSeq] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { t0, span, totals } = useMemo(() => {
|
||||||
|
const starts = trace.events.map((e) => new Date(e.started_at).getTime()).filter((n) => !Number.isNaN(n));
|
||||||
|
const ends = trace.events.map((e) =>
|
||||||
|
new Date(e.ended_at ?? e.started_at).getTime()).filter((n) => !Number.isNaN(n));
|
||||||
|
const start = starts.length ? Math.min(...starts) : 0;
|
||||||
|
const end = ends.length ? Math.max(...ends) : start + 1;
|
||||||
|
return {
|
||||||
|
t0: start,
|
||||||
|
span: Math.max(end - start, 1),
|
||||||
|
totals: trace.events.reduce(
|
||||||
|
(acc, e) => ({
|
||||||
|
tokens: acc.tokens + (e.tokens_in ?? 0) + (e.tokens_out ?? 0),
|
||||||
|
cost: acc.cost + (e.cost ?? 0),
|
||||||
|
}),
|
||||||
|
{ tokens: 0, cost: 0 },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}, [trace]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="flex items-center gap-4 mb-2 text-[10.5px] text-slate-400 dark:text-zinc-500">
|
||||||
|
<span className="inline-flex items-center gap-1"><Layers size={10} />{trace.events.length} steps</span>
|
||||||
|
<span className="inline-flex items-center gap-1"><Timer size={10} />{fmtDuration(span)}</span>
|
||||||
|
{totals.tokens > 0 && (
|
||||||
|
<span className="inline-flex items-center gap-1"><Hash size={10} />{totals.tokens.toLocaleString()} tokens</span>
|
||||||
|
)}
|
||||||
|
{fmtCost(totals.cost) && (
|
||||||
|
<span className="inline-flex items-center gap-1"><Coins size={10} />{fmtCost(totals.cost)}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="space-y-1">
|
||||||
|
{trace.events.map((ev: AITraceEvent) => {
|
||||||
|
const m = metaFor(ev.type);
|
||||||
|
const s = new Date(ev.started_at).getTime();
|
||||||
|
const dur = ev.duration_ms ?? Math.max(new Date(ev.ended_at ?? ev.started_at).getTime() - s, 0);
|
||||||
|
const left = Number.isNaN(s) ? 0 : ((s - t0) / span) * 100;
|
||||||
|
const width = Math.max((dur / span) * 100, 1.2);
|
||||||
|
const failed = ev.status && ev.status !== "ok" && ev.status !== "success";
|
||||||
|
const isOpen = openSeq === ev.seq;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={ev.seq}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpenSeq(isOpen ? null : ev.seq)}
|
||||||
|
className="w-full text-left group"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={`shrink-0 px-1.5 py-0.5 rounded text-[9.5px] font-bold uppercase tracking-wide ${m.chip}`}>
|
||||||
|
{m.label}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11.5px] text-slate-600 dark:text-zinc-300 truncate max-w-[190px] group-hover:text-violet-600 dark:group-hover:text-violet-400">
|
||||||
|
{ev.name}
|
||||||
|
</span>
|
||||||
|
{/* Waterfall lane */}
|
||||||
|
<span className="flex-1 h-[7px] bg-slate-100 dark:bg-zinc-800 rounded-full relative overflow-hidden min-w-[60px]">
|
||||||
|
<span
|
||||||
|
className={`absolute top-0 bottom-0 rounded-full ${failed ? "bg-red-500" : m.bar}`}
|
||||||
|
style={{ left: `${left}%`, width: `${width}%` }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span className="shrink-0 text-[10.5px] tabular-nums text-slate-400 dark:text-zinc-500 w-14 text-right">
|
||||||
|
{fmtDuration(dur)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="ml-2 mt-1.5 mb-2 pl-3 border-l-2 border-slate-100 dark:border-zinc-800 space-y-2">
|
||||||
|
{(ev.tokens_in || ev.tokens_out) && (
|
||||||
|
<p className="text-[10.5px] text-slate-400 dark:text-zinc-500">
|
||||||
|
{(ev.tokens_in ?? 0).toLocaleString()} in · {(ev.tokens_out ?? 0).toLocaleString()} out
|
||||||
|
{fmtCost(ev.cost) ? ` · ${fmtCost(ev.cost)}` : ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{ev.error && (
|
||||||
|
<p className="text-[11.5px] text-red-600 dark:text-red-400">{ev.error}</p>
|
||||||
|
)}
|
||||||
|
{ev.input != null && (
|
||||||
|
<Snippet label="Input" text={asText(ev.input)} />
|
||||||
|
)}
|
||||||
|
{ev.output != null && (
|
||||||
|
<Snippet label={ev.type === "reflexion" ? "Self-critique" : ev.type === "plan" ? "Plan" : "Output"} text={asText(ev.output)} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Snippet({ label, text }: { label: string; text: string }) {
|
||||||
|
if (!text.trim()) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-slate-400 dark:text-zinc-600 mb-0.5">{label}</div>
|
||||||
|
<pre className="text-[11px] leading-relaxed text-slate-600 dark:text-zinc-300 bg-slate-50 dark:bg-zinc-800/60 rounded-lg px-2.5 py-2 max-h-52 overflow-auto whitespace-pre-wrap break-words">
|
||||||
|
{text}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Decision card
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function DecisionCard({ d, trace }: { d: AIDecision; trace?: AITrace }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const escalated = d.reflexion?.should_escalate;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-slate-200/80 dark:border-zinc-700/60 px-4 py-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100">
|
||||||
|
{activityLabel(d.activity_id)}
|
||||||
|
</span>
|
||||||
|
<span className={`px-1.5 py-0.5 rounded text-[10px] font-bold uppercase ${STATUS_CHIP[d.status] ?? "bg-slate-100 text-slate-600 dark:bg-zinc-800 dark:text-zinc-400"}`}>
|
||||||
|
{d.status}
|
||||||
|
</span>
|
||||||
|
<ConfidencePill value={d.confidence} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[10.5px] text-slate-400 dark:text-zinc-500 mt-0.5">
|
||||||
|
{d.instance_state ? `saw "${d.instance_state}"` : null}
|
||||||
|
{d.model ? ` · ${d.model}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-[11px] text-slate-400 dark:text-zinc-500 shrink-0" title={fmtDateTime(d.created_at)}>
|
||||||
|
{fmtRelative(d.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{d.reasoning && (
|
||||||
|
<p className="text-[12.5px] leading-relaxed text-slate-600 dark:text-zinc-300 mt-2">
|
||||||
|
{d.reasoning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{d.error_message && (
|
||||||
|
<div className="mt-2 flex items-start gap-1.5 text-[11.5px] text-red-600 dark:text-red-400">
|
||||||
|
<AlertTriangle size={12} className="shrink-0 mt-0.5" />
|
||||||
|
<span className="break-words">{d.error_message}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{escalated && (
|
||||||
|
<div className="mt-2 flex items-center gap-1.5 text-[11.5px] text-amber-700 dark:text-amber-400">
|
||||||
|
<AlertTriangle size={12} />Flagged for human escalation
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="mt-2 text-[11px] text-slate-400 dark:text-zinc-500 hover:text-violet-600 dark:hover:text-violet-400 inline-flex items-center gap-1 transition-colors"
|
||||||
|
>
|
||||||
|
{open ? <ChevronDown size={11} /> : <ChevronRight size={11} />}
|
||||||
|
{open ? "Hide reasoning trail" : "Reasoning trail"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-2 space-y-2.5">
|
||||||
|
{d.plan && <Snippet label="Plan" text={d.plan} />}
|
||||||
|
{d.reflexion?.concerns ? <Snippet label="Concerns" text={d.reflexion.concerns} /> : null}
|
||||||
|
{d.reflexion?.adjusted_confidence != null && (
|
||||||
|
<p className="text-[11px] text-slate-400 dark:text-zinc-500">
|
||||||
|
Adjusted confidence after self-check: {Math.round(d.reflexion.adjusted_confidence * 100)}%
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{trace && <Waterfall trace={trace} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
decisions: AIDecision[];
|
||||||
|
escalations: AIEscalation[];
|
||||||
|
traces: AITrace[];
|
||||||
|
unavailable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIActivityPanel({ decisions, escalations, traces, unavailable }: Props) {
|
||||||
|
// Each trace carries the decision_id it belongs to on its events, so the
|
||||||
|
// waterfall can be attached to the decision it explains.
|
||||||
|
const traceByDecision = useMemo(() => {
|
||||||
|
const m = new Map<number, AITrace>();
|
||||||
|
for (const t of traces) {
|
||||||
|
const id = t.events.find((e) => e.decision_id != null)?.decision_id;
|
||||||
|
if (id != null && !m.has(id)) m.set(id, t);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [traces]);
|
||||||
|
|
||||||
|
const ordered = useMemo(
|
||||||
|
() => [...decisions].sort(
|
||||||
|
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()),
|
||||||
|
[decisions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totals = useMemo(() => {
|
||||||
|
let tokens = 0, cost = 0;
|
||||||
|
for (const t of traces) for (const e of t.events) {
|
||||||
|
tokens += (e.tokens_in ?? 0) + (e.tokens_out ?? 0);
|
||||||
|
cost += e.cost ?? 0;
|
||||||
|
}
|
||||||
|
return { tokens, cost };
|
||||||
|
}, [traces]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
|
||||||
|
<div className="flex items-center justify-between mb-3 gap-3 flex-wrap">
|
||||||
|
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 flex items-center gap-2">
|
||||||
|
<Sparkles size={14} className="text-violet-500" />
|
||||||
|
AI Review
|
||||||
|
<span className="font-normal text-slate-400 dark:text-zinc-500">· Priya · AI Finance</span>
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center gap-3 text-[10.5px] text-slate-400 dark:text-zinc-500">
|
||||||
|
{ordered.length > 0 && <span>{ordered.length} {ordered.length === 1 ? "pass" : "passes"}</span>}
|
||||||
|
{totals.tokens > 0 && <span>{totals.tokens.toLocaleString()} tokens</span>}
|
||||||
|
{fmtCost(totals.cost) && <span>{fmtCost(totals.cost)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{unavailable ? (
|
||||||
|
<p className="text-[12.5px] text-slate-400 dark:text-zinc-500">
|
||||||
|
AI activity is unavailable right now — the monitoring service couldn't be reached.
|
||||||
|
</p>
|
||||||
|
) : ordered.length === 0 ? (
|
||||||
|
<p className="text-[12.5px] text-slate-400 dark:text-zinc-500">
|
||||||
|
No AI review recorded for this invoice yet.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2.5">
|
||||||
|
{ordered.map((d) => (
|
||||||
|
<DecisionCard key={d.id} d={d} trace={traceByDecision.get(d.id)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{escalations.length > 0 && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-zinc-800">
|
||||||
|
<h4 className="text-[11px] uppercase tracking-wide text-amber-600 dark:text-amber-400 mb-2 flex items-center gap-1.5">
|
||||||
|
<AlertTriangle size={11} />Escalations
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{escalations.map((e) => (
|
||||||
|
<div key={e.id} className="text-[12px] text-slate-600 dark:text-zinc-300">
|
||||||
|
<span className="font-medium">{e.recommended_action || "Escalated"}</span>
|
||||||
|
{e.reason ? <> — {e.reason}</> : null}
|
||||||
|
<span className="text-slate-400 dark:text-zinc-500"> · {fmtRelative(e.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
199
src/components/AuditTimeline.tsx
Normal file
199
src/components/AuditTimeline.tsx
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
// AuditTimeline — the invoice's history as a vertical timeline.
|
||||||
|
//
|
||||||
|
// Built from GET /app/{id}/view/audit, which returns one row per performed
|
||||||
|
// activity with the actor (user_id + roles), the resulting state, the timestamp
|
||||||
|
// and the submitted payload. A single status pill can't explain how an invoice
|
||||||
|
// reached its state; this can — including which steps the AI took versus a
|
||||||
|
// person, and how long each hop waited.
|
||||||
|
//
|
||||||
|
// The helper for this endpoint existed in viewService.ts and was never called
|
||||||
|
// from anywhere in the UI.
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Sparkles, User, ChevronRight, FileUp, HelpCircle, MessageSquareReply,
|
||||||
|
Gavel, CheckCircle2, XCircle, PauseCircle, Clock,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { AuditEntry } from "../api/viewService";
|
||||||
|
import { ACTIVITY_IDS } from "../config";
|
||||||
|
import {
|
||||||
|
activityLabel, stateMeta, resolveActor, fmtDateTime, fmtRelative, fmtGap,
|
||||||
|
TONE_DOT, TONE_PILL, fmtAmount,
|
||||||
|
} from "../lib/workflowMeta";
|
||||||
|
|
||||||
|
const ACTIVITY_ICON: Record<string, React.ReactNode> = {
|
||||||
|
[ACTIVITY_IDS.SUBMIT_INVOICE]: <FileUp size={13} />,
|
||||||
|
[ACTIVITY_IDS.REQUEST_CLARIFICATION]: <HelpCircle size={13} />,
|
||||||
|
[ACTIVITY_IDS.PROVIDE_CLARIFICATION]: <MessageSquareReply size={13} />,
|
||||||
|
[ACTIVITY_IDS.SUBMIT_RECOMMENDATION]: <Gavel size={13} />,
|
||||||
|
[ACTIVITY_IDS.APPROVE_PAYMENT]: <CheckCircle2 size={13} />,
|
||||||
|
[ACTIVITY_IDS.REJECT_PAYMENT]: <XCircle size={13} />,
|
||||||
|
[ACTIVITY_IDS.HOLD_PAYMENT]: <PauseCircle size={13} />,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Per-activity headline: the one or two values that matter at a glance, so the
|
||||||
|
// timeline reads as a story instead of a list of generic event names.
|
||||||
|
function summarise(e: AuditEntry): string | null {
|
||||||
|
const d = (e.data ?? {}) as Record<string, any>;
|
||||||
|
switch (e.activity_id) {
|
||||||
|
case ACTIVITY_IDS.SUBMIT_INVOICE:
|
||||||
|
return [d.invoice_number, d.po_number, d.total_amount != null ? fmtAmount(d.total_amount, d.field_5 || "INR") : null]
|
||||||
|
.filter(Boolean).join(" · ") || null;
|
||||||
|
case ACTIVITY_IDS.REQUEST_CLARIFICATION:
|
||||||
|
return d.target_department ? `Asked ${String(d.target_department).replace(/_/g, " ")}` : null;
|
||||||
|
case ACTIVITY_IDS.PROVIDE_CLARIFICATION:
|
||||||
|
return d.response_text ? String(d.response_text) : null;
|
||||||
|
case ACTIVITY_IDS.SUBMIT_RECOMMENDATION:
|
||||||
|
return [
|
||||||
|
d.recommendation ? String(d.recommendation).toUpperCase() : null,
|
||||||
|
d.recommended_amount != null ? fmtAmount(d.recommended_amount) : null,
|
||||||
|
d.ai_confidence != null ? `${Math.round(Number(d.ai_confidence) * 100)}% confidence` : null,
|
||||||
|
].filter(Boolean).join(" · ") || null;
|
||||||
|
case ACTIVITY_IDS.APPROVE_PAYMENT:
|
||||||
|
return d.approved_amount != null ? fmtAmount(d.approved_amount) : null;
|
||||||
|
case ACTIVITY_IDS.REJECT_PAYMENT:
|
||||||
|
return d.rejection_reason ? String(d.rejection_reason) : null;
|
||||||
|
case ACTIVITY_IDS.HOLD_PAYMENT:
|
||||||
|
return d.hold_reason ? String(d.hold_reason) : null;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fields already shown in the headline, or internal — don't repeat them in the
|
||||||
|
// expanded payload.
|
||||||
|
const HIDE_KEYS = new Set([
|
||||||
|
"invoice_pdf", "analysis_so_far", "_nodes", "_http", "_responsePayload",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function PayloadGrid({ data }: { data: Record<string, unknown> }) {
|
||||||
|
const rows = Object.entries(data).filter(
|
||||||
|
([k, v]) => !HIDE_KEYS.has(k) && v !== null && v !== "" && typeof v !== "object",
|
||||||
|
);
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<dl className="mt-2.5 grid grid-cols-2 gap-x-5 gap-y-1.5">
|
||||||
|
{rows.map(([k, v]) => (
|
||||||
|
<div key={k} className="min-w-0">
|
||||||
|
<dt className="text-[10.5px] uppercase tracking-wide text-slate-400 dark:text-zinc-600">
|
||||||
|
{k.replace(/_/g, " ")}
|
||||||
|
</dt>
|
||||||
|
<dd className="text-[12.5px] text-slate-700 dark:text-zinc-200 break-words">{String(v)}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AuditTimeline({ entries }: { entries: AuditEntry[] }) {
|
||||||
|
const [expanded, setExpanded] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
|
const toggle = (id: number) =>
|
||||||
|
setExpanded((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.has(id) ? next.delete(id) : next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return (
|
||||||
|
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-6">
|
||||||
|
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 mb-1">History</h3>
|
||||||
|
<p className="text-[12.5px] text-slate-400 dark:text-zinc-500">No activity recorded yet.</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 flex items-center gap-2">
|
||||||
|
<Clock size={14} className="text-slate-400" />History
|
||||||
|
</h3>
|
||||||
|
<span className="text-[11px] text-slate-400 dark:text-zinc-500">
|
||||||
|
{entries.length} {entries.length === 1 ? "step" : "steps"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ol className="relative">
|
||||||
|
{entries.map((e, i) => {
|
||||||
|
const actor = resolveActor(e.user_roles, e.user_id);
|
||||||
|
const st = stateMeta(e.execution_state);
|
||||||
|
const isLast = i === entries.length - 1;
|
||||||
|
const gap = i > 0 ? fmtGap(entries[i - 1].created_at, e.created_at) : "";
|
||||||
|
const headline = summarise(e);
|
||||||
|
const isOpen = expanded.has(e.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={e.id} className="relative pl-9 pb-5 last:pb-0">
|
||||||
|
{/* Rail */}
|
||||||
|
{!isLast && (
|
||||||
|
<span className="absolute left-[11px] top-6 bottom-0 w-px bg-slate-200 dark:bg-zinc-700" />
|
||||||
|
)}
|
||||||
|
{/* Node */}
|
||||||
|
<span
|
||||||
|
className={`absolute left-0 top-0.5 w-[23px] h-[23px] rounded-full flex items-center justify-center ring-4 ring-white dark:ring-zinc-900 ${
|
||||||
|
actor.isAI
|
||||||
|
? "bg-violet-100 text-violet-600 dark:bg-violet-950/60 dark:text-violet-300"
|
||||||
|
: "bg-slate-100 text-slate-500 dark:bg-zinc-800 dark:text-zinc-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ACTIVITY_ICON[e.activity_id] ?? <ChevronRight size={13} />}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100">
|
||||||
|
{activityLabel(e.activity_id)}
|
||||||
|
</span>
|
||||||
|
<span className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10.5px] font-medium ${
|
||||||
|
actor.isAI
|
||||||
|
? "text-violet-700 bg-violet-50 dark:text-violet-300 dark:bg-violet-950/50"
|
||||||
|
: "text-slate-500 bg-slate-100 dark:text-zinc-400 dark:bg-zinc-800"
|
||||||
|
}`}>
|
||||||
|
{actor.isAI ? <Sparkles size={9} /> : <User size={9} />}{actor.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{headline && (
|
||||||
|
<p className="text-[12.5px] text-slate-600 dark:text-zinc-300 mt-1 line-clamp-2">
|
||||||
|
{headline}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10.5px] font-medium ${TONE_PILL[st.tone]}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${TONE_DOT[st.tone]}`} />
|
||||||
|
{st.label}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggle(e.id)}
|
||||||
|
className="text-[11px] text-slate-400 dark:text-zinc-500 hover:text-violet-600 dark:hover:text-violet-400 transition-colors"
|
||||||
|
>
|
||||||
|
{isOpen ? "Hide details" : "Details"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && <PayloadGrid data={(e.data ?? {}) as Record<string, unknown>} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right shrink-0">
|
||||||
|
<div className="text-[11px] text-slate-400 dark:text-zinc-500 whitespace-nowrap" title={fmtDateTime(e.created_at)}>
|
||||||
|
{fmtRelative(e.created_at)}
|
||||||
|
</div>
|
||||||
|
{gap && (
|
||||||
|
<div className="text-[10.5px] text-slate-300 dark:text-zinc-600 whitespace-nowrap mt-0.5">
|
||||||
|
+{gap}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
280
src/components/InvoiceDetail.tsx
Normal file
280
src/components/InvoiceDetail.tsx
Normal file
@ -0,0 +1,280 @@
|
|||||||
|
// InvoiceDetail — the invoice page, given a hierarchy.
|
||||||
|
//
|
||||||
|
// It used to render a flat grid of every field the detail view returned: 29 for
|
||||||
|
// Invoice Detail, 21 for Finance Review Detail. Grouped, but with no ranking, so
|
||||||
|
// the reviewer's actual decision inputs — the recommendation, its confidence,
|
||||||
|
// the match result, the amount — sat among twenty peers of equal weight.
|
||||||
|
//
|
||||||
|
// Order here follows the decision the viewer has to make:
|
||||||
|
//
|
||||||
|
// 1. Identity + status who/what/where in the flow
|
||||||
|
// 2. AI recommendation the answer, up front
|
||||||
|
// 3. 3-way match evidence why that answer is defensible
|
||||||
|
// 4. Clarification Q&A the human exchange, if there was one
|
||||||
|
// 5. Source document the PDF being reasoned about
|
||||||
|
// 6. AI review the full reasoning trail, on demand
|
||||||
|
// 7. History how it got here
|
||||||
|
// 8. All fields the original grid, collapsed
|
||||||
|
//
|
||||||
|
// Everything derives from one fetch pass (useInstanceDetail).
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
ChevronDown, ChevronRight, Sparkles, MessageSquare, CheckCircle2,
|
||||||
|
XCircle, PauseCircle, HelpCircle, Loader2, AlertCircle, ListTree,
|
||||||
|
} from "lucide-react";
|
||||||
|
import DetailViewPanel from "./DetailViewPanel";
|
||||||
|
import ThreeWayMatchCard from "./ThreeWayMatchCard";
|
||||||
|
import InvoiceDocumentCard from "./InvoiceDocumentCard";
|
||||||
|
import AIActivityPanel from "./AIActivityPanel";
|
||||||
|
import AuditTimeline from "./AuditTimeline";
|
||||||
|
import { useInstanceDetail } from "../hooks/useInstanceDetail";
|
||||||
|
import { stateMeta, TONE_PILL, TONE_DOT, fmtAmount, fmtDateTime } from "../lib/workflowMeta";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instanceId: string;
|
||||||
|
/** dv/screen uuid used for the collapsed full-field grid. */
|
||||||
|
viewId: number | string;
|
||||||
|
/** Current state UUID, from the instance. */
|
||||||
|
stateId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REC_STYLE: Record<string, { tone: string; icon: React.ReactNode; label: string }> = {
|
||||||
|
approve: { tone: "text-emerald-700 bg-emerald-50 ring-emerald-200 dark:text-emerald-300 dark:bg-emerald-950/40 dark:ring-emerald-800/60", icon: <CheckCircle2 size={13} />, label: "Approve" },
|
||||||
|
partial_approve: { tone: "text-amber-700 bg-amber-50 ring-amber-200 dark:text-amber-300 dark:bg-amber-950/40 dark:ring-amber-800/60", icon: <PauseCircle size={13} />, label: "Partial approve" },
|
||||||
|
reject: { tone: "text-red-700 bg-red-50 ring-red-200 dark:text-red-300 dark:bg-red-950/40 dark:ring-red-800/60", icon: <XCircle size={13} />, label: "Reject" },
|
||||||
|
hold: { tone: "text-slate-600 bg-slate-100 ring-slate-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700", icon: <PauseCircle size={13} />, label: "Hold" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const str = (v: unknown): string | null => {
|
||||||
|
if (v == null) return null;
|
||||||
|
const s = String(v).trim();
|
||||||
|
return s === "" ? null : s;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InvoiceDetail({ instanceId, viewId, stateId }: Props) {
|
||||||
|
const d = useInstanceDetail(instanceId);
|
||||||
|
const [showAll, setShowAll] = useState(false);
|
||||||
|
|
||||||
|
if (d.loading) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 py-16 flex flex-col items-center gap-2">
|
||||||
|
<Loader2 size={20} className="animate-spin text-violet-500" />
|
||||||
|
<span className="text-[12px] text-slate-400 dark:text-zinc-500">Loading invoice…</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-red-200 dark:border-red-900 px-5 py-4 flex items-start gap-2 text-[13px] text-red-600 dark:text-red-400">
|
||||||
|
<AlertCircle size={15} className="shrink-0 mt-0.5" />{d.error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const s = d.submitted;
|
||||||
|
const cur = String(s.field_5 || "INR");
|
||||||
|
const st = stateMeta(stateId);
|
||||||
|
|
||||||
|
const recommendation = str(s.recommendation)?.toLowerCase();
|
||||||
|
const rec = recommendation ? REC_STYLE[recommendation] : undefined;
|
||||||
|
const confidence = s.ai_confidence != null ? Number(s.ai_confidence) : undefined;
|
||||||
|
|
||||||
|
const question = str(s.clarification_question);
|
||||||
|
const answer = str(s.response_text);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* ── 1. Identity + status ─────────────────────────────────────────── */}
|
||||||
|
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
|
<h2 className="text-[18px] font-bold text-slate-900 dark:text-zinc-50 tracking-tight">
|
||||||
|
{str(s.invoice_number) ?? `Invoice #${instanceId}`}
|
||||||
|
</h2>
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-semibold ${TONE_PILL[st.tone]}`}>
|
||||||
|
<span className={`w-1.5 h-1.5 rounded-full ${TONE_DOT[st.tone]}`} />{st.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[13px] text-slate-500 dark:text-zinc-400 mt-0.5">
|
||||||
|
{str(s.vendor_name) ?? "Unknown vendor"}
|
||||||
|
{str(s.vendor_id) ? ` · ${str(s.vendor_id)}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[22px] font-bold tabular-nums text-slate-900 dark:text-zinc-50 leading-none">
|
||||||
|
{fmtAmount(s.total_amount, cur)}
|
||||||
|
</div>
|
||||||
|
{s.tax_amount != null && (
|
||||||
|
<div className="text-[11px] text-slate-400 dark:text-zinc-500 mt-1">
|
||||||
|
incl. tax {fmtAmount(s.tax_amount, cur)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-6 flex-wrap mt-3.5 pt-3.5 border-t border-slate-100 dark:border-zinc-800">
|
||||||
|
<Meta label="Purchase order" value={str(s.po_number)} />
|
||||||
|
<Meta label="Invoice date" value={str(s.invoice_date)} />
|
||||||
|
<Meta label="Payment terms" value={str(s.payment_terms)} />
|
||||||
|
<Meta label="Currency" value={cur} />
|
||||||
|
<Meta label="Record" value={`#${instanceId}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{str(s.notes) && (
|
||||||
|
<p className="mt-3 text-[12.5px] text-slate-500 dark:text-zinc-400">
|
||||||
|
<span className="text-slate-400 dark:text-zinc-600">Vendor notes: </span>{str(s.notes)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* ── 2. AI recommendation ─────────────────────────────────────────── */}
|
||||||
|
{rec && (
|
||||||
|
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2 mb-2.5">
|
||||||
|
<Sparkles size={14} className="text-violet-500" />
|
||||||
|
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100">AI recommendation</h3>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-[12.5px] font-bold ring-1 ${rec.tone}`}>
|
||||||
|
{rec.icon}{rec.label}
|
||||||
|
</span>
|
||||||
|
{s.recommended_amount != null && (
|
||||||
|
<div>
|
||||||
|
<span className="text-[11px] text-slate-400 dark:text-zinc-500">Recommended </span>
|
||||||
|
<span className="text-[14px] font-bold tabular-nums text-slate-800 dark:text-zinc-100">
|
||||||
|
{fmtAmount(s.recommended_amount, cur)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{confidence != null && Number.isFinite(confidence) && (
|
||||||
|
<span className="text-[11px] text-slate-400 dark:text-zinc-500">
|
||||||
|
{Math.round(confidence * 100)}% confidence
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{str(s.ai_reasoning) && (
|
||||||
|
<p className="text-[12.5px] leading-relaxed text-slate-600 dark:text-zinc-300 mt-2.5">
|
||||||
|
{str(s.ai_reasoning)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 3. Match evidence ────────────────────────────────────────────── */}
|
||||||
|
<ThreeWayMatchCard
|
||||||
|
matchSummary={str(s.match_summary) ?? undefined}
|
||||||
|
analysis={str(s.analysis_so_far) ?? undefined}
|
||||||
|
invoice={{
|
||||||
|
invoice_number: s.invoice_number,
|
||||||
|
po_number: s.po_number,
|
||||||
|
total_amount: s.total_amount,
|
||||||
|
tax_amount: s.tax_amount,
|
||||||
|
currency: cur,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── 4. Clarification exchange ────────────────────────────────────── */}
|
||||||
|
{(question || answer) && (
|
||||||
|
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
|
||||||
|
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 flex items-center gap-2 mb-3">
|
||||||
|
<MessageSquare size={14} className="text-slate-400" />Clarification
|
||||||
|
{str(s.target_department) && (
|
||||||
|
<span className="font-normal text-slate-400 dark:text-zinc-500">
|
||||||
|
· {String(s.target_department).replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{question && (
|
||||||
|
<Bubble
|
||||||
|
who="Priya · AI Finance"
|
||||||
|
ai
|
||||||
|
text={question}
|
||||||
|
context={str(s.clarification_context)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{answer && (
|
||||||
|
<Bubble
|
||||||
|
who={str(s.target_department)?.replace(/_/g, " ") ?? "Department"}
|
||||||
|
text={answer}
|
||||||
|
context={str(s.response_notes)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{question && !answer && (
|
||||||
|
<div className="flex items-center gap-1.5 text-[12px] text-amber-700 dark:text-amber-400">
|
||||||
|
<HelpCircle size={12} />Awaiting a response.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 5. Source document ───────────────────────────────────────────── */}
|
||||||
|
<InvoiceDocumentCard file={d.invoicePdf} />
|
||||||
|
|
||||||
|
{/* ── 6. AI reasoning trail ────────────────────────────────────────── */}
|
||||||
|
<AIActivityPanel
|
||||||
|
decisions={d.decisions}
|
||||||
|
escalations={d.escalations}
|
||||||
|
traces={d.traces}
|
||||||
|
unavailable={d.aiUnavailable}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ── 7. History ───────────────────────────────────────────────────── */}
|
||||||
|
<AuditTimeline entries={d.audit} />
|
||||||
|
|
||||||
|
{/* ── 8. Everything else, collapsed ────────────────────────────────── */}
|
||||||
|
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAll((v) => !v)}
|
||||||
|
className="w-full px-5 py-3.5 flex items-center gap-2 text-[13px] font-semibold text-slate-700 dark:text-zinc-200 hover:bg-slate-50 dark:hover:bg-zinc-800/60 rounded-2xl transition-colors"
|
||||||
|
>
|
||||||
|
{showAll ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||||
|
<ListTree size={14} className="text-slate-400" />
|
||||||
|
All invoice fields
|
||||||
|
</button>
|
||||||
|
{showAll && (
|
||||||
|
<div className="px-5 pb-5 pt-1 border-t border-slate-100 dark:border-zinc-800">
|
||||||
|
<DetailViewPanel viewId={viewId} instanceId={instanceId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Meta({ label, value }: { label: string; value?: string | null }) {
|
||||||
|
if (!value) return null;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-slate-400 dark:text-zinc-600">{label}</div>
|
||||||
|
<div className="text-[12.5px] font-medium text-slate-700 dark:text-zinc-200">{value}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bubble({ who, text, context, ai }: { who: string; text: string; context?: string | null; ai?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-xl px-3.5 py-2.5 ${
|
||||||
|
ai
|
||||||
|
? "bg-violet-50/70 dark:bg-violet-950/25 border border-violet-100 dark:border-violet-900/40"
|
||||||
|
: "bg-slate-50 dark:bg-zinc-800/50 border border-slate-100 dark:border-zinc-700/60"
|
||||||
|
}`}>
|
||||||
|
<div className={`text-[10.5px] font-semibold uppercase tracking-wide mb-1 ${
|
||||||
|
ai ? "text-violet-600 dark:text-violet-400" : "text-slate-500 dark:text-zinc-400"
|
||||||
|
}`}>
|
||||||
|
{who}
|
||||||
|
</div>
|
||||||
|
<p className="text-[12.5px] leading-relaxed text-slate-700 dark:text-zinc-200 whitespace-pre-wrap">{text}</p>
|
||||||
|
{context && (
|
||||||
|
<p className="text-[11.5px] leading-relaxed text-slate-500 dark:text-zinc-400 mt-1.5 pt-1.5 border-t border-slate-200/70 dark:border-zinc-700/50">
|
||||||
|
{context}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
src/components/InvoiceDocumentCard.tsx
Normal file
147
src/components/InvoiceDocumentCard.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
// InvoiceDocumentCard — the source invoice PDF, viewable by whoever is looking
|
||||||
|
// at the record.
|
||||||
|
//
|
||||||
|
// Before this, `invoice_pdf` was write-only: the vendor uploaded it, OCR read
|
||||||
|
// it, and then it appeared in no record view and no detail view — so the
|
||||||
|
// department answering a clarification and the reviewer approving payment
|
||||||
|
// couldn't open the document they were reasoning about.
|
||||||
|
//
|
||||||
|
// The /download endpoint sets Content-Disposition: attachment, so a plain <a>
|
||||||
|
// would always download. Fetching a Blob and building an object URL instead
|
||||||
|
// lets the same bytes be shown inline AND opened/downloaded on demand.
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { FileText, Download, ExternalLink, AlertCircle, Loader2, Eye } from "lucide-react";
|
||||||
|
import { downloadFieldFile, type FileMeta } from "../api/viewService";
|
||||||
|
import { fmtBytes } from "../lib/workflowMeta";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
file: FileMeta | null;
|
||||||
|
/** Render the preview open on mount (reviewers want it; list-side callers may not). */
|
||||||
|
defaultOpen?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InvoiceDocumentCard({ file, defaultOpen = false }: Props) {
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
|
const urlRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
// Fetch once, on first expand. Object URLs must be revoked or the blob leaks
|
||||||
|
// for the lifetime of the document.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!file || !open || urlRef.current) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
downloadFieldFile(file.blob_path, file.original_name)
|
||||||
|
.then((blob) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const objUrl = URL.createObjectURL(blob);
|
||||||
|
urlRef.current = objUrl;
|
||||||
|
setUrl(objUrl);
|
||||||
|
})
|
||||||
|
.catch((e: any) => { if (!cancelled) setError(e?.message || "Could not load the document"); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [file, open]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (urlRef.current) URL.revokeObjectURL(urlRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
if (!url || !file) return;
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = file.original_name || "invoice.pdf";
|
||||||
|
a.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
return (
|
||||||
|
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2.5 text-[13px] text-slate-400 dark:text-zinc-500">
|
||||||
|
<FileText size={15} />
|
||||||
|
No invoice document was attached to this submission.
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isPdf = (file.mime_type || "").includes("pdf");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 overflow-hidden">
|
||||||
|
<div className="px-5 py-4 flex items-center gap-3">
|
||||||
|
<div className="w-9 h-9 shrink-0 rounded-xl bg-violet-50 dark:bg-violet-950/40 flex items-center justify-center">
|
||||||
|
<FileText size={16} className="text-violet-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 truncate">
|
||||||
|
{file.original_name || "Invoice document"}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-400 dark:text-zinc-500">
|
||||||
|
Source document · {fmtBytes(file.size_bytes)}
|
||||||
|
{isPdf ? " · PDF" : file.mime_type ? ` · ${file.mime_type}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="h-8 px-3 text-[12px] font-medium rounded-lg inline-flex items-center gap-1.5 text-slate-600 dark:text-zinc-300 bg-slate-50 dark:bg-zinc-800 hover:bg-slate-100 dark:hover:bg-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Eye size={13} />{open ? "Hide" : "View"}
|
||||||
|
</button>
|
||||||
|
{url && (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="h-8 px-3 text-[12px] font-medium rounded-lg inline-flex items-center gap-1.5 text-slate-600 dark:text-zinc-300 bg-slate-50 dark:bg-zinc-800 hover:bg-slate-100 dark:hover:bg-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<ExternalLink size={13} />Open
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={save}
|
||||||
|
className="h-8 px-3 text-[12px] font-medium rounded-lg inline-flex items-center gap-1.5 text-slate-600 dark:text-zinc-300 bg-slate-50 dark:bg-zinc-800 hover:bg-slate-100 dark:hover:bg-zinc-700 transition-colors"
|
||||||
|
>
|
||||||
|
<Download size={13} />Save
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="border-t border-slate-100 dark:border-zinc-800">
|
||||||
|
{loading && (
|
||||||
|
<div className="h-[420px] flex flex-col items-center justify-center gap-2 text-slate-400 dark:text-zinc-500">
|
||||||
|
<Loader2 size={18} className="animate-spin text-violet-500" />
|
||||||
|
<span className="text-[12px]">Loading document…</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="px-5 py-4 flex items-start gap-2 text-[12.5px] text-red-600 dark:text-red-400">
|
||||||
|
<AlertCircle size={14} className="shrink-0 mt-0.5" />{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{url && !loading && !error && (
|
||||||
|
isPdf ? (
|
||||||
|
<iframe
|
||||||
|
src={url}
|
||||||
|
title={file.original_name || "Invoice"}
|
||||||
|
className="w-full h-[560px] bg-slate-50 dark:bg-zinc-950"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<img src={url} alt={file.original_name || "Invoice"} className="w-full max-h-[560px] object-contain bg-slate-50 dark:bg-zinc-950" />
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
src/components/ThreeWayMatchCard.tsx
Normal file
154
src/components/ThreeWayMatchCard.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
// ThreeWayMatchCard — makes the AI's recommendation auditable.
|
||||||
|
//
|
||||||
|
// The employee reconciles invoice vs purchase order vs goods receipt vs payment
|
||||||
|
// history through four agentic tools, then writes its findings as prose into two
|
||||||
|
// fields. Both were previously invisible in the UI: `match_summary` was buried
|
||||||
|
// among ~29 flat fields, and `analysis_so_far` was explicitly dropped by
|
||||||
|
// DetailViewPanel's SKIP_KEYS because it looked like raw JSON.
|
||||||
|
//
|
||||||
|
// They're actually semi-structured, straight from the deployed data:
|
||||||
|
//
|
||||||
|
// match_summary "Price: MATCH (₹550/unit per contract amendment …).
|
||||||
|
// Quantity: MATCH (200 units invoiced = 200 units received
|
||||||
|
// per GR-3001). Goods Receipt: VALID (…). Duplicate Check:
|
||||||
|
// CLEAN (₹0 prior payments). Contract: ACTIVE with …"
|
||||||
|
//
|
||||||
|
// analysis_so_far "PO-3001: 200 units @ ₹500/unit = ₹100,000. GR-3001: 200
|
||||||
|
// units received, QC passed. Contract CNT-300: active,
|
||||||
|
// ₹500/unit, 2% tolerance. Invoice: 200 units @ ₹550/unit =
|
||||||
|
// ₹110,000. Payment history: clean (₹0 paid). Issue: 10%
|
||||||
|
// price variance exceeds 2% tolerance."
|
||||||
|
//
|
||||||
|
// So each parses into label → value pairs, with match_summary additionally
|
||||||
|
// carrying a verdict keyword the reviewer can scan in one pass.
|
||||||
|
|
||||||
|
import { CheckCircle2, XCircle, AlertTriangle, MinusCircle, Scale } from "lucide-react";
|
||||||
|
import { fmtAmount } from "../lib/workflowMeta";
|
||||||
|
import { parseMatchSummary, parseEvidence, overallTone, type Tone } from "../lib/matchSummary";
|
||||||
|
|
||||||
|
const TONE_STYLE: Record<Tone, { chip: string; icon: React.ReactNode }> = {
|
||||||
|
good: { chip: "text-emerald-700 bg-emerald-50 ring-1 ring-emerald-200 dark:text-emerald-300 dark:bg-emerald-950/40 dark:ring-emerald-800/60", icon: <CheckCircle2 size={12} /> },
|
||||||
|
bad: { chip: "text-red-700 bg-red-50 ring-1 ring-red-200 dark:text-red-300 dark:bg-red-950/40 dark:ring-red-800/60", icon: <XCircle size={12} /> },
|
||||||
|
warn: { chip: "text-amber-700 bg-amber-50 ring-1 ring-amber-200 dark:text-amber-300 dark:bg-amber-950/40 dark:ring-amber-800/60", icon: <AlertTriangle size={12} /> },
|
||||||
|
neutral: { chip: "text-slate-600 bg-slate-100 ring-1 ring-slate-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700", icon: <MinusCircle size={12} /> },
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
matchSummary?: string;
|
||||||
|
analysis?: string;
|
||||||
|
/** The invoice's own submitted figures, for the reference row. */
|
||||||
|
invoice?: {
|
||||||
|
invoice_number?: unknown;
|
||||||
|
po_number?: unknown;
|
||||||
|
total_amount?: unknown;
|
||||||
|
tax_amount?: unknown;
|
||||||
|
currency?: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ThreeWayMatchCard({ matchSummary, analysis, invoice }: Props) {
|
||||||
|
const verdicts = parseMatchSummary(matchSummary);
|
||||||
|
const evidence = parseEvidence(analysis);
|
||||||
|
|
||||||
|
if (verdicts.length === 0 && evidence.length === 0) {
|
||||||
|
return (
|
||||||
|
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
|
||||||
|
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 flex items-center gap-2 mb-1">
|
||||||
|
<Scale size={14} className="text-slate-400" />3-Way Match
|
||||||
|
</h3>
|
||||||
|
<p className="text-[12.5px] text-slate-400 dark:text-zinc-500">
|
||||||
|
The AI hasn't reconciled this invoice against its PO and goods receipt yet.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const worst = overallTone(verdicts);
|
||||||
|
const headline =
|
||||||
|
worst === "bad" ? "Discrepancy found" : worst === "warn" ? "Needs attention" : "All checks passed";
|
||||||
|
|
||||||
|
const cur = String(invoice?.currency || "INR");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-white dark:bg-zinc-900 rounded-2xl border border-slate-200/80 dark:border-zinc-700/60 px-5 py-4">
|
||||||
|
<div className="flex items-center justify-between gap-3 mb-3 flex-wrap">
|
||||||
|
<h3 className="text-[13px] font-semibold text-slate-800 dark:text-zinc-100 flex items-center gap-2">
|
||||||
|
<Scale size={14} className="text-slate-400" />3-Way Match
|
||||||
|
</h3>
|
||||||
|
{verdicts.length > 0 && (
|
||||||
|
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-semibold ${TONE_STYLE[worst].chip}`}>
|
||||||
|
{TONE_STYLE[worst].icon}{headline}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reference figures from the invoice itself */}
|
||||||
|
{invoice && (invoice.invoice_number || invoice.po_number || invoice.total_amount != null) && (
|
||||||
|
<div className="flex items-center gap-5 flex-wrap mb-3 pb-3 border-b border-slate-100 dark:border-zinc-800">
|
||||||
|
{invoice.invoice_number ? (
|
||||||
|
<Ref label="Invoice" value={String(invoice.invoice_number)} />
|
||||||
|
) : null}
|
||||||
|
{invoice.po_number ? <Ref label="Purchase order" value={String(invoice.po_number)} /> : null}
|
||||||
|
{invoice.total_amount != null ? (
|
||||||
|
<Ref label="Invoiced" value={fmtAmount(invoice.total_amount, cur)} strong />
|
||||||
|
) : null}
|
||||||
|
{invoice.tax_amount != null ? <Ref label="Tax" value={fmtAmount(invoice.tax_amount, cur)} /> : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Per-dimension verdicts */}
|
||||||
|
{verdicts.length > 0 && (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{verdicts.map((v, i) => (
|
||||||
|
<li key={`${v.label}-${i}`} className="flex items-start gap-2.5">
|
||||||
|
<span className={`shrink-0 inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md text-[10px] font-bold uppercase tracking-wide mt-0.5 ${TONE_STYLE[v.tone].chip}`}>
|
||||||
|
{TONE_STYLE[v.tone].icon}{v.verdict || "—"}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-[12.5px] font-medium text-slate-700 dark:text-zinc-200">{v.label}</div>
|
||||||
|
{v.detail && (
|
||||||
|
<div className="text-[12px] text-slate-500 dark:text-zinc-400 leading-relaxed">{v.detail}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Supporting evidence the AI gathered from PO / GR / contract / payments */}
|
||||||
|
{evidence.length > 0 && (
|
||||||
|
<div className={verdicts.length > 0 ? "mt-3 pt-3 border-t border-slate-100 dark:border-zinc-800" : ""}>
|
||||||
|
<div className="text-[10.5px] uppercase tracking-wide text-slate-400 dark:text-zinc-600 mb-2">
|
||||||
|
Evidence gathered
|
||||||
|
</div>
|
||||||
|
<dl className="grid sm:grid-cols-2 gap-x-6 gap-y-2">
|
||||||
|
{evidence.map((e, i) => (
|
||||||
|
<div
|
||||||
|
key={`${e.label}-${i}`}
|
||||||
|
className={e.isIssue ? "sm:col-span-2 rounded-lg bg-amber-50 dark:bg-amber-950/30 px-2.5 py-1.5" : ""}
|
||||||
|
>
|
||||||
|
<dt className={`text-[10.5px] uppercase tracking-wide ${e.isIssue ? "text-amber-700 dark:text-amber-400" : "text-slate-400 dark:text-zinc-600"}`}>
|
||||||
|
{e.label}
|
||||||
|
</dt>
|
||||||
|
<dd className={`text-[12.5px] ${e.isIssue ? "text-amber-800 dark:text-amber-200 font-medium" : "text-slate-700 dark:text-zinc-200"}`}>
|
||||||
|
{e.value}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Ref({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-slate-400 dark:text-zinc-600">{label}</div>
|
||||||
|
<div className={`${strong ? "text-[14px] font-bold" : "text-[12.5px] font-medium"} text-slate-800 dark:text-zinc-100 tabular-nums`}>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
src/hooks/useInstanceDetail.ts
Normal file
136
src/hooks/useInstanceDetail.ts
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
// useInstanceDetail — one fetch pass for everything the invoice detail page needs.
|
||||||
|
//
|
||||||
|
// The detail page shows four panels that all derive from the same three calls,
|
||||||
|
// so they're fetched once here and passed down as props rather than each panel
|
||||||
|
// doing its own round trip.
|
||||||
|
//
|
||||||
|
// /app/{id}/view/audit → the activity trail (timeline + submitted data)
|
||||||
|
// /monitor/instances/{id}/log → the AI employee's decisions + escalations
|
||||||
|
// /monitor/instances/{id}/trace→ per-run step traces
|
||||||
|
//
|
||||||
|
// The two /monitor calls are best-effort: an instance the employee never
|
||||||
|
// touched has no rows, and ai-employee-service is a separate deployment that
|
||||||
|
// can be down without the invoice page needing to break. Those failures are
|
||||||
|
// swallowed into empty results and surfaced as `aiUnavailable`.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
getAuditLog, getAIInstanceLog, getAIInstanceTrace,
|
||||||
|
type AuditEntry, type AIDecision, type AIEscalation, type AITrace, type FileMeta,
|
||||||
|
} from "../api/viewService";
|
||||||
|
import { ACTIVITY_IDS } from "../config";
|
||||||
|
|
||||||
|
export interface InstanceDetail {
|
||||||
|
audit: AuditEntry[];
|
||||||
|
decisions: AIDecision[];
|
||||||
|
escalations: AIEscalation[];
|
||||||
|
traces: AITrace[];
|
||||||
|
/** True when the AI monitoring service couldn't be reached. */
|
||||||
|
aiUnavailable: boolean;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
refresh: () => void;
|
||||||
|
|
||||||
|
// ── derived ───────────────────────────────────────────────────────────────
|
||||||
|
/** All submitted field values across every activity, later writes winning. */
|
||||||
|
submitted: Record<string, unknown>;
|
||||||
|
/** The invoice PDF from the Submit Invoice activity, if one was uploaded. */
|
||||||
|
invoicePdf: FileMeta | null;
|
||||||
|
/** Most recent decision that actually went through. */
|
||||||
|
latestDecision: AIDecision | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The audit can carry more than one row for the same activity — the trigger's
|
||||||
|
// performActivity node commits the submission, which lands alongside the
|
||||||
|
// original. Collapse on (activity_id, created_at to the second).
|
||||||
|
function dedupe(entries: AuditEntry[]): AuditEntry[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: AuditEntry[] = [];
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = `${e.activity_id}|${(e.created_at || "").slice(0, 19)}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(e);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
const asFileMeta = (v: unknown): FileMeta | null => {
|
||||||
|
const first = Array.isArray(v) ? v[0] : v;
|
||||||
|
if (first && typeof first === "object" && "blob_path" in (first as any)) return first as FileMeta;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useInstanceDetail(instanceId: string | null): InstanceDetail {
|
||||||
|
const [audit, setAudit] = useState<AuditEntry[]>([]);
|
||||||
|
const [decisions, setDecisions] = useState<AIDecision[]>([]);
|
||||||
|
const [escalations, setEscalations] = useState<AIEscalation[]>([]);
|
||||||
|
const [traces, setTraces] = useState<AITrace[]>([]);
|
||||||
|
const [aiUnavailable, setAiUnavailable] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [tick, setTick] = useState(0);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => setTick((t) => t + 1), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instanceId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// The audit is the only required call — the page is still useful without
|
||||||
|
// the AI panels, so those resolve to empty instead of rejecting.
|
||||||
|
Promise.all([
|
||||||
|
getAuditLog(instanceId),
|
||||||
|
getAIInstanceLog(instanceId).catch(() => null),
|
||||||
|
getAIInstanceTrace(instanceId).catch(() => null),
|
||||||
|
])
|
||||||
|
.then(([auditRes, logRes, traceRes]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const sorted = dedupe(
|
||||||
|
[...(auditRes ?? [])].sort(
|
||||||
|
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setAudit(sorted);
|
||||||
|
setDecisions(logRes?.decisions ?? []);
|
||||||
|
setEscalations(logRes?.escalations ?? []);
|
||||||
|
setTraces(traceRes?.traces ?? []);
|
||||||
|
setAiUnavailable(logRes === null && traceRes === null);
|
||||||
|
})
|
||||||
|
.catch((e: any) => { if (!cancelled) setError(e?.message || "Could not load this invoice"); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [instanceId, tick]);
|
||||||
|
|
||||||
|
const submitted = useMemo(() => {
|
||||||
|
const merged: Record<string, unknown> = {};
|
||||||
|
for (const e of audit) Object.assign(merged, e.data ?? {});
|
||||||
|
return merged;
|
||||||
|
}, [audit]);
|
||||||
|
|
||||||
|
const invoicePdf = useMemo(() => {
|
||||||
|
// Prefer the Submit Invoice activity explicitly; fall back to any entry
|
||||||
|
// carrying the field, so a re-submission still resolves.
|
||||||
|
const init = audit.find((e) => e.activity_id === ACTIVITY_IDS.SUBMIT_INVOICE);
|
||||||
|
return asFileMeta(init?.data?.invoice_pdf) ?? asFileMeta(submitted.invoice_pdf);
|
||||||
|
}, [audit, submitted]);
|
||||||
|
|
||||||
|
const latestDecision = useMemo(() => {
|
||||||
|
const ok = decisions.filter((d) => d.status === "submitted");
|
||||||
|
const pool = ok.length ? ok : decisions;
|
||||||
|
return (
|
||||||
|
[...pool].sort(
|
||||||
|
(a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime(),
|
||||||
|
)[0] ?? null
|
||||||
|
);
|
||||||
|
}, [decisions]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
audit, decisions, escalations, traces, aiUnavailable,
|
||||||
|
loading, error, refresh,
|
||||||
|
submitted, invoicePdf, latestDecision,
|
||||||
|
};
|
||||||
|
}
|
||||||
82
src/lib/matchSummary.ts
Normal file
82
src/lib/matchSummary.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
// Parsers for the AI employee's two free-text reconciliation fields.
|
||||||
|
//
|
||||||
|
// Kept separate from the card that renders them so the logic is testable
|
||||||
|
// without React. Both formats come from the deployed prompt/knowledge, not from
|
||||||
|
// a schema, so these parsers are deliberately forgiving: an unrecognised chunk
|
||||||
|
// is dropped rather than throwing, and a missing verdict keyword degrades to a
|
||||||
|
// neutral chip instead of losing the row.
|
||||||
|
|
||||||
|
export type Tone = "good" | "bad" | "warn" | "neutral";
|
||||||
|
|
||||||
|
export const VERDICT_TONE: Record<string, Tone> = {
|
||||||
|
MATCH: "good", VALID: "good", CLEAN: "good", ACTIVE: "good",
|
||||||
|
PASS: "good", PASSED: "good", OK: "good", EXISTS: "good", APPROVED: "good",
|
||||||
|
MISMATCH: "bad", INVALID: "bad", FAIL: "bad", FAILED: "bad",
|
||||||
|
DUPLICATE: "bad", EXPIRED: "bad", EXCEEDED: "bad", VIOLATION: "bad",
|
||||||
|
PARTIAL: "warn", MISSING: "warn", PENDING: "warn",
|
||||||
|
UNKNOWN: "warn", UNVERIFIED: "warn",
|
||||||
|
// "Payments: NONE" means no prior payment was found — the good outcome for a
|
||||||
|
// duplicate check, so it must not read as a warning.
|
||||||
|
NONE: "neutral", "N/A": "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits "Label: rest. Label: rest." into pairs.
|
||||||
|
*
|
||||||
|
* A sentence boundary only separates records when what follows looks like a new
|
||||||
|
* "Label:" — otherwise a value containing a full stop (a date, "2% tolerance.")
|
||||||
|
* would be torn in half.
|
||||||
|
*
|
||||||
|
* The label pattern has to allow digits and 2-character labels: real values
|
||||||
|
* include "PO-3001:", "GR-3001:", "Contract CNT-300:" and "GR:". An earlier
|
||||||
|
* letters-only, 3-char-minimum pattern silently merged the whole evidence
|
||||||
|
* string into one row and dropped "GR:" entirely.
|
||||||
|
*/
|
||||||
|
export function splitLabelled(text: string): Array<{ label: string; rest: string }> {
|
||||||
|
const out: Array<{ label: string; rest: string }> = [];
|
||||||
|
const chunks = text
|
||||||
|
.split(/(?<=\.)\s+(?=[A-Z][A-Za-z0-9][A-Za-z0-9 \-/]{0,28}:)/)
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
for (const c of chunks) {
|
||||||
|
const m = c.match(/^([^:]{2,30}):\s*([\s\S]*)$/);
|
||||||
|
if (!m) continue;
|
||||||
|
out.push({ label: m[1].trim(), rest: m[2].trim().replace(/\.$/, "") });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Verdict { label: string; verdict: string; detail: string; tone: Tone }
|
||||||
|
|
||||||
|
/** "Price: MATCH (₹550/unit per …)" → {label:"Price", verdict:"MATCH", detail:"₹550/unit per …"} */
|
||||||
|
export function parseMatchSummary(text?: string): Verdict[] {
|
||||||
|
if (!text?.trim()) return [];
|
||||||
|
return splitLabelled(text).map(({ label, rest }) => {
|
||||||
|
// A leading ALL-CAPS token is the verdict; the remainder explains it.
|
||||||
|
const m = rest.match(/^([A-Z][A-Z/_]{1,14})\b[\s:.\-]*([\s\S]*)$/);
|
||||||
|
const verdict = m ? m[1] : "";
|
||||||
|
let detail = (m ? m[2] : rest).trim();
|
||||||
|
// Most details arrive fully parenthesised — unwrap for cleaner rendering.
|
||||||
|
detail = detail.replace(/^\(([\s\S]*)\)$/, "$1").trim();
|
||||||
|
return { label, verdict, detail, tone: VERDICT_TONE[verdict] ?? "neutral" };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EvidenceRow { label: string; value: string; isIssue: boolean }
|
||||||
|
|
||||||
|
/** "PO-3001: 200 units @ ₹500/unit = ₹100,000." → one row per entity. */
|
||||||
|
export function parseEvidence(text?: string): EvidenceRow[] {
|
||||||
|
if (!text?.trim()) return [];
|
||||||
|
return splitLabelled(text).map(({ label, rest }) => ({
|
||||||
|
label,
|
||||||
|
value: rest,
|
||||||
|
isIssue: /^(issue|issues|concern|problem|discrepanc)/i.test(label),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Worst verdict across the set — drives the card's headline. */
|
||||||
|
export function overallTone(verdicts: Verdict[]): Tone {
|
||||||
|
if (verdicts.some((v) => v.tone === "bad")) return "bad";
|
||||||
|
if (verdicts.some((v) => v.tone === "warn")) return "warn";
|
||||||
|
return "good";
|
||||||
|
}
|
||||||
157
src/lib/workflowMeta.ts
Normal file
157
src/lib/workflowMeta.ts
Normal file
@ -0,0 +1,157 @@
|
|||||||
|
// Display metadata for the P2P workflow.
|
||||||
|
//
|
||||||
|
// Everything here keys off UUIDs (ACTIVITY_IDS / STATE_IDS from config.ts)
|
||||||
|
// rather than off the human-readable names the API returns. That matters: the
|
||||||
|
// deployed state is named "Financial Review", and several older call sites
|
||||||
|
// match on the string "Finance Review" and silently fall through to a neutral
|
||||||
|
// style. Keying on the uuid can't drift.
|
||||||
|
|
||||||
|
import { ACTIVITY_IDS, STATE_IDS } from "../config";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Activities
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const ACTIVITY_LABELS: Record<string, string> = {
|
||||||
|
[ACTIVITY_IDS.SUBMIT_INVOICE]: "Invoice submitted",
|
||||||
|
[ACTIVITY_IDS.REQUEST_CLARIFICATION]: "Clarification requested",
|
||||||
|
[ACTIVITY_IDS.PROVIDE_CLARIFICATION]: "Clarification provided",
|
||||||
|
[ACTIVITY_IDS.SUBMIT_RECOMMENDATION]: "Recommendation submitted",
|
||||||
|
[ACTIVITY_IDS.APPROVE_PAYMENT]: "Payment approved",
|
||||||
|
[ACTIVITY_IDS.REJECT_PAYMENT]: "Payment rejected",
|
||||||
|
[ACTIVITY_IDS.HOLD_PAYMENT]: "Payment held",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const activityLabel = (uid: string): string =>
|
||||||
|
ACTIVITY_LABELS[uid] ?? "Activity";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// States
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type StateTone = "sky" | "violet" | "amber" | "indigo" | "emerald" | "red" | "slate";
|
||||||
|
|
||||||
|
interface StateMeta { label: string; tone: StateTone; terminal?: boolean }
|
||||||
|
|
||||||
|
// Labels match the deployed workflow exactly (note "Financial Review").
|
||||||
|
export const STATE_META: Record<string, StateMeta> = {
|
||||||
|
[STATE_IDS.INVOICE_SUBMITTED]: { label: "Invoice Submitted", tone: "sky" },
|
||||||
|
[STATE_IDS.AI_ANALYSIS]: { label: "AI Analysis", tone: "violet" },
|
||||||
|
[STATE_IDS.AWAITING_CLARIFICATION]: { label: "Awaiting Clarification", tone: "amber" },
|
||||||
|
[STATE_IDS.FINANCE_REVIEW]: { label: "Financial Review", tone: "indigo" },
|
||||||
|
[STATE_IDS.APPROVED]: { label: "Approved", tone: "emerald", terminal: true },
|
||||||
|
[STATE_IDS.REJECTED]: { label: "Rejected", tone: "red", terminal: true },
|
||||||
|
[STATE_IDS.ON_HOLD]: { label: "On Hold", tone: "slate", terminal: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stateMeta = (uid?: string | null): StateMeta =>
|
||||||
|
(uid && STATE_META[uid]) || { label: "—", tone: "slate" };
|
||||||
|
|
||||||
|
/** Tailwind classes per tone, for pills and dots. Light + dark. */
|
||||||
|
export const TONE_PILL: Record<StateTone, string> = {
|
||||||
|
sky: "text-sky-700 bg-sky-50 ring-1 ring-sky-200 dark:text-sky-300 dark:bg-sky-950/50 dark:ring-sky-800/60",
|
||||||
|
violet: "text-violet-700 bg-violet-50 ring-1 ring-violet-200 dark:text-violet-300 dark:bg-violet-950/50 dark:ring-violet-800/60",
|
||||||
|
amber: "text-amber-700 bg-amber-50 ring-1 ring-amber-200 dark:text-amber-300 dark:bg-amber-950/50 dark:ring-amber-800/60",
|
||||||
|
indigo: "text-indigo-700 bg-indigo-50 ring-1 ring-indigo-200 dark:text-indigo-300 dark:bg-indigo-950/50 dark:ring-indigo-800/60",
|
||||||
|
emerald: "text-emerald-700 bg-emerald-50 ring-1 ring-emerald-200 dark:text-emerald-300 dark:bg-emerald-950/50 dark:ring-emerald-800/60",
|
||||||
|
red: "text-red-700 bg-red-50 ring-1 ring-red-200 dark:text-red-300 dark:bg-red-950/50 dark:ring-red-800/60",
|
||||||
|
slate: "text-slate-600 bg-slate-100 ring-1 ring-slate-200 dark:text-zinc-400 dark:bg-zinc-800 dark:ring-zinc-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TONE_DOT: Record<StateTone, string> = {
|
||||||
|
sky: "bg-sky-500", violet: "bg-violet-500", amber: "bg-amber-500",
|
||||||
|
indigo: "bg-indigo-500", emerald: "bg-emerald-500", red: "bg-red-500", slate: "bg-slate-400",
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Actors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// The audit records user_id + roles, not names. Roles are stable and readable,
|
||||||
|
// so derive the actor from the role rather than hardcoding a user-id table.
|
||||||
|
const ROLE_ACTORS: Record<string, { name: string; ai?: boolean }> = {
|
||||||
|
p2p_ai_finance: { name: "Priya · AI Finance", ai: true },
|
||||||
|
p2p_vendor: { name: "Vendor" },
|
||||||
|
p2p_warehouse: { name: "Warehouse" },
|
||||||
|
p2p_procurement: { name: "Procurement" },
|
||||||
|
p2p_ap_team: { name: "Accounts Payable" },
|
||||||
|
p2p_finance_reviewer: { name: "Finance Reviewer" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Actor { name: string; isAI: boolean }
|
||||||
|
|
||||||
|
export function resolveActor(roles?: string[] | null, userId?: string): Actor {
|
||||||
|
const list = Array.isArray(roles) ? roles : [];
|
||||||
|
// A single-role actor is unambiguous. Multi-role users (e.g. the admin test
|
||||||
|
// account holds all six) can't be pinned down, so fall back to the user id.
|
||||||
|
if (list.length === 1) {
|
||||||
|
const m = ROLE_ACTORS[list[0]];
|
||||||
|
if (m) return { name: m.name, isAI: !!m.ai };
|
||||||
|
}
|
||||||
|
const ai = list.includes("p2p_ai_finance") && list.length === 1;
|
||||||
|
return { name: userId ? `User ${userId}` : "Unknown", isAI: ai };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Formatting
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function fmtDateTime(iso?: string): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return String(iso);
|
||||||
|
return d.toLocaleString(undefined, {
|
||||||
|
day: "2-digit", month: "short", year: "numeric",
|
||||||
|
hour: "2-digit", minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtRelative(iso?: string): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
const t = new Date(iso).getTime();
|
||||||
|
if (Number.isNaN(t)) return "";
|
||||||
|
const secs = Math.round((Date.now() - t) / 1000);
|
||||||
|
if (secs < 60) return "just now";
|
||||||
|
const mins = Math.round(secs / 60);
|
||||||
|
if (mins < 60) return `${mins}m ago`;
|
||||||
|
const hrs = Math.round(mins / 60);
|
||||||
|
if (hrs < 24) return `${hrs}h ago`;
|
||||||
|
const days = Math.round(hrs / 24);
|
||||||
|
if (days < 30) return `${days}d ago`;
|
||||||
|
return fmtDateTime(iso);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Elapsed time between two ISO stamps, for timeline gaps. */
|
||||||
|
export function fmtGap(fromIso: string, toIso: string): string {
|
||||||
|
const a = new Date(fromIso).getTime(), b = new Date(toIso).getTime();
|
||||||
|
if (Number.isNaN(a) || Number.isNaN(b)) return "";
|
||||||
|
const secs = Math.round((b - a) / 1000);
|
||||||
|
if (secs < 1) return "";
|
||||||
|
if (secs < 60) return `${secs}s`;
|
||||||
|
const mins = Math.round(secs / 60);
|
||||||
|
if (mins < 60) return `${mins}m`;
|
||||||
|
const hrs = Math.round(mins / 60);
|
||||||
|
if (hrs < 48) return `${hrs}h`;
|
||||||
|
return `${Math.round(hrs / 24)}d`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDuration(ms?: number): string {
|
||||||
|
if (ms == null) return "—";
|
||||||
|
if (ms < 1000) return `${ms}ms`;
|
||||||
|
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
return `${Math.floor(ms / 60_000)}m ${Math.round((ms % 60_000) / 1000)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Amounts in this app are INR. Keep the code, not the glyph — matches the PDFs. */
|
||||||
|
export function fmtAmount(v: unknown, currency = "INR"): string {
|
||||||
|
const n = typeof v === "number" ? v : Number(String(v ?? "").replace(/[^0-9.\-]/g, ""));
|
||||||
|
if (!Number.isFinite(n)) return "—";
|
||||||
|
return `${currency} ${n.toLocaleString("en-IN", { maximumFractionDigits: 2 })}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtBytes(n?: number): string {
|
||||||
|
if (!n) return "";
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user