diff --git a/src/components/AIActivityPanel.tsx b/src/components/AIActivityPanel.tsx deleted file mode 100644 index c662bd4..0000000 --- a/src/components/AIActivityPanel.tsx +++ /dev/null @@ -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 = { - 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 = { - 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 {pct}%; -} - -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(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 ( -
-
- {trace.events.length} steps - {fmtDuration(span)} - {totals.tokens > 0 && ( - {totals.tokens.toLocaleString()} tokens - )} - {fmtCost(totals.cost) && ( - {fmtCost(totals.cost)} - )} -
- -
    - {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 ( -
  1. - - - {isOpen && ( -
    - {(ev.tokens_in || ev.tokens_out) && ( -

    - {(ev.tokens_in ?? 0).toLocaleString()} in · {(ev.tokens_out ?? 0).toLocaleString()} out - {fmtCost(ev.cost) ? ` · ${fmtCost(ev.cost)}` : ""} -

    - )} - {ev.error && ( -

    {ev.error}

    - )} - {ev.input != null && ( - - )} - {ev.output != null && ( - - )} -
    - )} -
  2. - ); - })} -
-
- ); -} - -function Snippet({ label, text }: { label: string; text: string }) { - if (!text.trim()) return null; - return ( -
-
{label}
-
-        {text}
-      
-
- ); -} - -// --------------------------------------------------------------------------- -// Decision card -// --------------------------------------------------------------------------- - -function DecisionCard({ d, trace }: { d: AIDecision; trace?: AITrace }) { - const [open, setOpen] = useState(false); - const escalated = d.reflexion?.should_escalate; - - return ( -
-
-
-
- - {activityLabel(d.activity_id)} - - - {d.status} - - -
-
- {d.instance_state ? `saw "${d.instance_state}"` : null} - {d.model ? ` · ${d.model}` : ""} -
-
- - {fmtRelative(d.created_at)} - -
- - {d.reasoning && ( -

- {d.reasoning} -

- )} - - {d.error_message && ( -
- - {d.error_message} -
- )} - - {escalated && ( -
- Flagged for human escalation -
- )} - - - - {open && ( -
- {d.plan && } - {d.reflexion?.concerns ? : null} - {d.reflexion?.adjusted_confidence != null && ( -

- Adjusted confidence after self-check: {Math.round(d.reflexion.adjusted_confidence * 100)}% -

- )} - {trace && } -
- )} -
- ); -} - -// --------------------------------------------------------------------------- -// 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(); - 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 ( -
-
-

- - AI Review - · Priya · AI Finance -

-
- {ordered.length > 0 && {ordered.length} {ordered.length === 1 ? "pass" : "passes"}} - {totals.tokens > 0 && {totals.tokens.toLocaleString()} tokens} - {fmtCost(totals.cost) && {fmtCost(totals.cost)}} -
-
- - {unavailable ? ( -

- AI activity is unavailable right now — the monitoring service couldn't be reached. -

- ) : ordered.length === 0 ? ( -

- No AI review recorded for this invoice yet. -

- ) : ( -
- {ordered.map((d) => ( - - ))} -
- )} - - {escalations.length > 0 && ( -
-

- Escalations -

-
- {escalations.map((e) => ( -
- {e.recommended_action || "Escalated"} - {e.reason ? <> — {e.reason} : null} - · {fmtRelative(e.created_at)} -
- ))} -
-
- )} -
- ); -} diff --git a/src/components/AiActivity.tsx b/src/components/AiActivity.tsx new file mode 100644 index 0000000..f5e9c5e --- /dev/null +++ b/src/components/AiActivity.tsx @@ -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 = { + 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(); + 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 ( +

+ The AI monitoring service isn’t reachable, so decisions and traces can’t be shown. +

+ ); + } + if (ordered.length === 0 && escalations.length === 0) { + return ( +

+ The AI employee hasn’t acted on this invoice. +

+ ); + } + + return ( +
+ {(totals.tokens > 0 || totals.cost > 0) && ( +
+ {totals.tokens > 0 && ( + + + {totals.tokens.toLocaleString("en-IN")} tokens + + )} + {totals.cost > 0 && ( + + ${totals.cost.toFixed(4)} + + )} +
+ )} + +
    + {ordered.map((d) => ( + + ))} +
+ + {escalations.length > 0 && ( +
+ {escalations.map((e) => ( +
+ +
+

+ Escalated to a human + {e.recommended_action && ` · ${e.recommended_action}`} +

+ {(e.reasoning || e.reason) && ( +

{e.reasoning || e.reason}

+ )} +
+
+ ))} +
+ )} +
+ ); +} + +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 ( +
  • +
    + + + {activityLabel(d.activity_id)} + + + {d.status} + + {pct != null && ( + + {Math.round(pct * 100)}% confidence + + )} +
    + +

    + {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`} +

    + + {d.error_message && ( +

    {d.error_message}

    + )} + + {(d.plan || d.reasoning || d.reflexion?.concerns || trace) && ( + <> + + + {open && ( +
    + {d.plan && } + {d.reasoning && } + {d.reflexion?.concerns && ( + + )} + {d.knowledge_sources && d.knowledge_sources.length > 0 && ( + + )} + {trace && } +
    + )} + + )} +
  • + ); +} + +function Passage({ label, text, tone }: { label: string; text: string; tone?: "warn" }) { + return ( +
    +

    + {label} +

    +

    + {text.replace(/[ \t]{2,}/g, " ")} +

    +
    + ); +} + +/** 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 ( +
    +

    + Run trace +

    +
      + {events.map((e) => ( +
    • + + {e.name || e.type} + + + + + + {fmtDuration(e.duration_ms)} + +
    • + ))} +
    +
    + ); +} diff --git a/src/components/AuditTimeline.tsx b/src/components/AuditTimeline.tsx deleted file mode 100644 index fcc5bf2..0000000 --- a/src/components/AuditTimeline.tsx +++ /dev/null @@ -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 = { - [ACTIVITY_IDS.SUBMIT_INVOICE]: , - [ACTIVITY_IDS.REQUEST_CLARIFICATION]: , - [ACTIVITY_IDS.PROVIDE_CLARIFICATION]: , - [ACTIVITY_IDS.SUBMIT_RECOMMENDATION]: , - [ACTIVITY_IDS.APPROVE_PAYMENT]: , - [ACTIVITY_IDS.REJECT_PAYMENT]: , - [ACTIVITY_IDS.HOLD_PAYMENT]: , -}; - -// 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; - 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 }) { - 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 ( -
    - {rows.map(([k, v]) => ( -
    -
    - {k.replace(/_/g, " ")} -
    -
    {String(v)}
    -
    - ))} -
    - ); -} - -export default function AuditTimeline({ entries }: { entries: AuditEntry[] }) { - const [expanded, setExpanded] = useState>(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 ( -
    -

    History

    -

    No activity recorded yet.

    -
    - ); - } - - return ( -
    -
    -

    - History -

    - - {entries.length} {entries.length === 1 ? "step" : "steps"} - -
    - -
      - {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 ( -
    1. - {/* Rail */} - {!isLast && ( - - )} - {/* Node */} - - {ACTIVITY_ICON[e.activity_id] ?? } - - -
      -
      -
      - - {activityLabel(e.activity_id)} - - - {actor.isAI ? : }{actor.name} - -
      - - {headline && ( -

      - {headline} -

      - )} - -
      - - - {st.label} - - -
      - - {isOpen && } />} -
      - -
      -
      - {fmtRelative(e.created_at)} -
      - {gap && ( -
      - +{gap} -
      - )} -
      -
      -
    2. - ); - })} -
    -
    - ); -} diff --git a/src/components/AuditTrail.tsx b/src/components/AuditTrail.tsx new file mode 100644 index 0000000..7e8c552 --- /dev/null +++ b/src/components/AuditTrail.tsx @@ -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 = { + [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; + 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

    No activity recorded yet.

    ; + } + + return ( +
      + {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 ( +
    1. + + + + +
      +
      + + {activityLabel(e.activity_id)} + + + {actor.isAI && } + {!actor.isAI && } + {actor.name} + + {state && } +
      + + {summary && ( +

      {summary}

      + )} + +

      + {formatDate(e.created_at)} + {time && ` · ${time}`} + {/* How long this step waited on the one before it. */} + {gap && ` · +${gap}`} +

      +
      +
    2. + ); + })} +
    + ); +} diff --git a/src/components/DetailViewPanel.tsx b/src/components/DetailViewPanel.tsx index fb9f0e4..a91feeb 100644 --- a/src/components/DetailViewPanel.tsx +++ b/src/components/DetailViewPanel.tsx @@ -8,7 +8,9 @@ import { ChevronDown, ChevronUp, CircleSlash, + FileText, Gavel, + History, Landmark, MessageSquare, Minus, @@ -20,12 +22,24 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { Separator } from "@/components/ui/separator"; +import { Skeleton } from "@/components/ui/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getDVScreen, getDetailView } from "@/api/viewService"; +import { useInstanceDetail } from "@/hooks/useInstanceDetail"; import { DetailSkeleton } from "./Skeleton"; import StatusBadge from "./StatusBadge"; import WorkflowStepper from "./WorkflowStepper"; +import SourceDocument from "./SourceDocument"; +import AuditTrail from "./AuditTrail"; +import AiActivity from "./AiActivity"; 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"; interface Field { @@ -140,43 +154,11 @@ const BAND_STYLE = { }, } as const; -/** - * `match_summary` arrives as prose in a fixed shape: - * "Price: MATCH (₹550/unit …). Quantity: MISMATCH (0 received vs 300 billed). …" - * Splitting it into a checklist turns the 3-way match result into something - * scannable, which is the whole point of the field. - */ -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 }]; - }); -} +// `match_summary` and `analysis_so_far` parsing lives in lib/matchSummary.ts. +// That parser handles cases mine did not: entity labels with digits and +// hyphens ("PO-3001:", "GR:"), a `warn` tone for PARTIAL/MISSING/PENDING, and +// "Payments: NONE" as neutral rather than a warning — NONE is the *good* +// outcome for a duplicate check. // --------------------------------------------------------------------------- @@ -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(null); const fields: Field[] = data?.fields ?? []; @@ -320,6 +306,38 @@ export default function DetailViewPanel({ viewId, instanceId, onTitle }: Props) {decisionFields.length > 0 && ( )} + + {/* The document the whole record is about — write-only until now. */} + {(instance.invoicePdf || instance.loading) && ( + + {instance.loading && !instance.invoicePdf ? ( + + ) : ( + + )} + + )} + + + {instance.loading ? ( + + ) : ( + + )} + + + + {instance.loading ? ( + + ) : ( + + )} + {/* ══ Rail ═════════════════════════════════════════════════════════ */} @@ -374,13 +392,16 @@ function AiAssessment({ }) { const verdict = VERDICT[text(row, "recommendation").toLowerCase()]; const pct = has(row, "ai_confidence") ? toPercent(row.ai_confidence) : null; - const checks = has(row, "match_summary") ? parseMatchSummary(text(row, "match_summary")) : []; - const failed = checks.filter((c) => c.tone === "bad").length; + const checks = parseMatchSummary(text(row, "match_summary")); + 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 // reviewer must not skim past. - const ordered = [...checks].sort( - (a, b) => (a.tone === "bad" ? 0 : 1) - (b.tone === "bad" ? 0 : 1), - ); + const rank = (t: Tone) => (t === "bad" ? 0 : t === "warn" ? 1 : 2); + 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; return ( @@ -429,7 +450,14 @@ function AiAssessment({ {/* Headline count first — the reason to read the list at all is to find out whether anything failed. */} {failed > 0 ? ( - + {failed} {failed === 1 ? "issue" : "issues"} ) : ( @@ -454,17 +482,40 @@ function AiAssessment({ /> )} - {/* Prose, not a JSON blob — it was hidden outright before. Collapsed by - default because it restates the reasoning in shorthand. */} - {has(row, "analysis_so_far") && ( -
    + {/* The gathered evidence — PO, goods receipt, contract, payment history + and the specific discrepancy. Parsed into rows so the verdict above is + auditable instead of a wall of prose. */} + {evidence.length > 0 && ( +
    - - {byKey.get("analysis_so_far")?.output_label ?? "Analysis So Far"} + + Evidence gathered -

    - {text(row, "analysis_so_far")} -

    +
    + {evidence.map((e) => ( +
    +
    + {e.label} +
    +
    + {e.value} +
    +
    + ))} +
    )} @@ -718,17 +769,26 @@ function FieldRow({ field, value }: { field: Field; value: unknown }) { ); } -const MATCH_TONE = { - good: { text: "text-state-approved", disc: "bg-state-approved/12 text-state-approved", icon: Check }, +const MATCH_TONE: Record = { + 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 }, + 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 }, -} as const; +}; /** * One 3-way-match check. Two lines: verdict on the first so the column of * 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]; return (
  • diff --git a/src/components/InvoiceDetail.tsx b/src/components/InvoiceDetail.tsx deleted file mode 100644 index eced84d..0000000 --- a/src/components/InvoiceDetail.tsx +++ /dev/null @@ -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 = { - 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: , 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: , 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: , 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: , 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 ( -
    - - Loading invoice… -
    - ); - } - - if (d.error) { - return ( -
    - {d.error} -
    - ); - } - - 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 ( -
    - {/* ── 1. Identity + status ─────────────────────────────────────────── */} -
    -
    -
    -
    -

    - {str(s.invoice_number) ?? `Invoice #${instanceId}`} -

    - - {st.label} - -
    -

    - {str(s.vendor_name) ?? "Unknown vendor"} - {str(s.vendor_id) ? ` · ${str(s.vendor_id)}` : ""} -

    -
    -
    -
    - {fmtAmount(s.total_amount, cur)} -
    - {s.tax_amount != null && ( -
    - incl. tax {fmtAmount(s.tax_amount, cur)} -
    - )} -
    -
    - -
    - - - - - -
    - - {str(s.notes) && ( -

    - Vendor notes: {str(s.notes)} -

    - )} -
    - - {/* ── 2. AI recommendation ─────────────────────────────────────────── */} - {rec && ( -
    -
    - -

    AI recommendation

    -
    -
    - - {rec.icon}{rec.label} - - {s.recommended_amount != null && ( -
    - Recommended - - {fmtAmount(s.recommended_amount, cur)} - -
    - )} - {confidence != null && Number.isFinite(confidence) && ( - - {Math.round(confidence * 100)}% confidence - - )} -
    - {str(s.ai_reasoning) && ( -

    - {str(s.ai_reasoning)} -

    - )} -
    - )} - - {/* ── 3. Match evidence ────────────────────────────────────────────── */} - - - {/* ── 4. Clarification exchange ────────────────────────────────────── */} - {(question || answer) && ( -
    -

    - Clarification - {str(s.target_department) && ( - - · {String(s.target_department).replace(/_/g, " ")} - - )} -

    -
    - {question && ( - - )} - {answer && ( - - )} - {question && !answer && ( -
    - Awaiting a response. -
    - )} -
    -
    - )} - - {/* ── 5. Source document ───────────────────────────────────────────── */} - - - {/* ── 6. AI reasoning trail ────────────────────────────────────────── */} - - - {/* ── 7. History ───────────────────────────────────────────────────── */} - - - {/* ── 8. Everything else, collapsed ────────────────────────────────── */} -
    - - {showAll && ( -
    - -
    - )} -
    -
    - ); -} - -function Meta({ label, value }: { label: string; value?: string | null }) { - if (!value) return null; - return ( -
    -
    {label}
    -
    {value}
    -
    - ); -} - -function Bubble({ who, text, context, ai }: { who: string; text: string; context?: string | null; ai?: boolean }) { - return ( -
    -
    - {who} -
    -

    {text}

    - {context && ( -

    - {context} -

    - )} -
    - ); -} diff --git a/src/components/InvoiceDocumentCard.tsx b/src/components/InvoiceDocumentCard.tsx deleted file mode 100644 index 997bdc5..0000000 --- a/src/components/InvoiceDocumentCard.tsx +++ /dev/null @@ -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 -// 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(null); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [open, setOpen] = useState(defaultOpen); - const urlRef = useRef(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 ( -
    -
    - - No invoice document was attached to this submission. -
    -
    - ); - } - - const isPdf = (file.mime_type || "").includes("pdf"); - - return ( -
    - - - {open && ( -
    - {loading && ( -
    - - Loading document… -
    - )} - {error && ( -
    - {error} -
    - )} - {url && !loading && !error && ( - isPdf ? ( -