diff --git a/apps/apollo-vertex/.env.example b/apps/apollo-vertex/.env.example new file mode 100644 index 000000000..4babfd006 --- /dev/null +++ b/apps/apollo-vertex/.env.example @@ -0,0 +1,20 @@ +# Slack integration for the Invoice Processing demo (Socket Mode). +# Copy this file to `.env` and fill in the real values from the "Invoice Agent" +# Slack app in the "VS Demos" workspace. NEVER commit `.env` (it is gitignored). + +# Bot User OAuth Token — starts with xoxb- +SLACK_BOT_TOKEN=xoxb-your-token-here + +# App-Level Token (Socket Mode, scope connections:write) — starts with xapp- +SLACK_APP_TOKEN=xapp-your-token-here + +# Signing Secret — 32-char hex. Not strictly required for Socket Mode, +# included for completeness. +SLACK_SIGNING_SECRET=your-32-char-hex-signing-secret + +# Channel ID for #ap-exceptions — starts with C +SLACK_CHANNEL_ID=C0XXXXXXXXX + +# Port for the Slack listener's small local HTTP endpoint (escalation trigger). +# Kept off 3000 so it never collides with the Next.js dev server. +LISTENER_PORT=3010 diff --git a/apps/apollo-vertex/.gitignore b/apps/apollo-vertex/.gitignore index eaa371f01..a10450123 100644 --- a/apps/apollo-vertex/.gitignore +++ b/apps/apollo-vertex/.gitignore @@ -3,3 +3,10 @@ public/r/ app/theme.generated.css .vercel .env*.local + +# Slack demo runtime state (regenerated by the listener / reset script) +data/demo-state.json + +# Isolated Slack listener deps (installed with npm, not pnpm) +slack/node_modules +slack/package-lock.json diff --git a/apps/apollo-vertex/app/_meta.ts b/apps/apollo-vertex/app/_meta.ts index 152665709..96ec0679f 100644 --- a/apps/apollo-vertex/app/_meta.ts +++ b/apps/apollo-vertex/app/_meta.ts @@ -9,6 +9,9 @@ export default { "data-querying": "Data Querying", localization: "Localization", mcp: "MCP Server", + "invoice-review": { + display: "hidden", + }, auth_callback: { display: "hidden", }, diff --git a/apps/apollo-vertex/app/api/demo-reply/route.ts b/apps/apollo-vertex/app/api/demo-reply/route.ts new file mode 100644 index 000000000..368a8143c --- /dev/null +++ b/apps/apollo-vertex/app/api/demo-reply/route.ts @@ -0,0 +1,34 @@ +import { type NextRequest, NextResponse } from "next/server"; + +// Server-side proxy: the Comms reply input POSTs here, and we forward to the +// Slack listener, which posts the reply into the card's thread (as the +// reviewer) and records it in the shared store. +export const dynamic = "force-dynamic"; + +const LISTENER_PORT = process.env.LISTENER_PORT || "3010"; + +export async function POST(request: NextRequest) { + let body = "{}"; + try { + body = JSON.stringify(await request.json()); + } catch { + body = "{}"; + } + try { + const res = await fetch(`http://localhost:${LISTENER_PORT}/reply`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }); + const data = await res.json(); + return NextResponse.json(data, { status: res.ok ? 200 : res.status }); + } catch { + return NextResponse.json( + { + ok: false, + error: `Slack listener not reachable on :${LISTENER_PORT}. Start it with: cd slack && npm start`, + }, + { status: 502 }, + ); + } +} diff --git a/apps/apollo-vertex/app/api/demo-state/route.ts b/apps/apollo-vertex/app/api/demo-state/route.ts new file mode 100644 index 000000000..bb457d17e --- /dev/null +++ b/apps/apollo-vertex/app/api/demo-state/route.ts @@ -0,0 +1,18 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { NextResponse } from "next/server"; + +// Reads the shared demo store that the Slack listener writes to. The invoice +// review UI polls this every couple seconds to reflect Slack-driven actions. +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const file = path.join(process.cwd(), "data", "demo-state.json"); + const raw = await readFile(file, "utf8"); + return NextResponse.json(JSON.parse(raw)); + } catch { + // Store not created yet (listener never ran) — return an empty overlay. + return NextResponse.json({ invoices: {}, updated_at: null }); + } +} diff --git a/apps/apollo-vertex/app/api/demo-trigger/route.ts b/apps/apollo-vertex/app/api/demo-trigger/route.ts new file mode 100644 index 000000000..dde132dd9 --- /dev/null +++ b/apps/apollo-vertex/app/api/demo-trigger/route.ts @@ -0,0 +1,37 @@ +import { type NextRequest, NextResponse } from "next/server"; + +// Server-side proxy: the prototype's "Escalate to manager" flag action POSTs +// here, and we forward to the standalone Slack listener's local HTTP endpoint. +// Keeps the listener port server-side (no CORS, no client hardcoding). +export const dynamic = "force-dynamic"; + +const LISTENER_PORT = process.env.LISTENER_PORT || "3010"; + +export async function POST(request: NextRequest) { + let body = "{}"; + try { + body = JSON.stringify(await request.json()); + } catch { + body = "{}"; // no body is fine — listener falls back to demo defaults + } + try { + const res = await fetch( + `http://localhost:${LISTENER_PORT}/trigger-escalation`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + }, + ); + const data = await res.json(); + return NextResponse.json(data, { status: res.ok ? 200 : 502 }); + } catch { + return NextResponse.json( + { + ok: false, + error: `Slack listener not reachable on :${LISTENER_PORT}. Start it with: cd slack && npm start`, + }, + { status: 502 }, + ); + } +} diff --git a/apps/apollo-vertex/app/invoice-review/page.tsx b/apps/apollo-vertex/app/invoice-review/page.tsx new file mode 100644 index 000000000..63853d27f --- /dev/null +++ b/apps/apollo-vertex/app/invoice-review/page.tsx @@ -0,0 +1,10 @@ +"use client"; +import { InvoiceReviewTemplate } from "@/templates/invoice-review/InvoiceReviewTemplate"; + +export default function InvoiceReviewPage() { + return ( +
+ +
+ ); +} diff --git a/apps/apollo-vertex/lib/i18n.ts b/apps/apollo-vertex/lib/i18n.ts index 7d0253891..86ffe428a 100644 --- a/apps/apollo-vertex/lib/i18n.ts +++ b/apps/apollo-vertex/lib/i18n.ts @@ -30,6 +30,10 @@ export const SUPPORTED_LOCALES = Object.keys( const DEFAULT_LOCALE: SupportedLocale = "en"; export const configurei18n = async () => { + if (i18n.isInitialized) { + document.documentElement.lang = i18n.language; + return; + } await i18n .use({ type: "backend", diff --git a/apps/apollo-vertex/locales/en.json b/apps/apollo-vertex/locales/en.json index ed39a3c4a..9bde2f4bc 100644 --- a/apps/apollo-vertex/locales/en.json +++ b/apps/apollo-vertex/locales/en.json @@ -68,6 +68,7 @@ "image_preview": "Image preview", "import": "Import", "info": "Info", + "invoices": "Invoices", "japanese": "Japanese", "korean": "Korean", "language": "Language", diff --git a/apps/apollo-vertex/package.json b/apps/apollo-vertex/package.json index d8c96ae51..3b10cd806 100644 --- a/apps/apollo-vertex/package.json +++ b/apps/apollo-vertex/package.json @@ -6,6 +6,8 @@ "main": "index.js", "scripts": { "dev": "pnpm generate:theme && next --turbopack", + "demo": "slack/node_modules/.bin/concurrently --names APP,SLACK --prefix-colors cyan,magenta \"pnpm dev\" \"cd slack && npm start\"", + "demo:reset": "cd slack && npm run reset-demo", "build": "pnpm generate:theme && pnpm registry:build && next build", "start": "next start", "generate:theme": "node --experimental-strip-types scripts/generate-theme-css.ts", diff --git a/apps/apollo-vertex/public/peter-vachon.jpg b/apps/apollo-vertex/public/peter-vachon.jpg new file mode 100644 index 000000000..dbb00323b Binary files /dev/null and b/apps/apollo-vertex/public/peter-vachon.jpg differ diff --git a/apps/apollo-vertex/registry.json b/apps/apollo-vertex/registry.json index 09b3a7062..6932ff8a5 100644 --- a/apps/apollo-vertex/registry.json +++ b/apps/apollo-vertex/registry.json @@ -126,6 +126,16 @@ "chart-3": "oklch(0.8300 0.1550 75.2000)", "chart-4": "oklch(0.7200 0.1800 320.8000)", "chart-5": "oklch(0.6800 0.1500 245.5000)", + "insight-50": "oklch(0.96 0.03 277)", + "insight-100": "oklch(0.92 0.05 277)", + "insight-200": "oklch(0.86 0.09 277)", + "insight-300": "oklch(0.78 0.14 277)", + "insight-400": "oklch(0.70 0.19 277)", + "insight-500": "oklch(0.62 0.22 277)", + "insight-600": "oklch(0.56 0.20 277)", + "insight-700": "oklch(0.48 0.17 277)", + "insight-800": "oklch(0.38 0.13 278)", + "insight-900": "oklch(0.30 0.10 278)", "sidebar": "oklch(0.9723 0.0074 260.7300)", "sidebar-foreground": "oklch(0.2394 0.0455 252.4450)", "sidebar-primary": "oklch(0.64 0.115 208)", @@ -230,6 +240,16 @@ "chart-3": "oklch(0.8300 0.1550 75.2000)", "chart-4": "oklch(0.7200 0.1800 320.8000)", "chart-5": "oklch(0.6800 0.1500 245.5000)", + "insight-50": "oklch(0.96 0.03 277)", + "insight-100": "oklch(0.92 0.05 277)", + "insight-200": "oklch(0.86 0.09 277)", + "insight-300": "oklch(0.78 0.14 277)", + "insight-400": "oklch(0.70 0.19 277)", + "insight-500": "oklch(0.62 0.22 277)", + "insight-600": "oklch(0.56 0.20 277)", + "insight-700": "oklch(0.48 0.17 277)", + "insight-800": "oklch(0.38 0.13 278)", + "insight-900": "oklch(0.30 0.10 278)", "sidebar": "oklch(0.1620 0.0310 257.7000)", "sidebar-foreground": "oklch(0.9525 0.0110 225.9830)", "sidebar-primary": "oklch(0.69 0.112 207)", diff --git a/apps/apollo-vertex/registry/button/button.tsx b/apps/apollo-vertex/registry/button/button.tsx index 64c3ae31d..67e13772b 100644 --- a/apps/apollo-vertex/registry/button/button.tsx +++ b/apps/apollo-vertex/registry/button/button.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", { variants: { variant: { diff --git a/apps/apollo-vertex/slack/README.md b/apps/apollo-vertex/slack/README.md new file mode 100644 index 000000000..609e7a226 --- /dev/null +++ b/apps/apollo-vertex/slack/README.md @@ -0,0 +1,120 @@ +# Slack listener — Invoice Processing demo + +A standalone Node listener that drives the bidirectional Slack flow in the +Invoice Processing prototype. The prototype itself runs fine without this +listener — the Slack escalation just won't post, and a toast surfaces the +error. Wire it up if you want the full roundtrip. + +## What it does + +- Connects to Slack in **Socket Mode** (no public webhook required). +- Posts the agent's escalation card to `#ap-exceptions` when the reviewer + flags an invoice with reason "Escalating to manager". +- Handles button clicks on the card (Approve / Hold / Reject) and writes + the resolution to a shared JSON file the prototype polls. +- Ingests human thread replies on the card and surfaces them in the + prototype's Comms feed. +- Posts replies from the prototype's Comms input back into the thread, + attributed to the human via `chat:write.customize`. + +Everything is intentionally demo-grade — the team is expected to rewrite +this against real infra later. + +## Run it + +```bash +cd apps/apollo-vertex/slack +npm install +npm start +``` + +Reset between rehearsals (deletes the bot's own messages in the channel +and clears the shared store): + +```bash +npm run reset-demo +``` + +## Configuration + +The listener reads env vars from `apps/apollo-vertex/.env` (one level up). +Copy `.env.example` to `.env` and fill in real values: + +| Var | What it is | +| ---------------------- | --------------------------------------------------- | +| `SLACK_BOT_TOKEN` | Bot User OAuth Token — starts `xoxb-` | +| `SLACK_APP_TOKEN` | App-Level Token for Socket Mode — starts `xapp-` | +| `SLACK_SIGNING_SECRET` | Optional under Socket Mode; included for parity | +| `SLACK_CHANNEL_ID` | Channel ID for `#ap-exceptions` — starts `C` | +| `LISTENER_PORT` | Local HTTP endpoint port (default `3010`) | + +`.env` is gitignored. `.env.example` is the template you copy from. + +## Slack app setup + +In the Slack workspace, create an app **from manifest** with the YAML +below, then install it to the workspace. Grab the tokens from +**OAuth & Permissions** (bot token) and **Basic Information → App-Level +Tokens** (app token with `connections:write`). + +```yaml +display_information: + name: Invoice Agent +features: + bot_user: + display_name: Invoice Agent + always_online: true +oauth_config: + scopes: + bot: + - chat:write + - chat:write.customize + - users:read + - channels:history +settings: + event_subscriptions: + bot_events: + - message.channels + interactivity: + is_enabled: true + socket_mode_enabled: true +``` + +After installing the app, invite the bot into `#ap-exceptions` +(`/invite @Invoice Agent`) so it can post. + +## Hardcoded reviewer identity + +Two places assume the reviewer is **Peter Vachon**: + +- `REVIEWER_NAME` in `slack/server.js` (fallback for Comms attribution + when the prototype's request body omits identity). +- `REVIEWER_NAME` / `REVIEWER_AVATAR_PUBLIC` in + `templates/invoice-review/InvoiceReviewTemplate.tsx`. The public avatar + URL points at Peter's Slack CDN image so `chat:write.customize` can + render it. + +If someone else demos, either: +1. Swap those constants for the new presenter, or +2. Stub the prototype to read identity from a config — TODO when the team + rewrites this. + +## File layout + +``` +slack/ +├── server.js # Bolt app + HTTP endpoint +├── store.js # JSON-file shared store (data/demo-state.json) +├── escalation-card.js # Block Kit card builder +├── reset-demo.js # Deletes bot's messages + resets store +└── package.json # Isolated npm install (not part of pnpm workspace) +``` + +The shared store lives at `apps/apollo-vertex/data/demo-state.json` +(gitignored; auto-created on first listener run). + +## Architecture in one sentence + +Listener writes a JSON file → prototype polls `/api/demo-state` every 2s +→ store changes appear in the UI; user replies hit `/api/demo-reply` +which proxies to the listener at `localhost:3010/reply`. diff --git a/apps/apollo-vertex/slack/escalation-card.js b/apps/apollo-vertex/slack/escalation-card.js new file mode 100644 index 000000000..a01607a72 --- /dev/null +++ b/apps/apollo-vertex/slack/escalation-card.js @@ -0,0 +1,113 @@ +// Block Kit builder for the agent's escalation card. Mirrors the in-product +// Slack card (price mismatch on INV-GRN-001). Action IDs follow the +// `invoice_action_*` pattern so the listener's regex handler can route them, +// and each button's `value` carries { invoice_id, action } so the handler +// needs no message-metadata lookup. + +const DEMO_INVOICE = { + id: "INV-GRN-001", + vendor: "ACME Industrial", + invoiced: "$694.39", + poAgreed: "$689.55", + difference: "$4.84", + po: "PO-460035919", +}; + +// Where the prototype is served — used for the "View invoice in Apollo" deep link. +const APP_BASE_URL = process.env.APP_BASE_URL || "http://localhost:3000"; + +// opts: { invoiceId, escalatedBy, note } +// The card body uses the demo invoice's figures; invoiceId flows into the +// action button values so a click resolves the right invoice in the product. +function buildEscalationBlocks(opts = {}) { + const { + invoiceId = DEMO_INVOICE.id, + escalatedBy, + note, + appBaseUrl = APP_BASE_URL, + } = opts; + const inv = { ...DEMO_INVOICE, id: invoiceId }; + const blocks = []; + + // Escalator context (top) — who escalated and their optional note. + if (escalatedBy) { + blocks.push({ + type: "context", + elements: [ + { + type: "mrkdwn", + text: note + ? `Escalated by ${escalatedBy} · ${note}` + : `Escalated by ${escalatedBy}`, + }, + ], + }); + } + + blocks.push( + { + type: "section", + text: { + type: "mrkdwn", + text: `*Price mismatch on ${inv.id} from ${inv.vendor}.* Invoice exceeds the PO-agreed price by ${inv.difference}. Holding for review.`, + }, + }, + { + type: "section", + fields: [ + { type: "mrkdwn", text: `*Invoiced*\n${inv.invoiced}` }, + { type: "mrkdwn", text: `*PO agreed*\n${inv.poAgreed}` }, + { type: "mrkdwn", text: `*Difference*\n:warning: +${inv.difference}` }, + ], + }, + { + type: "actions", + block_id: "invoice_actions", + elements: [ + { + type: "button", + text: { type: "plain_text", text: "Approve anyway" }, + style: "primary", + action_id: "invoice_action_approve", + value: JSON.stringify({ invoice_id: inv.id, action: "approve" }), + }, + { + type: "button", + text: { type: "plain_text", text: "Hold for correction" }, + action_id: "invoice_action_hold", + value: JSON.stringify({ invoice_id: inv.id, action: "hold" }), + }, + { + type: "button", + text: { type: "plain_text", text: "Reject" }, + style: "danger", + action_id: "invoice_action_reject", + value: JSON.stringify({ invoice_id: inv.id, action: "reject" }), + }, + ], + }, + // Deep link back to the invoice in the product (bottom). + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `🔗 <${appBaseUrl}/invoice-review?invoice=${inv.id}|View invoice>`, + }, + ], + }, + ); + + return blocks; +} + +// Plain-text fallback shown in notifications and by screen readers. +function escalationFallbackText(invoiceId = DEMO_INVOICE.id) { + return `Price mismatch on ${invoiceId} from ${DEMO_INVOICE.vendor} — exceeds PO by ${DEMO_INVOICE.difference}. Action needed in #ap-exceptions.`; +} + +module.exports = { + DEMO_INVOICE, + buildEscalationBlocks, + escalationFallbackText, +}; diff --git a/apps/apollo-vertex/slack/package.json b/apps/apollo-vertex/slack/package.json new file mode 100644 index 000000000..0be7ecfcb --- /dev/null +++ b/apps/apollo-vertex/slack/package.json @@ -0,0 +1,16 @@ +{ + "name": "apollo-vertex-slack", + "private": true, + "version": "0.0.0", + "description": "Standalone Slack Socket Mode listener for the Invoice Processing demo. Isolated from the pnpm workspace so it depends only on public npm packages.", + "scripts": { + "start": "node --env-file=../.env server.js", + "reset-demo": "node --env-file=../.env reset-demo.js" + }, + "dependencies": { + "@slack/bolt": "^4.4.0" + }, + "devDependencies": { + "concurrently": "^9.1.0" + } +} diff --git a/apps/apollo-vertex/slack/reset-demo.js b/apps/apollo-vertex/slack/reset-demo.js new file mode 100644 index 000000000..c271ebe02 --- /dev/null +++ b/apps/apollo-vertex/slack/reset-demo.js @@ -0,0 +1,94 @@ +// Reset the demo to a clean state between rehearsals. +// npm run reset-demo (from apps/apollo-vertex/slack) +// +// 1) Deletes the bot's own messages from #ap-exceptions (Slack only lets a bot +// delete its own messages — human replies must be removed manually). +// 2) Resets the shared invoice store to the known starting state. +// Idempotent: safe to run repeatedly. + +const { WebClient } = require("@slack/web-api"); +const store = require("./store"); + +const log = (...args) => console.log("[RESET]", ...args); +const web = new WebClient(process.env.SLACK_BOT_TOKEN); +const channel = process.env.SLACK_CHANNEL_ID; + +(async () => { + if (!process.env.SLACK_BOT_TOKEN || !channel) { + console.error( + "[RESET] Missing SLACK_BOT_TOKEN or SLACK_CHANNEL_ID in .env", + ); + process.exit(1); + } + + let botUserId = null; + try { + const auth = await web.auth.test(); + botUserId = auth.user_id; + log(`bot identity: ${auth.user} (${botUserId})`); + } catch (e) { + log("auth.test failed:", e.data?.error || e.message); + } + + let deleted = 0; + let skippedHuman = 0; + let failed = 0; + const alreadyDeleted = new Set(); + + // 1) Delete cards by the exact timestamps the listener recorded. This is the + // reliable path — chat.delete by ts doesn't depend on conversations.history + // consistency (which lags for very recent messages). + const tracked = Object.keys(store.readState().cards || {}); + if (tracked.length) log(`deleting ${tracked.length} tracked card(s) by ts`); + for (const ts of tracked) { + try { + await web.chat.delete({ channel, ts }); + deleted++; + alreadyDeleted.add(ts); + } catch (e) { + const err = e.data?.error || e.message; + if (err === "message_not_found") { + alreadyDeleted.add(ts); // already gone — fine + } else { + failed++; + log(`could not delete tracked ${ts}: ${err}`); + } + } + } + + // 2) Backup sweep: catch any bot content not tracked (e.g., posted before + // tracking, or manual test posts). Best-effort; history can lag. + try { + const hist = await web.conversations.history({ channel, limit: 200 }); + const messages = hist.messages || []; + for (const m of messages) { + if (m.subtype) continue; // leave system messages (joins, etc.) + if (alreadyDeleted.has(m.ts)) continue; + const isOurs = Boolean(m.bot_id) || (botUserId && m.user === botUserId); + if (!isOurs) { + skippedHuman++; + continue; + } + try { + await web.chat.delete({ channel, ts: m.ts }); + deleted++; + } catch (e) { + failed++; + log(`could not delete ${m.ts}: ${e.data?.error || e.message}`); + } + } + } catch (e) { + log("conversations.history failed:", e.data?.error || e.message); + } + + store.resetAll(); // clears invoices + postedMessages back to initial + + log(`deleted ${deleted} bot message(s); ${failed} failed`); + log("store reset → INV-GRN-001 = awaiting_decision"); + if (skippedHuman > 0) { + log( + `left ${skippedHuman} non-bot message(s) in place — the bot can't delete human replies; remove those manually if needed`, + ); + } + log("done. Reload the prototype (⌘R) to clear any in-browser state."); +})(); diff --git a/apps/apollo-vertex/slack/server.js b/apps/apollo-vertex/slack/server.js new file mode 100644 index 000000000..98bae57ce --- /dev/null +++ b/apps/apollo-vertex/slack/server.js @@ -0,0 +1,388 @@ +// Standalone Slack Socket Mode listener for the Invoice Processing demo. +// Run with: npm start (from apps/apollo-vertex/slack) +// which is: node --env-file=../.env server.js +// +// Responsibilities: connect via Socket Mode, post the agent escalation card on +// trigger, and handle button clicks (Approve / Hold / Reject) by writing the +// shared store and updating the Slack message. + +const http = require("node:http"); +const { App } = require("@slack/bolt"); +const { + DEMO_INVOICE, + buildEscalationBlocks, + escalationFallbackText, +} = require("./escalation-card"); +const store = require("./store"); + +const log = (...args) => console.log("[SLACK]", ...args); +const errlog = (...args) => console.error("[SLACK]", ...args); + +// Slack message status line shown (via chat.update) after an action is taken. +const STATUS_LINE = { + approve: (by, ts) => + `:white_check_mark: *Approved* by ${by} · `, + hold: (by, ts) => + `:hourglass_flowing_sand: *Held for correction* by ${by} · `, + reject: (by, ts) => `:x: *Rejected* by ${by} · `, +}; + +const LISTENER_PORT = Number(process.env.LISTENER_PORT) || 3010; + +// Fallback attribution for product-originated replies in the Comms feed +// when the request body doesn't carry an explicit `posted_as`. Kept narrow: +// the human's identity normally comes from the product (data.assignee + +// public avatar URL) so the Slack post and the feed agree. +const REVIEWER_NAME = "Peter Vachon"; +const REVIEWER_INITIALS = "PV"; +const REVIEWER_AVATAR_LOCAL = "/peter-vachon.jpg"; + +// Remember the most recently posted escalation card so later steps can update +// it (chat.update) after an action is taken. +let lastMessage = null; + +const required = ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"]; +const missing = required.filter((k) => !process.env[k]); +if (missing.length > 0) { + errlog( + `Missing env vars: ${missing.join(", ")}.`, + "Create apps/apollo-vertex/.env from .env.example and run via `npm start`.", + ); + process.exit(1); +} + +const app = new App({ + token: process.env.SLACK_BOT_TOKEN, + appToken: process.env.SLACK_APP_TOKEN, + // Not required for Socket Mode, passed for completeness when present. + signingSecret: process.env.SLACK_SIGNING_SECRET || undefined, + socketMode: true, +}); + +// Surface Socket Mode connection lifecycle so demo issues are easy to spot. +const sm = app.receiver?.client; +if (sm) { + sm.on("connecting", () => log("connecting…")); + sm.on("connected", () => log("connected — Socket Mode listener running")); + sm.on("disconnected", () => log("disconnected")); + sm.on("reconnecting", () => log("reconnecting…")); +} + +// Resolve a Slack user's display name, cached for the listener's run so we +// don't hammer users.info. +const userNameCache = new Map(); +async function resolveUserName(client, userId, fallback) { + if (!userId) return fallback || "Someone"; + if (userNameCache.has(userId)) return userNameCache.get(userId); + let name = fallback || userId; + try { + const info = await client.users.info({ user: userId }); + name = + info.user?.real_name || + info.user?.profile?.real_name || + info.user?.name || + name; + } catch { + /* keep fallback */ + } + userNameCache.set(userId, name); + return name; +} + +function initialsFrom(name) { + const parts = String(name).trim().split(/\s+/).filter(Boolean); + if (parts.length >= 2) { + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); + } + return String(name).slice(0, 2).toUpperCase(); +} + +// Handle button clicks on the escalation card. action_id matches +// `invoice_action_*`; the button `value` carries { invoice_id, action }. +app.action(/^invoice_action_.+$/, async ({ ack, body, client, action }) => { + // Must ack within 3 seconds or Slack shows the user an error. + await ack(); + + let payload = {}; + try { + payload = JSON.parse(action.value || "{}"); + } catch { + errlog("could not parse action value:", action.value); + } + const invoiceId = payload.invoice_id; + const act = payload.action; // approve | hold | reject + + // Resolve a friendly display name (users:read scope); fall back gracefully. + const by = await resolveUserName( + client, + body.user?.id, + body.user?.username || body.user?.name, + ); + + log(`action received: ${act} on ${invoiceId} by ${by}`); + + // 1) Update the shared store the prototype polls. + try { + store.resolveInvoice(invoiceId, { action: act, by }); + log(`store updated: ${invoiceId} → ${store.STATUS_BY_ACTION[act] || act}`); + } catch (e) { + errlog("store write failed:", e?.message || e); + } + + // 2) Update the Slack message: drop the buttons, add a status line. + try { + const keptBlocks = (body.message?.blocks || []).filter( + (b) => b.type !== "actions", + ); + const tsNow = Math.floor(Date.now() / 1000); + const lineFn = STATUS_LINE[act]; + const statusText = lineFn + ? lineFn(by, tsNow) + : `*${act}* by ${by} · `; + await client.chat.update({ + channel: body.channel.id, + ts: body.message.ts, + text: `${invoiceId} ${act} by ${by}`, + blocks: [ + ...keptBlocks, + { type: "context", elements: [{ type: "mrkdwn", text: statusText }] }, + ], + }); + log(`chat.update ok: ${invoiceId} marked ${act}`); + } catch (e) { + errlog("chat.update failed:", e?.data ? JSON.stringify(e.data) : e.message); + } +}); + +// Ingest thread replies on cards we've posted into the invoice's Comms feed. +// Requires the `message.channels` bot event subscription (Socket Mode). +app.message(async ({ message, client }) => { + const ignore = (reason) => log(`ignored message: ${reason}`); + + // Edits/deletes are out of scope for v1. + if ( + message.subtype === "message_changed" || + message.subtype === "message_deleted" + ) { + return ignore(`subtype ${message.subtype} (edits/deletes skipped)`); + } + // Never re-ingest the bot's own messages — they're already in the product. + if (message.subtype === "bot_message" || message.bot_id) { + return ignore("bot message"); + } + // Thread replies only — top-level channel posts are ignored. + if (!message.thread_ts) return ignore("not a thread reply"); + + const invoiceId = store.invoiceForThread(message.thread_ts); + if (!invoiceId) + return ignore(`thread ${message.thread_ts} is not a known card`); + + const body = (message.text || "").trim(); + if (!body) return ignore("empty text (non-text message skipped)"); + + const name = await resolveUserName(client, message.user); + const added = store.addMessage(invoiceId, { + id: `slack-${message.ts}`, + source: "slack", + subtype: "plain", + from: { name, initials: initialsFrom(name), type: "human" }, + toOrChannel: "reply in #ap-exceptions", + timestamp: new Date(Number(message.ts) * 1000).toISOString(), + body, + external_id: message.ts, + }); + + if (added) { + log(`ingested message from ${name} in thread ${message.thread_ts}`); + } else { + ignore(`duplicate ${message.ts}`); + } +}); + +// Post the agent's escalation card to the demo channel. +// opts: { invoiceId, escalatedBy, note } +async function postEscalation(opts = {}) { + const invoiceId = opts.invoiceId || DEMO_INVOICE.id; + log( + `escalation triggered → posting card for ${invoiceId}` + + (opts.escalatedBy ? ` (by ${opts.escalatedBy})` : ""), + ); + // Start clean so the prototype doesn't pick up a prior rehearsal's result. + store.resetInvoice(invoiceId); + const res = await app.client.chat.postMessage({ + channel: process.env.SLACK_CHANNEL_ID, + // No identity overrides — posts as the app's default identity. + text: escalationFallbackText(invoiceId), + blocks: buildEscalationBlocks({ + invoiceId, + escalatedBy: opts.escalatedBy, + note: opts.note, + }), + }); + lastMessage = { channel: res.channel, ts: res.ts }; + // Map the card's ts → invoice so thread replies route back here, and so + // reset can delete the card by exact ts. + store.recordCard(res.ts, invoiceId); + log(`chat.postMessage ok — ts: ${res.ts}`); + return { ok: true, ts: res.ts, channel: res.channel }; +} + +// Collect a JSON request body (small payloads only). +function readJsonBody(req) { + return new Promise((resolve) => { + let data = ""; + req.on("data", (c) => { + data += c; + if (data.length > 1e6) req.destroy(); // guard + }); + req.on("end", () => { + try { + resolve(data ? JSON.parse(data) : {}); + } catch { + resolve({}); + } + }); + req.on("error", () => resolve({})); + }); +} + +// Post a reply from the product back into the card's Slack thread. Human +// replies arrive with a `postedAs` block so Slack attributes the message to +// the reviewer via chat:write.customize; agent-authored content omits it +// (and posts as the app's default identity). The bot-authored message is +// filtered from ingestion, so we record it in the store ourselves. +// +// postedAs.avatarUrl MUST be publicly reachable — Slack's CDN fetches it +// server-side, so localhost paths won't render. +async function postThreadReply({ invoiceId, text, postedAs }) { + const body = (text || "").trim(); + if (!body) return { ok: false, error: "empty reply" }; + const threadTs = store.threadForInvoice(invoiceId); + if (!threadTs) { + return { + ok: false, + error: `No active Slack card thread for ${invoiceId} — escalate first.`, + }; + } + const postArgs = { + channel: process.env.SLACK_CHANNEL_ID, + thread_ts: threadTs, + text: body, + }; + if (postedAs?.name) postArgs.username = postedAs.name; + if (postedAs?.avatarUrl) postArgs.icon_url = postedAs.avatarUrl; + const res = await app.client.chat.postMessage(postArgs); + const displayName = postedAs?.name || REVIEWER_NAME; + store.addMessage(invoiceId, { + id: `slack-${res.ts}`, + source: "slack", + subtype: "plain", + from: { + name: displayName, + initials: initialsFrom(displayName) || REVIEWER_INITIALS, + type: "human", + avatarUrl: postedAs?.avatarUrl || REVIEWER_AVATAR_LOCAL, + }, + toOrChannel: "reply in #ap-exceptions", + timestamp: new Date(Number(res.ts) * 1000).toISOString(), + body, + external_id: res.ts, + }); + log( + `posted product reply to thread ${threadTs} (${invoiceId})` + + (postedAs?.name ? ` as ${postedAs.name}` : ""), + ); + return { ok: true, ts: res.ts }; +} + +// Small local HTTP endpoint so the prototype's "Trigger escalation" affordance +// (or a curl) can ask the listener to post the card. Kept off port 3000. +function startHttpTrigger() { + const server = http.createServer((req, res) => { + res.setHeader("Access-Control-Allow-Origin", "*"); + res.setHeader("Access-Control-Allow-Methods", "POST, GET, OPTIONS"); + res.setHeader("Access-Control-Allow-Headers", "Content-Type"); + if (req.method === "OPTIONS") { + res.writeHead(204); + return res.end(); + } + if (req.method === "GET" && req.url === "/health") { + res.writeHead(200, { "Content-Type": "application/json" }); + return res.end(JSON.stringify({ ok: true, connected: true })); + } + if (req.method === "POST" && req.url === "/trigger-escalation") { + readJsonBody(req) + .then((body) => + postEscalation({ + invoiceId: body.invoice_id, + escalatedBy: body.by, + note: body.note, + }), + ) + .then((r) => { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(r)); + }) + .catch((e) => { + const detail = e?.data ? JSON.stringify(e.data) : e?.message; + errlog("trigger failed:", detail); + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: detail })); + }); + return; + } + if (req.method === "POST" && req.url === "/reply") { + readJsonBody(req) + .then((body) => + postThreadReply({ + invoiceId: body.invoice_id, + text: body.text, + postedAs: body.posted_as + ? { + name: body.posted_as.name, + avatarUrl: body.posted_as.avatar_url, + } + : undefined, + }), + ) + .then((r) => { + res.writeHead(r.ok ? 200 : 400, { + "Content-Type": "application/json", + }); + res.end(JSON.stringify(r)); + }) + .catch((e) => { + const detail = e?.data ? JSON.stringify(e.data) : e?.message; + errlog("reply failed:", detail); + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ ok: false, error: detail })); + }); + return; + } + res.writeHead(404); + res.end("not found"); + }); + server.listen(LISTENER_PORT, () => + log( + `HTTP trigger on http://localhost:${LISTENER_PORT} (POST /trigger-escalation)`, + ), + ); +} + +(async () => { + try { + await app.start(); + log( + `channel target: ${process.env.SLACK_CHANNEL_ID || "(SLACK_CHANNEL_ID unset)"}`, + ); + startHttpTrigger(); + } catch (err) { + errlog("failed to start:", err?.message || err); + process.exit(1); + } +})(); + +process.on("SIGINT", () => { + log("shutting down"); + process.exit(0); +}); diff --git a/apps/apollo-vertex/slack/store.js b/apps/apollo-vertex/slack/store.js new file mode 100644 index 000000000..b9cfc7c44 --- /dev/null +++ b/apps/apollo-vertex/slack/store.js @@ -0,0 +1,155 @@ +// Shared invoice state for the demo. The Slack listener writes here; the +// prototype reads it (via /api/demo-state) and reflects changes in the UI. +// A JSON file is the source of truth — no DB needed for 8 demo invoices. +// Writes are atomic (write temp + rename) to avoid partial reads under races. + +const fs = require("node:fs"); +const path = require("node:path"); + +const DATA_DIR = path.join(__dirname, "..", "data"); +const STORE_PATH = path.join(DATA_DIR, "demo-state.json"); + +const STATUS_BY_ACTION = { + approve: "approved", + hold: "on_hold", + reject: "rejected", +}; + +function blankInvoice() { + return { + status: "awaiting_decision", + action: null, + resolved_at: null, + resolved_by: null, + resolved_via: null, + messages: [], // thread replies ingested from Slack + }; +} + +// Known starting state for the demo. The reset script returns to exactly this. +function initialState() { + return { + invoices: { + "INV-GRN-001": blankInvoice(), + }, + // Maps a posted card's ts -> invoice id. Used to (a) route thread replies + // back to the right invoice and (b) delete the bot's cards on reset. + cards: {}, + updated_at: null, + }; +} + +function ensure() { + if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); + if (!fs.existsSync(STORE_PATH)) writeState(initialState()); +} + +function readState() { + ensure(); + try { + const state = JSON.parse(fs.readFileSync(STORE_PATH, "utf8")); + if (!state.cards) state.cards = {}; + return state; + } catch { + return initialState(); + } +} + +function writeState(state) { + if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true }); + state.updated_at = new Date().toISOString(); + const tmp = `${STORE_PATH}.tmp-${process.pid}`; + fs.writeFileSync(tmp, JSON.stringify(state, null, 2)); + fs.renameSync(tmp, STORE_PATH); // atomic on the same filesystem + return state; +} + +// Record a resolution from a Slack action. action is approve | hold | reject. +// Preserves any ingested thread messages. +function resolveInvoice(invoiceId, { action, by }) { + const state = readState(); + const prev = state.invoices[invoiceId] || blankInvoice(); + state.invoices[invoiceId] = { + ...prev, + status: STATUS_BY_ACTION[action] || "awaiting_decision", + action, + resolved_at: new Date().toISOString(), + resolved_by: by || "Someone", + resolved_via: "slack", + }; + writeState(state); + return state.invoices[invoiceId]; +} + +// Map a posted card's ts to its invoice (for thread-reply routing + reset). +function recordCard(ts, invoiceId) { + const state = readState(); + state.cards[ts] = invoiceId; + writeState(state); + return state.cards; +} + +// Which invoice (if any) does this thread_ts belong to? +function invoiceForThread(threadTs) { + return readState().cards[threadTs] || null; +} + +// The (latest) card thread for an invoice — used to post product replies back. +function threadForInvoice(invoiceId) { + const cards = readState().cards || {}; + const matches = Object.keys(cards).filter((ts) => cards[ts] === invoiceId); + if (!matches.length) return null; + matches.sort((a, b) => Number(b) - Number(a)); + return matches[0]; +} + +// Append an ingested Slack thread reply. Deduped by external_id. +// Returns true if added, false if it was a duplicate. +function addMessage(invoiceId, message) { + const state = readState(); + const inv = state.invoices[invoiceId] || blankInvoice(); + if (!Array.isArray(inv.messages)) inv.messages = []; + if ( + message.external_id && + inv.messages.some((m) => m.external_id === message.external_id) + ) { + state.invoices[invoiceId] = inv; + writeState(state); + return false; + } + inv.messages.push(message); + state.invoices[invoiceId] = inv; + writeState(state); + return true; +} + +function resetInvoice(invoiceId) { + const state = readState(); + state.invoices[invoiceId] = blankInvoice(); + // Drop any card mappings pointing at this invoice (start the thread fresh). + for (const ts of Object.keys(state.cards)) { + if (state.cards[ts] === invoiceId) delete state.cards[ts]; + } + writeState(state); + return state.invoices[invoiceId]; +} + +function resetAll() { + return writeState(initialState()); +} + +module.exports = { + STORE_PATH, + STATUS_BY_ACTION, + initialState, + ensure, + readState, + writeState, + resolveInvoice, + recordCard, + invoiceForThread, + threadForInvoice, + addMessage, + resetInvoice, + resetAll, +}; diff --git a/apps/apollo-vertex/templates/invoice-review/InvoiceReviewTemplate.tsx b/apps/apollo-vertex/templates/invoice-review/InvoiceReviewTemplate.tsx new file mode 100644 index 000000000..8f45e8d14 --- /dev/null +++ b/apps/apollo-vertex/templates/invoice-review/InvoiceReviewTemplate.tsx @@ -0,0 +1,6318 @@ +"use client"; + +import { + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + RouterContextProvider, +} from "@tanstack/react-router"; +import type { ColumnDef, FilterFn } from "@tanstack/react-table"; +import { motion } from "framer-motion"; +import { + ArrowLeft, + ArrowRight, + ArrowUp, + Check, + ChevronDown, + ChevronLeft, + ChevronRight, + Clock, + ExternalLink, + FileText, + Flag, + Loader2, + Mail, + MessageSquare, + MessageSquareOff, + Play, + Plus, + RefreshCw, + Send, + Settings2, + Sparkle, + Sparkles, + TriangleAlert, + X, +} from "lucide-react"; +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { toast } from "sonner"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { + DataTable, + DataTableColumnHeader, + dataTableFacetedFilterFn, + dataTableGlobalFilterFn, +} from "@/components/ui/data-table"; +import { FilterDropdown } from "@/components/ui/filter-dropdown"; +import { MetricCard } from "@/components/ui/metric-card"; +import { + PageHeader, + PageHeaderActions, + PageHeaderContent, + PageHeaderDescription, + PageHeaderField, + PageHeaderFieldLabel, + PageHeaderFieldValue, + PageHeaderNav, + PageHeaderTitle, + PageHeaderTitleGroup, +} from "@/components/ui/page-header"; +import { Separator } from "@/components/ui/separator"; +import { ApolloShell } from "@/components/ui/shell"; +import { Skeleton } from "@/components/ui/skeleton"; +import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils"; +import { AutopilotGradientIcon } from "@/registry/ai-chat/components/icons/autopilot-gradient"; +import { Avatar, AvatarFallback, AvatarImage } from "@/registry/avatar/avatar"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/registry/dialog/dialog"; +import { Toaster } from "@/registry/sonner/sonner"; +import { useDataTable } from "@/registry/use-data-table/useDataTable"; + +// ── Data ───────────────────────────────────────────────────────────────────── + +interface Invoice { + id: string; + vendor: string; + amount: string; + tag?: string; + tagType?: "error" | "warning" | "info"; + score: number; + status?: "done"; + dueGroup: "today" | "tomorrow" | "auto"; +} + +type ExceptionType = + | "price-mismatch" + | "high-value" + | "no-po-match" + | "duplicate" + | "missing-po" + | "new-vendor" + | "none"; +type InvoiceStatus = + | "pending-review" + | "in-review" + | "approved" + | "rejected" + | "sent-for-approval" + | "flagged" + | "on-hold"; + +interface InvoiceTableRow { + id: string; + vendor: string; + amount: number; + currency: "USD" | "EUR" | "GBP" | "CAD"; + dueDate: string; + exception: ExceptionType; + score: number; + status: InvoiceStatus; + assignee: string; +} + +interface InvoiceDetailData { + id: string; + vendor: string; + vendorEmail: string; + amount: string; + currency: string; + dueDate: string; + dueFormatted: string; + documentDateFormatted: string; + po: string; + paymentTerms: string; + billTo: string; + billAddress: string; + assignee: string; + assigneeInitials: string; + vat: string; + description: string; + exceptionTag: string; + exceptionTagStatus: "error" | "warning" | "info"; + exceptionHeadline: string; + exceptionMetrics: { label: string; value: string; cls: string }[]; + exceptionBody: string; + exceptionPrimaryAction: string; + exceptionSecondaryAction: string; + lines: { + description: string; + qty: number; + amount: string; + unitPrice?: string; + flag?: string; + flagStatus?: "error" | "warning"; + agreed?: string; + }[]; + linesTotal: string; + linesAlert?: { text: string; status: "error" | "warning" }; + sourceFilename: string; + sourceLines: string[]; +} + +interface SentEmail { + to: string; + cc: string; + subject: string; + body: string; + sentAt: string; +} + +// ── Canonical action model ────────────────────────────────────────────────── +// One action set per invoice. Every surface (Findings, Slack card) dispatches +// these same IDs; surfaces only choose which subset to show and how to label it. +type ActionId = "approve" | "hold" | "contact_supplier" | "reject" | "flag"; +type ActionSource = "findings" | "slack"; + +const ACTION_LABELS: Record = { + approve: "Approve", + hold: "Hold", + contact_supplier: "Contact supplier", + reject: "Reject", + flag: "Flag", +}; + +interface CommsAttachment { + name: string; + size: string; +} + +interface CommsContextBlock { + title: string; + headline?: string; + rows: { label: string; value: string; accent?: "error" | "warning" }[]; +} + +interface CommsAction { + label: string; + actionId: ActionId; + variant: "primary" | "secondary"; +} + +interface CommsMessage { + id: string; + source: "email" | "slack"; + // Which email provider sent this — surfaces as an Outlook brand mark in + // the card header so "Contact supplier" messages are attributed to the + // Outlook channel. Leave undefined for generic / non-provider emails. + provider?: "outlook"; + subtype: "plain" | "rich_block"; + from: { + name: string; + initials: string; + type: "human" | "agent"; + avatarUrl?: string; + company?: boolean; + }; + toOrChannel: string; + direction?: "inbound" | "outbound"; + timestamp: string; + body: string; + attachments?: CommsAttachment[]; + contextBlocks?: CommsContextBlock[]; + actions?: CommsAction[]; +} + +const detailDataMap: Record = { + "INV-GRN-001": { + id: "INV-GRN-001", + vendor: "ACME Industrial", + vendorEmail: "accounts@acmeindustrial.com", + amount: "$694.39 USD", + currency: "USD", + dueDate: "2026-05-28", + dueFormatted: "May 28, 2026", + documentDateFormatted: "Apr 10, 2026", + po: "PO-460035919", + paymentTerms: "Net 30 · USD", + billTo: "Global Enterprises Inc", + billAddress: "800 Corporate Center, Chicago IL 60601", + assignee: "Peter Vachon", + assigneeInitials: "PV", + vat: "—", + description: + "USB peripherals for Q4 office refresh. Single-line invoice for bulk USB hubs supplied under blanket PO-460035919.", + exceptionTag: "Price mismatch", + exceptionTagStatus: "error", + exceptionHeadline: "USB Hub invoiced above agreed price", + exceptionMetrics: [ + { label: "Invoiced", value: "$694.39", cls: "text-foreground" }, + { label: "PO agreed", value: "$689.55", cls: "text-foreground" }, + { label: "Difference", value: "-$4.84", cls: "text-[#C0392B]" }, + ], + exceptionBody: + "Supplier agreed to discounted price per PO note — invoice reflects original price. Request a corrected invoice from ACME Industrial before approving.", + exceptionPrimaryAction: "Contact supplier", + exceptionSecondaryAction: "Reject invoice", + lines: [ + { + description: "USB Hub — 7 Port Powered", + qty: 1, + amount: "$694.39", + unitPrice: "$694.39", + flag: "↑ price", + flagStatus: "error", + agreed: "$689.55", + }, + ], + linesTotal: "$694.39", + linesAlert: { + text: "Price exceeds PO by $4.84 — could not auto-resolve.", + status: "error", + }, + sourceFilename: "INV-GRN-001.pdf", + sourceLines: [ + "INVOICE", + "Invoice #: INV-GRN-001", + "Date: October 5, 2025", + "Due: November 2, 2025", + "---", + "From:", + "ACME Industrial Supply", + "123 Industrial Way", + "Detroit, MI 48201", + "---", + "Bill To:", + "Global Enterprises Inc", + "800 Corporate Center", + "Chicago, IL 60601", + "---", + "Items:", + "USB Hub 7-Port × 1 · $694.39", + "---", + "Total: $694.39", + ], + }, + "INV-66216": { + id: "INV-66216", + vendor: "Prime Office Solutions", + vendorEmail: "billing@primeoffice.com", + amount: "$65,800.00 USD", + currency: "USD", + dueDate: "2026-05-28", + dueFormatted: "May 28, 2026", + documentDateFormatted: "Apr 9, 2026", + po: "PO-820044712", + paymentTerms: "Net 45 · USD", + billTo: "Global Enterprises Inc", + billAddress: "800 Corporate Center, Chicago IL 60601", + assignee: "Maria Chen", + assigneeInitials: "MC", + vat: "US-82-4471200", + description: + "Office furniture and ergonomic equipment for Chicago headquarters expansion. Covers 20 new workstations with chairs, standing desks, and monitor arms.", + exceptionTag: "High value", + exceptionTagStatus: "warning", + exceptionHeadline: "Office furniture order above auto-approval limit", + exceptionMetrics: [ + { label: "Amount", value: "$65,800", cls: "text-foreground" }, + { label: "Threshold", value: "$50,000", cls: "text-foreground" }, + { label: "Excess", value: "+$15,800", cls: "text-[#EF9F27]" }, + ], + exceptionBody: + "Invoice exceeds the $50,000 automated approval threshold. CFO or VP Finance sign-off is required before this payment can be processed.", + exceptionPrimaryAction: "Request approval", + exceptionSecondaryAction: "Reject invoice", + lines: [ + { + description: "Ergonomic desk chair", + qty: 20, + amount: "$38,000.00", + unitPrice: "$1,900.00", + }, + { + description: "Height-adjustable standing desk", + qty: 10, + amount: "$24,000.00", + unitPrice: "$2,400.00", + }, + { + description: "Monitor arm (dual)", + qty: 30, + amount: "$3,800.00", + unitPrice: "$126.67", + flag: "high value", + flagStatus: "warning", + }, + ], + linesTotal: "$65,800.00", + linesAlert: { + text: "Total exceeds $50,000 threshold — CFO approval required before payment.", + status: "warning", + }, + sourceFilename: "INV-66216.pdf", + sourceLines: [ + "INVOICE", + "Invoice #: INV-66216", + "Date: November 20, 2025", + "Due: December 15, 2025", + "---", + "From:", + "Prime Office Solutions", + "4400 Commerce Blvd", + "Atlanta, GA 30339", + "---", + "Bill To:", + "Global Enterprises Inc", + "800 Corporate Center", + "Chicago, IL 60601", + "---", + "Items:", + "Ergonomic desk chair × 20 · $38,000.00", + "Standing desk × 10 · $24,000.00", + "Monitor arm × 30 · $3,800.00", + "---", + "Total: $65,800.00", + ], + }, + "INV-84471": { + id: "INV-84471", + vendor: "Acme Supply Co.", + vendorEmail: "ar@acmesupply.co", + amount: "$12,240.00 USD", + currency: "USD", + dueDate: "2026-05-28", + dueFormatted: "May 28, 2026", + documentDateFormatted: "Apr 14, 2026", + po: "—", + paymentTerms: "Net 30 · USD", + billTo: "Lakewood Manufacturing", + billAddress: "1 Industrial Park Rd, Cleveland OH 44101", + assignee: "Peter Vachon", + assigneeInitials: "PV", + vat: "—", + description: + "Quarterly facility maintenance supplies for the Cleveland manufacturing plant, including janitorial consumables and floor cleaning equipment.", + exceptionTag: "Missing PO", + exceptionTagStatus: "error", + exceptionHeadline: "Facility supplies submitted without purchase order", + exceptionMetrics: [ + { label: "Invoiced", value: "$12,240", cls: "text-foreground" }, + { label: "POs matched", value: "0", cls: "text-[#C0392B]" }, + ], + exceptionBody: + "No purchase order found matching this invoice. Contact the supplier to confirm whether supplies were ordered against a PO, or reject and request a PO-backed resubmission.", + exceptionPrimaryAction: "Contact supplier", + exceptionSecondaryAction: "Reject invoice", + lines: [ + { + description: "Janitorial supply kit", + qty: 12, + amount: "$4,080.00", + unitPrice: "$340.00", + flag: "Missing PO", + flagStatus: "error", + }, + { + description: "Floor cleaning solution (5L)", + qty: 24, + amount: "$3,360.00", + unitPrice: "$140.00", + flag: "Missing PO", + flagStatus: "error", + }, + { + description: "Industrial waste bags (case)", + qty: 60, + amount: "$4,800.00", + unitPrice: "$80.00", + flag: "Missing PO", + flagStatus: "error", + }, + ], + linesTotal: "$12,240.00", + linesAlert: { + text: "No matching PO found — payment blocked until a valid PO is provided.", + status: "error", + }, + sourceFilename: "INV-84471.pdf", + sourceLines: [ + "INVOICE", + "Invoice #: INV-84471", + "Date: October 30, 2025", + "Due: November 28, 2025", + "---", + "From:", + "Acme Supply Co.", + "78 Warehouse Drive", + "Columbus, OH 43215", + "---", + "Bill To:", + "Lakewood Manufacturing", + "1 Industrial Park Rd", + "Cleveland, OH 44101", + "---", + "Items:", + "Janitorial supply kit × 12 · $4,080.00", + "Floor cleaning solution × 24 · $3,360.00", + "Industrial waste bags × 60 · $4,800.00", + "---", + "Total: $12,240.00", + ], + }, + "INV-77294": { + id: "INV-77294", + vendor: "Vertex Supplies Inc.", + vendorEmail: "ar@vertexsupplies.com", + amount: "$3,180.00 USD", + currency: "USD", + dueDate: "2026-05-29", + dueFormatted: "May 29, 2026", + documentDateFormatted: "Apr 13, 2026", + po: "PO-771140082", + paymentTerms: "Net 30 · USD", + billTo: "Global Enterprises Inc", + billAddress: "800 Corporate Center, Chicago IL 60601", + assignee: "Peter Vachon", + assigneeInitials: "PV", + vat: "—", + description: + "IT peripherals and cabling for the New York office. Same invoice number was submitted twice within three weeks.", + exceptionTag: "Duplicate flag", + exceptionTagStatus: "warning", + exceptionHeadline: "Possible duplicate — invoice number already paid", + exceptionMetrics: [ + { label: "This invoice", value: "$3,180", cls: "text-foreground" }, + { label: "Prior payment", value: "$3,180", cls: "text-foreground" }, + { label: "Match", value: "Exact", cls: "text-[#EF9F27]" }, + ], + exceptionBody: + "Invoice number INV-77294 matches a payment already processed on Apr 2, 2026. Confirm with the supplier whether this is a resubmission before approving, or reject as a duplicate.", + exceptionPrimaryAction: "Contact supplier", + exceptionSecondaryAction: "Reject invoice", + lines: [ + { + description: "USB-C docking station", + qty: 6, + amount: "$1,440.00", + unitPrice: "$240.00", + flag: "duplicate", + flagStatus: "warning", + }, + { + description: "CAT6 patch cable (3m, pack of 10)", + qty: 12, + amount: "$540.00", + unitPrice: "$45.00", + }, + { + description: "Wireless keyboard + mouse set", + qty: 20, + amount: "$1,200.00", + unitPrice: "$60.00", + }, + ], + linesTotal: "$3,180.00", + linesAlert: { + text: "Invoice number matches a payment processed on Apr 2, 2026.", + status: "warning", + }, + sourceFilename: "INV-77294.pdf", + sourceLines: [ + "INVOICE", + "Invoice #: INV-77294", + "Date: April 13, 2026", + "Due: May 13, 2026", + "---", + "From:", + "Vertex Supplies Inc.", + "210 Commerce Way", + "Newark, NJ 07102", + "---", + "Bill To:", + "Global Enterprises Inc", + "800 Corporate Center", + "Chicago, IL 60601", + "---", + "Items:", + "USB-C docking station × 6 · $1,440.00", + "CAT6 patch cable × 12 · $540.00", + "Wireless keyboard + mouse × 20 · $1,200.00", + "---", + "Total: $3,180.00", + ], + }, + "INV-55832": { + id: "INV-55832", + vendor: "Meridian Group", + vendorEmail: "billing@meridiangroup.eu", + amount: "€22,500.00 EUR", + currency: "EUR", + dueDate: "2026-05-29", + dueFormatted: "May 29, 2026", + documentDateFormatted: "Apr 13, 2026", + po: "PO-558120044", + paymentTerms: "Net 30 · EUR", + billTo: "Global Enterprises GmbH", + billAddress: "Friedrichstraße 88, 10117 Berlin", + assignee: "Maria Chen", + assigneeInitials: "MC", + vat: "DE-114299471", + description: + "Q2 legal retainer and litigation support for the EMEA entity, covering advisory hours, contract review, and filing fees.", + exceptionTag: "High value", + exceptionTagStatus: "warning", + exceptionHeadline: "Legal services invoice above department limit", + exceptionMetrics: [ + { label: "Amount", value: "€22,500", cls: "text-foreground" }, + { label: "Threshold", value: "€20,000", cls: "text-foreground" }, + { label: "Excess", value: "+€2,500", cls: "text-[#EF9F27]" }, + ], + exceptionBody: + "Invoice exceeds the €20,000 legal-department approval threshold. Department head sign-off is required before this payment can be processed.", + exceptionPrimaryAction: "Request approval", + exceptionSecondaryAction: "Reject invoice", + lines: [ + { + description: "Legal advisory retainer (Q2)", + qty: 1, + amount: "€12,000.00", + unitPrice: "€12,000.00", + }, + { + description: "Contract review — 32 hrs @ €250", + qty: 32, + amount: "€8,000.00", + unitPrice: "€250.00", + flag: "high value", + flagStatus: "warning", + }, + { + description: "Court filing fees", + qty: 1, + amount: "€2,500.00", + unitPrice: "€2,500.00", + }, + ], + linesTotal: "€22,500.00", + linesAlert: { + text: "Total exceeds €20,000 threshold — department head approval required.", + status: "warning", + }, + sourceFilename: "INV-55832.pdf", + sourceLines: [ + "INVOICE", + "Invoice #: INV-55832", + "Date: April 13, 2026", + "Due: May 13, 2026", + "---", + "From:", + "Meridian Group", + "Unter den Linden 21", + "10117 Berlin, Germany", + "---", + "Bill To:", + "Global Enterprises GmbH", + "Friedrichstraße 88", + "10117 Berlin", + "---", + "Items:", + "Legal advisory retainer (Q2) · €12,000.00", + "Contract review — 32 hrs · €8,000.00", + "Court filing fees · €2,500.00", + "---", + "Total: €22,500.00", + ], + }, + "INV-60118": { + id: "INV-60118", + vendor: "Crestwood Co.", + vendorEmail: "hello@crestwood.co", + amount: "$940.00 USD", + currency: "USD", + dueDate: "2026-05-29", + dueFormatted: "May 29, 2026", + documentDateFormatted: "Apr 13, 2026", + po: "—", + paymentTerms: "Due on receipt · USD", + billTo: "Global Enterprises Inc", + billAddress: "800 Corporate Center, Chicago IL 60601", + assignee: "Peter Vachon", + assigneeInitials: "PV", + vat: "—", + description: + "One-off purchase of branded notebooks and pens for a recruiting event. No purchase order was raised for this order.", + exceptionTag: "Missing PO", + exceptionTagStatus: "error", + exceptionHeadline: "One-off purchase submitted without a PO", + exceptionMetrics: [ + { label: "Invoiced", value: "$940", cls: "text-foreground" }, + { label: "POs matched", value: "0", cls: "text-[#C0392B]" }, + ], + exceptionBody: + "No purchase order is on file for this invoice. The amount is under the $1,000 one-off threshold — confirm the requester with the supplier, then approve under petty-cash or reject for a PO-backed resubmission.", + exceptionPrimaryAction: "Contact supplier", + exceptionSecondaryAction: "Reject invoice", + lines: [ + { + description: "Branded notebooks (A5)", + qty: 100, + amount: "$600.00", + unitPrice: "$6.00", + flag: "Missing PO", + flagStatus: "error", + }, + { + description: "Branded pens (box of 50)", + qty: 4, + amount: "$340.00", + unitPrice: "$85.00", + flag: "Missing PO", + flagStatus: "error", + }, + ], + linesTotal: "$940.00", + linesAlert: { + text: "No PO found — under $1,000, may qualify for one-off approval.", + status: "warning", + }, + sourceFilename: "INV-60118.pdf", + sourceLines: [ + "INVOICE", + "Invoice #: INV-60118", + "Date: April 13, 2026", + "Due: April 13, 2026", + "---", + "From:", + "Crestwood Co.", + "55 Market Street", + "Madison, WI 53703", + "---", + "Bill To:", + "Global Enterprises Inc", + "800 Corporate Center", + "Chicago, IL 60601", + "---", + "Items:", + "Branded notebooks (A5) × 100 · $600.00", + "Branded pens (box of 50) × 4 · $340.00", + "---", + "Total: $940.00", + ], + }, + "INV-91003": { + id: "INV-91003", + vendor: "NorthStar LLC", + vendorEmail: "invoices@northstarllc.co.uk", + amount: "£8,750.00 GBP", + currency: "GBP", + dueDate: "2026-05-28", + dueFormatted: "May 28, 2026", + documentDateFormatted: "Apr 21, 2026", + po: "PO-NL-20250093", + paymentTerms: "Net 60 · GBP", + billTo: "UiPath UK Ltd", + billAddress: "1 Knightsbridge, London SW1X 7LX", + assignee: "James Park", + assigneeInitials: "JP", + vat: "GB-294-8821-33", + description: + "Strategic advisory services for Q4 2025 product roadmap review and stakeholder alignment workshops, delivered by NorthStar LLC.", + exceptionTag: "High value", + exceptionTagStatus: "warning", + exceptionHeadline: "Consulting services invoice exceeds approval threshold", + exceptionMetrics: [ + { label: "Invoiced", value: "£8,750", cls: "text-foreground" }, + { label: "Threshold", value: "£5,000", cls: "text-foreground" }, + { label: "Excess", value: "+£3,750", cls: "text-[#EF9F27]" }, + ], + exceptionBody: + "Consulting invoice exceeds the £5,000 approval threshold for professional services. Department head sign-off is required per policy before payment can proceed.", + exceptionPrimaryAction: "Request sign-off", + exceptionSecondaryAction: "Approve", + lines: [ + { + description: "Strategic roadmap review (20h × £275)", + qty: 1, + amount: "£5,500.00", + unitPrice: "£5,500.00", + }, + { + description: "Stakeholder workshop facilitation", + qty: 1, + amount: "£3,250.00", + unitPrice: "£3,250.00", + flag: "high value", + flagStatus: "warning", + }, + ], + linesTotal: "£8,750.00", + linesAlert: { + text: "Total exceeds £5,000 consulting threshold — department head sign-off required.", + status: "warning", + }, + sourceFilename: "INV-91003.pdf", + sourceLines: [ + "INVOICE", + "Invoice #: INV-91003", + "Date: November 15, 2025", + "Due: December 31, 2025", + "---", + "From:", + "NorthStar LLC", + "22 Canary Wharf", + "London E14 5AB", + "---", + "Bill To:", + "UiPath UK Ltd", + "1 Knightsbridge", + "London SW1X 7LX", + "---", + "Items:", + "Strategic roadmap review × 1 · £5,500.00", + "Workshop facilitation × 1 · £3,250.00", + "---", + "Total: £8,750.00", + ], + }, + "INV-48209": { + id: "INV-48209", + vendor: "Folio Systems", + vendorEmail: "ar@foliosystems.com", + amount: "$7,620.00 USD", + currency: "USD", + dueDate: "2026-05-29", + dueFormatted: "May 29, 2026", + documentDateFormatted: "May 7, 2026", + po: "PO-820044891", + paymentTerms: "Net 30 · USD", + billTo: "UiPath Inc.", + billAddress: "2 Tower Place, South San Francisco, CA 94080", + assignee: "Peter Vachon", + assigneeInitials: "PV", + vat: "N/A", + description: + "Software licensing and implementation services for Folio Systems document management platform, Q2 2026.", + exceptionTag: "New vendor", + exceptionTagStatus: "info", + exceptionHeadline: "First invoice from unverified vendor", + exceptionMetrics: [ + { label: "Invoiced", value: "$7,620", cls: "text-foreground" }, + { label: "PO matched", value: "1", cls: "text-foreground" }, + ], + exceptionBody: + "Folio Systems is not in the approved vendor master. PO-820044891 was located and amounts match exactly. Approve to add vendor to master list and process payment, or reject to request procurement sign-off first.", + exceptionPrimaryAction: "Approve", + exceptionSecondaryAction: "Reject invoice", + lines: [ + { + description: "Document management platform license (annual)", + qty: 1, + amount: "$5,400.00", + unitPrice: "$5,400.00", + }, + { + description: "Implementation & onboarding services", + qty: 12, + amount: "$2,220.00", + unitPrice: "$185.00", + }, + ], + linesTotal: "$7,620.00", + sourceFilename: "INV-48209.pdf", + sourceLines: [ + "INVOICE", + "Invoice #: INV-48209", + "Date: May 7, 2026", + "Due: May 15, 2026", + "---", + "From:", + "Folio Systems", + "800 Technology Drive", + "Austin, TX 78701", + "---", + "Bill To:", + "UiPath Inc.", + "2 Tower Place", + "South San Francisco, CA 94080", + "---", + "PO: PO-820044891", + "---", + "Items:", + "Document mgmt license × 1 · $5,400.00", + "Implementation services × 12 · $2,220.00", + "---", + "Total: $7,620.00", + ], + }, +}; + +const commsDataMap: Record = {}; + +const InvoiceDetailContext = createContext( + detailDataMap["INV-GRN-001"] as InvoiceDetailData, +); +const useInvoiceDetail = () => useContext(InvoiceDetailContext); + +const invoicesReview: Invoice[] = [ + { + id: "INV-GRN-001", + vendor: "ACME Industrial", + amount: "$694", + tag: "Price mismatch", + tagType: "error", + score: 3, + dueGroup: "today", + }, + { + id: "INV-66216", + vendor: "Prime Office Solutions", + amount: "$65,800", + tag: "High value", + tagType: "warning", + score: 4, + dueGroup: "today", + }, + { + id: "INV-84471", + vendor: "Acme Supply Co.", + amount: "$12,240", + tag: "Missing PO", + tagType: "error", + score: 2, + dueGroup: "today", + }, + { + id: "INV-91003", + vendor: "NorthStar LLC", + amount: "£8,750", + tag: "High value", + tagType: "warning", + score: 4, + dueGroup: "today", + }, + { + id: "INV-77294", + vendor: "Vertex Supplies Inc.", + amount: "$3,180", + tag: "Duplicate flag", + tagType: "warning", + score: 3, + dueGroup: "tomorrow", + }, + { + id: "INV-55832", + vendor: "Meridian Group", + amount: "€22,500", + tag: "High value", + tagType: "warning", + score: 4, + dueGroup: "tomorrow", + }, + { + id: "INV-60118", + vendor: "Crestwood Co.", + amount: "$940", + tag: "Missing PO", + tagType: "error", + score: 2, + dueGroup: "tomorrow", + }, + { + id: "INV-48209", + vendor: "Folio Systems", + amount: "$7,620", + tag: "New vendor", + tagType: "info", + score: 4, + dueGroup: "tomorrow", + }, +]; + +const dueTodayInvoices = invoicesReview.filter( + (inv) => inv.dueGroup === "today", +); + +const invoicesAuto: Invoice[] = [ + { + id: "INV-39471", + vendor: "Delta Corp", + amount: "$4,100", + score: 5, + status: "done", + dueGroup: "auto", + }, + { + id: "INV-38820", + vendor: "Ironside Ltd", + amount: "$2,900", + score: 5, + status: "done", + dueGroup: "auto", + }, + { + id: "INV-38441", + vendor: "Bluewave Tech", + amount: "€1,850", + score: 5, + status: "done", + dueGroup: "auto", + }, + { + id: "INV-37990", + vendor: "Harmon & Associates", + amount: "$6,400", + score: 5, + status: "done", + dueGroup: "auto", + }, + { + id: "INV-36884", + vendor: "Summit Procurement", + amount: "$14,100", + score: 5, + status: "done", + dueGroup: "auto", + }, + { + id: "INV-36502", + vendor: "Clearpath Solutions", + amount: "€920", + score: 5, + status: "done", + dueGroup: "auto", + }, +]; + +const invoiceTableData: InvoiceTableRow[] = [ + { + id: "INV-GRN-001", + vendor: "ACME Industrial", + amount: 694, + currency: "USD", + dueDate: "2026-05-28", + exception: "price-mismatch", + score: 3, + status: "pending-review", + assignee: "Peter Vachon", + }, + { + id: "INV-66216", + vendor: "Prime Office Solutions", + amount: 65800, + currency: "USD", + dueDate: "2026-05-28", + exception: "high-value", + score: 4, + status: "in-review", + assignee: "Maria Chen", + }, + { + id: "INV-84471", + vendor: "Acme Supply Co.", + amount: 12240, + currency: "USD", + dueDate: "2026-05-28", + exception: "no-po-match", + score: 2, + status: "pending-review", + assignee: "Peter Vachon", + }, + { + id: "INV-91003", + vendor: "NorthStar LLC", + amount: 8750, + currency: "GBP", + dueDate: "2026-05-28", + exception: "high-value", + score: 4, + status: "in-review", + assignee: "James Park", + }, + { + id: "INV-77294", + vendor: "Vertex Supplies Inc.", + amount: 3180, + currency: "USD", + dueDate: "2026-05-29", + exception: "duplicate", + score: 3, + status: "pending-review", + assignee: "Maria Chen", + }, + { + id: "INV-55832", + vendor: "Meridian Group", + amount: 22500, + currency: "EUR", + dueDate: "2026-05-29", + exception: "high-value", + score: 4, + status: "in-review", + assignee: "Peter Vachon", + }, + { + id: "INV-60118", + vendor: "Crestwood Co.", + amount: 940, + currency: "USD", + dueDate: "2026-05-29", + exception: "missing-po", + score: 2, + status: "pending-review", + assignee: "James Park", + }, + { + id: "INV-48209", + vendor: "Folio Systems", + amount: 7620, + currency: "USD", + dueDate: "2026-05-29", + exception: "new-vendor", + score: 4, + status: "pending-review", + assignee: "Peter Vachon", + }, + { + id: "INV-22045", + vendor: "Starlight Corp", + amount: 18400, + currency: "CAD", + dueDate: "2026-05-08", + exception: "none", + score: 5, + status: "approved", + assignee: "Peter Vachon", + }, + { + id: "INV-23801", + vendor: "TechForce Ltd", + amount: 5250, + currency: "USD", + dueDate: "2026-05-08", + exception: "high-value", + score: 4, + status: "in-review", + assignee: "Maria Chen", + }, + { + id: "INV-24990", + vendor: "Evergreen Partners", + amount: 320, + currency: "EUR", + dueDate: "2026-05-09", + exception: "none", + score: 5, + status: "approved", + assignee: "James Park", + }, + { + id: "INV-25114", + vendor: "Cascade Solutions", + amount: 44200, + currency: "USD", + dueDate: "2026-05-09", + exception: "high-value", + score: 3, + status: "pending-review", + assignee: "Peter Vachon", + }, + { + id: "INV-26332", + vendor: "Parity Group", + amount: 1880, + currency: "GBP", + dueDate: "2026-05-10", + exception: "duplicate", + score: 2, + status: "rejected", + assignee: "Maria Chen", + }, + { + id: "INV-27009", + vendor: "Apex Distributors", + amount: 9700, + currency: "USD", + dueDate: "2026-05-10", + exception: "no-po-match", + score: 3, + status: "in-review", + assignee: "James Park", + }, + { + id: "INV-28441", + vendor: "Summit Logistics", + amount: 3400, + currency: "CAD", + dueDate: "2026-05-11", + exception: "none", + score: 5, + status: "approved", + assignee: "Peter Vachon", + }, + { + id: "INV-29553", + vendor: "Waveline Inc.", + amount: 16300, + currency: "USD", + dueDate: "2026-05-11", + exception: "price-mismatch", + score: 3, + status: "pending-review", + assignee: "Maria Chen", + }, + { + id: "INV-30012", + vendor: "Falcon Procurement", + amount: 7100, + currency: "EUR", + dueDate: "2026-05-12", + exception: "missing-po", + score: 2, + status: "pending-review", + assignee: "James Park", + }, + { + id: "INV-31887", + vendor: "Ironclad Services", + amount: 51000, + currency: "USD", + dueDate: "2026-05-12", + exception: "high-value", + score: 4, + status: "in-review", + assignee: "Peter Vachon", + }, + { + id: "INV-32240", + vendor: "Cobalt Industries", + amount: 2200, + currency: "GBP", + dueDate: "2026-05-13", + exception: "new-vendor", + score: 3, + status: "in-review", + assignee: "Maria Chen", + }, + { + id: "INV-33905", + vendor: "Lighthouse LLC", + amount: 890, + currency: "USD", + dueDate: "2026-05-13", + exception: "none", + score: 5, + status: "approved", + assignee: "James Park", + }, +]; + +type RightTab = "details" | "lines" | "source" | "comms" | "activity" | "email"; + +const BETWEEN_INVOICE_STYLES = ` + @keyframes inv-between-enter { + from { opacity: 0; transform: translateY(24px); } + to { opacity: 1; transform: translateY(0); } + } + .inv-between-enter { + animation: inv-between-enter 180ms cubic-bezier(0.25, 0.46, 0.45, 0.94) both; + } + @keyframes inv-between-exit { + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(-40px); } + } + .inv-between-exit { + animation: inv-between-exit 130ms cubic-bezier(0.4, 0, 1, 1) both; + pointer-events: none; + } + @keyframes detail-slide-in { + from { opacity: 0; transform: translateX(24px); } + to { opacity: 1; transform: translateX(0); } + } + .detail-slide-in { + animation: detail-slide-in 190ms cubic-bezier(0.4, 0, 0.2, 1) 40ms both; + } + @keyframes skeleton-content-enter { + from { opacity: 0; } + to { opacity: 1; } + } + .skeleton-content-enter { + animation: skeleton-content-enter 180ms ease-out both; + } + @keyframes inv-glow-in { + from { opacity: 0; } + to { opacity: 0.04; } + } + .inv-glow-in { + animation: inv-glow-in 480ms ease-out 320ms both; + } + @keyframes fadeSlideIn { + from { transform: translateY(-6px); } + to { transform: translateY(0); } + } + .entry-new { + animation: fadeSlideIn 150ms ease forwards; + } + @keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } + } + .ai-panel-glow { + background: linear-gradient( + to left, + oklch(0.68 0.18 285 / 0.04) 0%, + transparent 100% + ); + } + .dark .ai-panel-glow { + background: linear-gradient( + to left, + oklch(0.68 0.18 285 / 0.06) 0%, + transparent 100% + ); + } + /* AI gradient stops for the active Findings tab — darker on light bg, + lighter on dark bg for accessibility. */ + :root { + --findings-ai-start: #5A4ACD; + --findings-ai-end: #2E9DB7; + } + .dark { + --findings-ai-start: #9583F2; + --findings-ai-end: #7FD1E5; + } + .findings-ai-gradient { + background-image: linear-gradient( + 97.73deg, + var(--findings-ai-start) 8.79%, + var(--findings-ai-end) 91.48% + ); + } +`; + +// ── Primitives ──────────────────────────────────────────────────────────────── + +function avatarBg(name: string): string { + let h = 0; + for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0; + const hues = [250, 180, 140, 290, 30, 200]; + return `oklch(0.50 0.14 ${hues[h % hues.length]})`; +} + +// The current logged-in reviewer — matches the real Slack user driving the demo. +const REVIEWER_NAME = "Peter Vachon"; +const REVIEWER_INITIALS = "PV"; +const REVIEWER_AVATAR = "/peter-vachon.jpg"; +// Publicly reachable avatar for Slack — chat:write.customize fetches icon_url +// from Slack's servers, so the bundled local /peter-vachon.jpg won't render +// in the thread. If the reviewer ever changes, swap this for their public URL. +const REVIEWER_AVATAR_PUBLIC = + "https://ca.slack-edge.com/EJB4CMA2H-U09A4BKS7N0-00c173f481f1-512"; + +// Slack brand mark — inline SVG of the four-color logo (pink/blue/green/ +// yellow), used wherever the UI attributes content to Slack as a source. +// Inlined (rather than lucide-react's stylized `Slack`, which loses the four +// shapes at small sizes) so leadership recognizes Slack at a glance. Default +// 14px; the four shapes stay distinct down to ~12px. +function SlackBrandIcon({ className }: { className?: string }) { + return ( + + ); +} + +// Microsoft Outlook brand mark — used in the Comms card header when an +// email was sent via Outlook (currently: every "Contact supplier" send). +// Inline SVG keeps the recognizable blue-on-white envelope + "O" at small +// sizes without pulling in a dep. +function OutlookBrandIcon({ className }: { className?: string }) { + return ( + + ); +} + +function ScoreBar({ + passed, + failed, + skipped, +}: { + passed: number; + failed: number; + skipped: number; +}) { + const segments: Array<"pass" | "fail" | "skip"> = [ + ...Array<"pass">(passed).fill("pass"), + ...Array<"fail">(failed).fill("fail"), + ...Array<"skip">(skipped).fill("skip"), + ]; + return ( +
+ {segments.map((type, i) => ( +
+ ))} +
+ ); +} + +function formatAmount( + amount: number, + currency: InvoiceTableRow["currency"], +): string { + return new Intl.NumberFormat("en-US", { + style: "currency", + currency, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(amount); +} + +const exceptionBadgeMap: Record< + ExceptionType, + { label: string; status: "error" | "warning" | "info" | null } +> = { + "price-mismatch": { label: "Price mismatch", status: "error" }, + "high-value": { label: "High value", status: "warning" }, + "no-po-match": { label: "Missing PO", status: "error" }, + duplicate: { label: "Duplicate", status: "warning" }, + "missing-po": { label: "Missing PO", status: "error" }, + "new-vendor": { label: "New vendor", status: "info" }, + none: { label: "—", status: null }, +}; + +const statusBadgeMap: Record< + InvoiceStatus, + { label: string; status: "info" | "warning" | "success" | "error" } +> = { + "pending-review": { label: "Pending review", status: "warning" }, + "in-review": { label: "In review", status: "info" }, + approved: { label: "Approved", status: "success" }, + rejected: { label: "Rejected", status: "error" }, + "sent-for-approval": { label: "Sent for approval", status: "info" }, + flagged: { label: "Flagged", status: "warning" }, + "on-hold": { label: "On hold", status: "warning" }, +}; + +const timeFilterOptions = [ + { label: "Last 7 days", value: "7d" }, + { label: "Last 30 days", value: "30d" }, + { label: "Last 90 days", value: "90d" }, + { label: "This year", value: "1y" }, +]; + +const exceptionFilterOptions = [ + { label: "Price mismatch", value: "price-mismatch" }, + { label: "High value", value: "high-value" }, + { label: "Missing PO", value: "no-po-match" }, + { label: "Duplicate", value: "duplicate" }, + { label: "Missing PO", value: "missing-po" }, + { label: "New vendor", value: "new-vendor" }, + { label: "None", value: "none" }, +]; + +const statusFilterOptions = [ + { label: "Pending review", value: "pending-review" }, + { label: "In review", value: "in-review" }, + { label: "Approved", value: "approved" }, + { label: "Rejected", value: "rejected" }, +]; + +const exceptionPriority: Partial> = { + "no-po-match": 2, + "missing-po": 2, + duplicate: 2, + "price-mismatch": 1, + "high-value": 1, + "new-vendor": 1, +}; + +const invoiceColumns: ColumnDef[] = [ + { + accessorKey: "id", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + {row.getValue("id")} + ), + }, + { + accessorKey: "vendor", + header: ({ column }) => ( + + ), + }, + { + accessorKey: "amount", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( + + {formatAmount(row.getValue("amount"), row.original.currency)} + + ), + }, + { + accessorKey: "dueDate", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const iso = row.getValue("dueDate"); + const date = new Date(`${iso}T00:00:00`); + const formatted = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }).format(date); + return {formatted}; + }, + }, + { + accessorKey: "exception", + header: ({ column }) => ( + + ), + // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- filterFn is Row but compatible at runtime + filterFn: dataTableFacetedFilterFn as FilterFn, + cell: ({ row }) => { + const ex = row.getValue("exception"); + const map = exceptionBadgeMap[ex]; + if (map.status === null) + return 5/5; + return ( + + {map.label} + + ); + }, + }, + { + accessorKey: "status", + header: ({ column }) => ( + + ), + // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- filterFn is Row but compatible at runtime + filterFn: dataTableFacetedFilterFn as FilterFn, + cell: ({ row }) => { + const s = row.getValue("status"); + const map = statusBadgeMap[s]; + return ( + + {map.label} + + ); + }, + }, + { + accessorKey: "assignee", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const name = row.getValue("assignee"); + // Reviewer (Peter) gets the photo avatar; others fall back to colored + // initials so the list still reads at a glance. + const isReviewer = name === REVIEWER_NAME; + const parts = name.trim().split(/\s+/).filter(Boolean); + const initials = ( + parts.length >= 2 + ? parts[0][0] + parts[parts.length - 1][0] + : name.slice(0, 2) + ).toUpperCase(); + return ( +
+ + {isReviewer && } + + {initials} + + + {name} +
+ ); + }, + }, +]; + +function AvatarChip({ type }: { type: "ai-pass" | "ai-fail" | "user" }) { + const isAI = type === "ai-pass" || type === "ai-fail"; + if (type === "user") { + return ( + + + + {REVIEWER_INITIALS} + + + ); + } + return ( +
+ +
+ ); +} + +// ── DetailSkeleton ──────────────────────────────────────────────────────────── + +function DetailSkeleton() { + return ( + <> + {/* TopBar skeleton */} +
+ +
+ + + + +
+
+ {/* Activity bar skeleton */} +
+ +
+ {/* Content + right panel skeleton */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+
+
+ + + +
+
+ + + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + ); +} + +// ── InvoiceListView ─────────────────────────────────────────────────────────── + +const LIST_COLUMN_ORDER = [ + "id", + "vendor", + "amount", + "dueDate", + "exception", + "status", + "assignee", +]; +const LIST_VISIBLE_COLUMNS = [ + "id", + "vendor", + "amount", + "dueDate", + "exception", + "status", + "assignee", +]; + +type CardFilterKey = + | "due-today" + | "pending-review" + | "exceptions" + | "auto-approved" + | null; + +function InvoiceListView({ + onRowClick, + completionMap, + parkedMap, +}: { + onRowClick: (id: string) => void; + completionMap: Record; + parkedMap: Record; +}) { + const [timeRange, setTimeRange] = useState("30d"); + const [cardFilter, setCardFilter] = useState(null); + + // Reflect live actions (approve/reject/flag/hold) in each row's status. + const liveData = useMemo( + () => + invoiceTableData.map((r) => { + const c = completionMap[r.id]; + if (c) { + return { + ...r, + status: (c.type === "approved" + ? "approved" + : "rejected") as InvoiceStatus, + }; + } + const p = parkedMap[r.id]; + if (p) { + return { + ...r, + status: (p.kind === "hold" + ? "on-hold" + : "flagged") as InvoiceStatus, + }; + } + return r; + }), + [completionMap, parkedMap], + ); + + const sortedData = useMemo(() => { + return [...liveData].sort((a, b) => { + const pa = exceptionPriority[a.exception] ?? 0; + const pb = exceptionPriority[b.exception] ?? 0; + if (pa !== pb) return pb - pa; + return a.score - b.score; + }); + }, [liveData]); + + // Demo "today" is pinned so the filter and the queue's "Due today" bucket + // line up regardless of the machine's actual date — the seed data is dated + // May 28–29, 2026 to keep dates close to the May 2026 invoice records. + const todayISO = "2026-05-28"; + + const filteredData = useMemo(() => { + switch (cardFilter) { + case "due-today": + return sortedData.filter((r) => r.dueDate === todayISO); + case "pending-review": + return sortedData.filter( + (r) => r.status === "pending-review" || r.status === "in-review", + ); + case "exceptions": + return sortedData.filter((r) => r.exception !== "none"); + case "auto-approved": + return sortedData.filter((r) => r.status === "approved"); + default: + return sortedData; + } + }, [sortedData, cardFilter, todayISO]); + + const tableState = useDataTable({ + data: filteredData, + columns: invoiceColumns, + storageKey: "invoice-review-list-v6", + defaultColumnOrder: LIST_COLUMN_ORDER, + defaultVisibleColumns: LIST_VISIBLE_COLUMNS, + }); + + const pendingCount = invoiceTableData.filter( + (r) => r.status === "pending-review" || r.status === "in-review", + ).length; + const dueTodayCount = invoiceTableData.filter( + (r) => r.dueDate === todayISO, + ).length; + const exceptCount = invoiceTableData.filter( + (r) => r.exception !== "none", + ).length; + const autoCount = invoicesAuto.length; + + // oxlint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- filterFn is Row but compatible at runtime + const typedGlobalFilterFn = + dataTableGlobalFilterFn as FilterFn; + + function getUrgencyClass(row: InvoiceTableRow): string { + if (row.score === 5) return "opacity-40"; + return ""; + } + + function toggleCard(key: NonNullable) { + setCardFilter((prev) => (prev === key ? null : key)); + tableState.onPaginationChange((prev) => ({ ...prev, pageIndex: 0 })); + } + + return ( +
+ + + Invoices + + Updated 1 minute ago + + + + { + if (typeof v === "string") setTimeRange(v); + }} + /> + + + + +
+ {/* Four metric cards */} +
+ {/* Due today */} + toggleCard("due-today")} + > + +
+

Due today

+ {cardFilter === "due-today" && ( +

+ ✕ Clear +

+ )} +
+

+ {dueTodayCount} +

+

+ of {invoiceTableData.length} total +

+
+
+ + {/* Pending review */} + toggleCard("pending-review")} + > + +
+

Pending review

+ {cardFilter === "pending-review" && ( +

+ ✕ Clear +

+ )} +
+

+ {pendingCount} +

+

+ need a decision +

+
+
+ + {/* Exceptions flagged */} + toggleCard("exceptions")} + > + +
+

+ Exceptions flagged +

+ {cardFilter === "exceptions" && ( +

+ ✕ Clear +

+ )} +
+

+ {exceptCount} +

+

+ agent identified +

+
+
+ + {/* Auto-approved */} + toggleCard("auto-approved")} + > + +
+

Auto-approved

+ {cardFilter === "auto-approved" && ( +

+ ✕ Clear +

+ )} +
+

+ {autoCount} +

+

+ no action needed +

+
+
+
+ + onRowClick(row.id)} + getRowClassName={getUrgencyClass} + toolbarContent={(table) => ( + <> + + + + )} + /> +
+
+ ); +} + +// ── LeftNav ─────────────────────────────────────────────────────────────────── + +function NavSectionLabel({ + label, + count, + first = false, +}: { + label: string; + count?: number; + first?: boolean; +}) { + return ( +
+ + {label} + {count !== undefined && ({count})} + +
+ ); +} + +function NavInvoiceItem({ + invoice, + isActive, + onClick, + completion, + parked, + contacted = false, +}: { + invoice: Invoice; + isActive: boolean; + onClick: () => void; + completion?: CompletionRecord; + parked?: ParkedState; + // True once the reviewer has sent a "Contact supplier" email for this + // invoice — shown as a secondary status in the queue card so the open + // action is visible without opening the invoice. + contacted?: boolean; +}) { + const isAuto = invoice.status === "done"; + const isCompleted = !!completion; + // Parked (flag/hold) only applies when not already approved/rejected. + const isParked = !isCompleted && !!parked; + + const dotColor = isCompleted + ? completion.type === "approved" + ? "bg-success" + : "bg-destructive" + : isParked + ? "bg-warning" + : isAuto + ? "bg-success" + : invoice.tagType === "error" + ? "bg-destructive" + : invoice.tagType === "warning" + ? "bg-warning" + : invoice.tagType === "info" + ? "bg-info" + : "bg-muted-foreground"; + + const tagLabel = isCompleted + ? completion.type === "approved" + ? "Approved" + : "Rejected" + : isParked + ? parked?.kind === "hold" + ? "On hold" + : "Flagged" + : isAuto + ? "Done" + : invoice.tag; + + return ( + + ); +} + +function LeftNav({ + activeId, + onInvoiceClick, + onBack, + completionMap, + parkedMap, + contactedMap, + onPrev, + onNext, + hasPrev, + hasNext, + position, + total, +}: { + activeId: string; + onInvoiceClick: (id: string) => void; + onBack: () => void; + completionMap: Record; + parkedMap: Record; + contactedMap: Record; + onPrev: () => void; + onNext: () => void; + hasPrev: boolean; + hasNext: boolean; + position: number; + total: number; +}) { + // Approved/rejected invoices move out of their due-date section into + // "Completed". Flagged/held stay put (still in the active queue). + const isDone = (id: string) => !!completionMap[id]; + const dueToday = dueTodayInvoices.filter((inv) => !isDone(inv.id)); + const dueTomorrow = invoicesReview.filter( + (inv) => inv.dueGroup === "tomorrow" && !isDone(inv.id), + ); + const completed = invoicesReview.filter((inv) => isDone(inv.id)); + + return ( +
+
+
+ + + My queue{" "} + + ({invoicesReview.length}) + + +
+ +
+ {dueToday.length > 0 && ( + <> + +
+ {dueToday.map((inv) => ( + onInvoiceClick(inv.id)} + completion={completionMap[inv.id]} + parked={parkedMap[inv.id]} + contacted={contactedMap[inv.id]} + /> + ))} +
+ + )} + + {dueTomorrow.length > 0 && ( + <> + +
+ {dueTomorrow.map((inv) => ( + onInvoiceClick(inv.id)} + completion={completionMap[inv.id]} + parked={parkedMap[inv.id]} + contacted={contactedMap[inv.id]} + /> + ))} +
+ + )} + + {completed.length > 0 && ( + <> + +
+ {completed.map((inv) => ( + onInvoiceClick(inv.id)} + completion={completionMap[inv.id]} + parked={parkedMap[inv.id]} + contacted={contactedMap[inv.id]} + /> + ))} +
+ + )} + + +
+ {invoicesAuto.map((inv) => ( + onInvoiceClick(inv.id)} + /> + ))} +
+
+ + {/* Prev / Next footer */} +
+ + + {position} of {total} + + +
+
+ ); +} + +// ── TopBar ──────────────────────────────────────────────────────────────────── + +function TopBar({ + flagged, + held, + completion, +}: { + flagged: boolean; + held?: boolean; + completion?: CompletionRecord; +}) { + const d = useInvoiceDetail(); + // Mirror the list view's Status column: row's data status, overridden by + // live completion/parked state so the header label stays in sync. + const tableRow = invoiceTableData.find((r) => r.id === d.id); + const baseStatus: InvoiceStatus = tableRow?.status ?? "pending-review"; + const effectiveStatus: InvoiceStatus = completion + ? completion.type === "approved" + ? "approved" + : "rejected" + : held + ? "on-hold" + : flagged + ? "flagged" + : baseStatus; + const statusInfo = statusBadgeMap[effectiveStatus]; + return ( + + + + + + {d.id} + + + + {d.vendor} + + + + + Amount + {d.amount} + + + Due + {d.dueFormatted} + + + PO + + {!d.po || d.po === "—" ? ( + + Missing PO + + ) : ( + d.po + )} + + + + Status + + + {statusInfo.label} + + + + + Assignee + + + {d.assignee === REVIEWER_NAME && ( + + )} + + {d.assigneeInitials[0]} + + + {d.assignee} + + + + + ); +} + +// ── AISummaryBar + AISummaryExpanded ───────────────────────────────────────── + +type ActivityBarProps = { + expanded: boolean; + onToggle: () => void; + emailSent: boolean; + minimal?: boolean; +}; + +// A — Status Badge pills + labelled expand +function ActivityBarA({ expanded, onToggle }: ActivityBarProps) { + return ( +
{ + if (e.key === "Enter" || e.key === " ") onToggle(); + }} + className="flex-1 flex items-center gap-2.5 px-4 sm:px-6 lg:px-8 hover:bg-muted/40 transition-colors text-left min-w-0 cursor-default" + > + + 3 passed + + + 1 failed + +
+
+ + + +
+ + Opened 4m ago + +
+
+ {expanded ? "Hide" : "View"} activity + +
+
+
+ ); +} + +// B — Colored numbers + labeled expand pill +function ActivityBarB({ expanded, onToggle }: ActivityBarProps) { + return ( +
{ + if (e.key === "Enter" || e.key === " ") onToggle(); + }} + className="flex-1 flex items-center gap-3 px-4 sm:px-6 lg:px-8 hover:bg-muted/40 transition-colors text-left min-w-0 cursor-default" + > +
+ 3 + passed +
+
+ 1 + failed +
+
+ + AI review complete · 1 exception · opened 4m ago + +
+
+ Checks + +
+
+
+ ); +} + +// C — Inline named check steps with dots, responsive condensing +function ActivityBarC({ + expanded, + onToggle, + emailSent, + minimal, +}: ActivityBarProps) { + const barRef = useRef(null); + const [barWidth, setBarWidth] = useState(9999); + + useEffect(() => { + const el = barRef.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => { + setBarWidth(entry.contentRect.width); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const showFull = barWidth > 640; + const showMeta = !minimal && barWidth > 480; + + const baseChecks = [ + { label: "Extracted", status: "pass" as const }, + { label: "Vendor", status: "pass" as const }, + { label: "Duplicate", status: "pass" as const }, + { label: "Price", status: "fail" as const }, + { label: "Lines", status: "skip" as const }, + ]; + const checks = emailSent + ? [...baseChecks, { label: "Contacted", status: "actioned" as const }] + : baseChecks; + + const passCount = baseChecks.filter((c) => c.status === "pass").length; + const failCount = baseChecks.filter((c) => c.status === "fail").length; + + return ( +
{ + if (e.key === "Enter" || e.key === " ") onToggle(); + }, + })} + className={cn( + "flex-1 flex items-center gap-3 px-4 sm:px-6 lg:px-8 transition-colors text-left min-w-0 cursor-default", + !minimal && "hover:bg-muted/40", + )} + > + {showFull ? ( +
+ {checks.map((check) => ( +
+
+ + {check.label} + +
+ ))} +
+ ) : ( +
+
+ + {passCount} + + passed +
+ {failCount > 0 && ( +
+ + {failCount} + + failed +
+ )} +
+ )} + + {showMeta && ( +
+ )} + {showMeta && ( + <> +
+ + + +
+ 4m ago + + )} + + {!minimal && ( +
+ {expanded ? "Hide" : "View"} activity + +
+ )} +
+ ); +} + +function AISummaryBar(props: ActivityBarProps) { + return ; +} + +const activityChecks = [ + { + status: "pass" as const, + label: "Data extracted", + desc: "All fields parsed successfully.", + }, + { + status: "pass" as const, + label: "Vendor matched", + desc: "ACME Industrial confirmed.", + }, + { + status: "pass" as const, + label: "No duplicate", + desc: "Clean in last 90 days.", + }, + { + status: "fail" as const, + label: "Price mismatch", + desc: "$694.39 vs PO $689.55 (+$4.84, 0.7% over 0.5% tolerance). Could not auto-resolve.", + }, + { + status: "skip" as const, + label: "Line items", + desc: "Skipped — halted at price check.", + }, +]; + +const activityLog = [ + { + chip: "ai-pass" as const, + text: "Agent reviewed & escalated", + time: "Oct 5 · 9:42am", + }, + { + chip: "user" as const, + text: "Assigned & opened", + time: "9:50am · 10:04am", + }, +]; + +function ActivityLogRows() { + return ( + <> + {activityLog.map((row) => ( +
+ + {row.text} + + {row.time} + +
+ ))} +
+
+ Awaiting decision +
+ + ); +} + +// Expanded A — Badge pills per check, activity footer +function ExpandedA() { + return ( +
+
+ {activityChecks.map((item) => ( +
+ + {item.status === "pass" + ? "Pass" + : item.status === "fail" + ? "Fail" + : "Skip"} + +
+ {item.label} + — {item.desc} +
+
+ ))} +
+
+ +
+
+ ); +} + +// Expanded B — Progress strip + table rows +function ExpandedB() { + return ( +
+
+
+
+
+ + 3 of 5 checks passed + +
+
+ {activityChecks.map((item) => ( +
+ + {item.status === "pass" + ? "OK" + : item.status === "fail" + ? "ERR" + : "—"} + + {item.label} + + {item.desc} + +
+ ))} +
+
+ +
+
+ ); +} + +// ── Center content ──────────────────────────────────────────────────────────── + +function MetricsRow({ className }: { className?: string }) { + const { exceptionMetrics } = useInvoiceDetail(); + return ( +
+ {exceptionMetrics.map((col, i) => ( +
0 && "border-l border-border pl-4 ml-4", + )} + > + {col.label} + + {col.value} + +
+ ))} +
+ ); +} + +function generateDraftBody(data: InvoiceDetailData): string { + const lineDesc = data.lines[0]?.description ?? "the invoiced item"; + const invoiced = data.lines[0]?.amount ?? data.amount; + const agreed = data.lines[0]?.agreed; + const agreedLine = agreed + ? `However, per Purchase Order ${data.po}, the agreed price is ${agreed}.\n\nIt appears the negotiated discount was not applied to this invoice. We kindly ask you to provide a corrected invoice reflecting the agreed price of ${agreed}.` + : data.exceptionBody; + return `Dear Accounts team,\n\nWe are writing regarding Invoice ${data.id}. Upon review, we noticed that the line item "${lineDesc}" is listed at ${invoiced}. ${agreedLine}\n\nThank you for your prompt attention to this matter.\n\nKind regards,\n[Your name]`; +} + +const AI_REWRITES = [ + "Make formal", + "Make concise", + "Add detail", + "Simplify", +] as const; + +function EmailComposer({ + onClose, + onSend, +}: { + onClose: () => void; + onSend: (email: SentEmail) => void; +}) { + const data = useInvoiceDetail(); + const [to, setTo] = useState(data.vendorEmail); + const [cc, setCc] = useState(""); + const [showCc, setShowCc] = useState(false); + const [subject, setSubject] = useState( + `Invoice correction request — Invoice ${data.id}`, + ); + const [body, setBody] = useState(() => generateDraftBody(data)); + const [sending, setSending] = useState(false); + + return ( +
+ {/* Header */} +
+
+
+ +

Draft Email

+
+

+ Review and edit the email below before sending to {data.vendor}. +

+
+ +
+ + {/* To / CC / Subject fields */} +
+
+ + To: + + setTo(e.target.value)} + className="flex-1 bg-muted/30 rounded-md px-3 py-1.5 text-sm border border-border focus:outline-none focus:ring-1 focus:ring-ring min-w-0" + /> + {!showCc && ( + + )} +
+ {showCc && ( +
+ + CC: + + setCc(e.target.value)} + placeholder="Add CC recipients…" + className="flex-1 bg-muted/30 rounded-md px-3 py-1.5 text-sm border border-border focus:outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/40 min-w-0" + /> +
+ )} +
+ + Subject: + + setSubject(e.target.value)} + className="flex-1 bg-muted/30 rounded-md px-3 py-1.5 text-sm border border-border focus:outline-none focus:ring-1 focus:ring-ring min-w-0" + /> +
+
+ + {/* AI rewrite toolbar */} +
+ + + AI rewrite: + + {AI_REWRITES.map((action) => ( + + ))} +
+ + {/* Body */} +
+