From 55cdd5a0e9d785aaff768b6d1f1912808b3daa5a Mon Sep 17 00:00:00 2001 From: Peter Vachon Date: Wed, 1 Jul 2026 11:20:00 -0400 Subject: [PATCH 1/5] feat(apollo-vertex): invoice processing demo with Slack + Outlook flow Invoice review template, demo API routes, and the Slack escalation server. Rebased onto latest main; prior prototype history (8 commits, incl. a merge) squashed into a single commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/apollo-vertex/.env.example | 20 + apps/apollo-vertex/.gitignore | 7 + apps/apollo-vertex/.oxlintrc.json | 5 +- apps/apollo-vertex/app/_meta.ts | 3 + .../apollo-vertex/app/api/demo-reply/route.ts | 34 + .../apollo-vertex/app/api/demo-state/route.ts | 18 + .../app/api/demo-trigger/route.ts | 37 + .../apollo-vertex/app/invoice-review/page.tsx | 10 + apps/apollo-vertex/lib/i18n.ts | 4 + apps/apollo-vertex/locales/en.json | 1 + apps/apollo-vertex/package.json | 2 + apps/apollo-vertex/public/peter-vachon.jpg | Bin 0 -> 68093 bytes apps/apollo-vertex/registry.json | 20 + apps/apollo-vertex/registry/button/button.tsx | 2 +- apps/apollo-vertex/slack/README.md | 120 + apps/apollo-vertex/slack/escalation-card.js | 113 + apps/apollo-vertex/slack/package.json | 16 + apps/apollo-vertex/slack/reset-demo.js | 94 + apps/apollo-vertex/slack/server.js | 388 + apps/apollo-vertex/slack/store.js | 155 + .../invoice-review/InvoiceReviewTemplate.tsx | 6318 +++++++++++++++++ .../templates/invoice-review/README.md | 34 + package.json | 2 + 23 files changed, 7401 insertions(+), 2 deletions(-) create mode 100644 apps/apollo-vertex/.env.example create mode 100644 apps/apollo-vertex/app/api/demo-reply/route.ts create mode 100644 apps/apollo-vertex/app/api/demo-state/route.ts create mode 100644 apps/apollo-vertex/app/api/demo-trigger/route.ts create mode 100644 apps/apollo-vertex/app/invoice-review/page.tsx create mode 100644 apps/apollo-vertex/public/peter-vachon.jpg create mode 100644 apps/apollo-vertex/slack/README.md create mode 100644 apps/apollo-vertex/slack/escalation-card.js create mode 100644 apps/apollo-vertex/slack/package.json create mode 100644 apps/apollo-vertex/slack/reset-demo.js create mode 100644 apps/apollo-vertex/slack/server.js create mode 100644 apps/apollo-vertex/slack/store.js create mode 100644 apps/apollo-vertex/templates/invoice-review/InvoiceReviewTemplate.tsx create mode 100644 apps/apollo-vertex/templates/invoice-review/README.md 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/.oxlintrc.json b/apps/apollo-vertex/.oxlintrc.json index 8c0bbc328..04779e516 100644 --- a/apps/apollo-vertex/.oxlintrc.json +++ b/apps/apollo-vertex/.oxlintrc.json @@ -93,7 +93,10 @@ ".dependency-cruiser.js", "scripts/**", "next-env.d.ts", - "types/**/*.d.ts" + "types/**/*.d.ts", + "slack/**", + "templates/invoice-review/**", + "app/api/demo-*/**" ], "overrides": [ { 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 e7737d066..e2ce16e82 100644 --- a/apps/apollo-vertex/locales/en.json +++ b/apps/apollo-vertex/locales/en.json @@ -121,6 +121,7 @@ "in_baseline": "In baseline", "info": "Info", "invoice": "Invoice", + "invoices": "Invoices", "japanese": "Japanese", "json_viewer_description": "JSON data viewer", "korean": "Korean", diff --git a/apps/apollo-vertex/package.json b/apps/apollo-vertex/package.json index dc8141c3f..a486a087c 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 0000000000000000000000000000000000000000..dbb00323b7c79f152dda134f7697df6c6d4518c4 GIT binary patch literal 68093 zcmb5V^;=Zk`vy96r+{?LAdS*6G)N3ccXu;%cPJ$@G$PW?&`6_5=j%{I4hlLTC7n_p zKi_lyfpf2Y?Y*!4Tv9v&_}ApsE~Aps#F5iuDF5iuz-At4C`2`L#lIR!Zp2_+RJIn}>+^8YEp z`q%M)y>M{}$cYGv{(b%biN6B?Dne`kHUI~U6M#*Hg+qn)cLcx+0AS-_{T~4TFYpO) z@d$BP$3Wjaf*O^uo7hp-ET1{n1@7x^nb}yD+5Ry{}o>r$>ta0=)TFWiysVLzdJSFat zX1UOP#hG}(_$TIlqFRJU-q$GXfeLo+fGboI!qJrGAkhKcckfXH{oQJ?JIkWrU70sF zX2-OFdlqn56QZ={_SKShNTd@m`vr zEl4NFJa0f1pC)hl|toENY$d zae_En$|W=}I?-{x2wESA<*tXZ34dg$E^73BZyd|bZId)7&$FPw=yU5d?x)%|Mf*ah zykPWvDm(7HN#qN&2lGYSS1iEhzByOUvZjYe0O%uME%SMmq~XmwuNt^U-THr+792oP zRFJs1v0$%_S(emJLc?big;aMQDCt`qV*S0mfbW|3FN|z<&!c0f##jSCuTx}qJ%;}=@sjO{c|_ANl6Y42TmkQcC&PtUhH~~IRbDt7}-^K9BB%~4$m=R;X+brW&D`+YTo<>zI0sdx)&yj!=UkYI zp5TRIE|=c~j{d>(s$u$-F=6=4vdrtZc%RS0#-Y!@`hT{^>z?V4=jkt2Ejpima=xL` zv9$Y(o3{l0GDp9mw9D_exXXnwqyKgwb|6=MqrgYW9Eq;h@hI(1Ef4@(6VpA{I8-c{ zj+Qzu!=iI_VSBtFr&9H&AjncuXi9D1`R+0KX|s5B{*1?ox=H4ah1t9~JQOuKwZjg> zl~4S}R6`6oJg?t3Nc7qPGFo-~q<6b1ViPW=dT!E__+e!moxPb1@tMg_Fg=$(BIy%j1Unrk9bcqS8E{kIk zY%yajhDX9z1@rbeeRPa6)cf1G`i&9$jFZ<+?_^<|X99hy>$6)j820AQy1l=Eli%Cj z1oCUoDeKxrTLIAp*q=<)wVu$~;Ro@3PyW&-xc{58VvXsr>gR@mOx)Tq`#H?i_87{; z4%y>ox%hlLe!IiBGWpQaMmd*fxH60A8&fZ^Vhv+_qh-jVZRQL-cMkxaWev0f4G)bE z$-?VopG+(ky3^KGf34g6TCoq#W3aQDpr^$gH!C&L*jP~|-mmOn%&t!^y^3ntdo@0A zIuAKTL!p`uw3Ju!x5Ml3*Ws$`Jan>HXlr`1B$(P01pIOBAXx%m^#_HqY?WIbOMTxTSJh$69$LBJKb}AClOj2-bxFdQ+j4}v|!hojK9d3Ta#I_5u3{^dD4lQ7lMVcUSVot*;#c6WuB#D}jMSIJ7rR{g*=w|Vt$8^6<0VX;~B)O_3&)Y?-%yQwB|k~x5- z!O-mn`Ej&qmAQ3b1dC@9bKkz-VJO@_^eVz{avW+marnV+O(lAQ6S1GF5jmjO&CKFS zOvnk}pys(i#ErGGlvkU1Q_4CeL9mV+^%ovq<LIbrBsutS2c_&*>9=3}>py^NjfOGNT(qaNi*vxxcrXJi&5~ z7;Ah-2kx%OP1(x3p8FjH+4fl{#%O73$v(yuRF=+2&E<*<%&T{S05UAtA6x1mO>g^> z;~sDMYida}C9cMy_%jer%~EV8x$({_Vnl7u__&uVBjNL6)$ly@pU;nLy*2zS?F?$a zfH2b8$~4K=b;u;Q$))k!f~9W044Ec&;vZt;`3YXkqtESrRsq^jSCcJcB$yzx4)rdd z&n4(>T9(LKuWDGLLSBz{?LkBuQvSJP!41O`%C?g!8xt-4 z(!_5_n5#ckOG0`V)vl%Mn?{@R0T2w-D&XV& z@!BVN2HDsu#%1}A^?ny5(7kw+&y14UtTqKK1`Bu|3D55jDx5c<%WQC*+VHW3(2%)oPI{SwTJ+t|9lPJ1)>sv>3;NbKaT= zT6urubDnE<&hPpG85V?nb#l7=5B%YvTiw8H!S zb$wNH=SC_}y?b4vS*LCfih7j$0v4@?tzXZ&qaQhQ?*aitAv^V9ozse=??6$P=jxxE z2A=<#Vcgll!s6;)Y%G@gg;29P+oV>oRuVRuv6ZDNjem|W;HgRPo6t9*so7_-o&X4ub z5dOlP_P`+gJR%T&i0#*|O^>U$HKp;WmXCdQj-az3Pg61d#aLg6tgFe3NhYQB=rs!y zY>K8^3p3u=sMqc0mkCHQgaiar{k;Kl14db^8|t`i#U)-v18}r@-v)6in{ng%2N@b^ zI%H*K^;S2$Y??CK+}%&8c7mDc1HEMn?}~ye;55BhtS@xyUlkZ78$4$(^ee*cBvSRx zG^5iS@mO^K@gnwy48GQ051SWpw91le@9bYTnKL55$_rlQPSM`P~;X=ny<=i|PF5XfDU%~HDOd3ea? zPmb4vMfYUd+pH?y{x_Yp4Y>k$KQd{{H39&1uk1WnN~*aNYAYIEJK|u{V6Oh!6gpoe z=l!@;#T-;|-{Y^tF&2z7FUhF+ha|e}ua^Ax-alp)!<+|v2OMReG(NTK4r?1}6Uos} zJ9saT%mE81ye_w*(@1Z(){`knV^`@T-PxSz!|m6$O{2A8_gU3m307ba(Yy z*H{Ac^RKR!)DD<_!O3Dg8u^Yiul=SJ;VQ?i`0VX$_r?e zedTx5vh^m^wr$bMU7;?iMJee9iQeH}{WzSHsQ_EnaKrtG<^^tF!b zex-2<>cQ!|i$p_r_xt^)M)Q{I$|PA*jD)4(hz}WWP~{2LFc!EuH0_Wf08Sp;s_j|x z{<*4IE?!%}c@3Vd!CwI2Lf*HR^^r)#N(;jF?i$QRXur2(|4gD83dm(38MQEtZi4;t zs{VbB;P(&+j^Bwx_culw_wanm{r2hT>S~rnZt+bE4+tP1gy8LF;L`N!?OoR^7)5VYIn@NH%$lPn)dn0&4yj({)aoTw}Z{C*|NFUAU9MG z%q~M@1!tF*lH(JLUZ{V;Q5C>=w+O*Amwk8iw<6)}@#ZkDqWI$;#m^@UC_Fp=7h+`UI!k+>YQ~<%2PTxCJqfgE zajfH_xX3JDQ)^~-PLanjR(7Eb1#a9`gtr1J5X6WVu}`f^9KvQ*6bdyDtOKbe{(E!h zk?Cd1?UfVxg)eyT+$}M&hp;G0dtzoR1=hCKluSh<8F`JdJ__`!)mr%-WTfZqJ5p?k z^KxOGH8(S)?KK0s+Sys0OuRkE*kvukUDWUW&ZiuWlcfGK`B%jW(;is56N+Rvt*NR3elcZ2_#>3FUQ82UaJdGZ$k#_l%gu=%r2=j9q#=3!wmrI}xzy|*EKi|P(ODnbO{|CWWSEn=uP zU9$tkD&nJW^Q%|1E5q65jQrk`Nz6UqypoJl)bHZfEFo{guc>x-VwhBltHFKc6hzwl zTKMT?kmCL)&%-v<_^l-~%qHw*6Y*(nwqj`(0rUll%%bQE7pHY~XmA@DqHxo(v*Bn$ zNBIPDh|mO=2vzdvbxSEknPAPWxOaBdvZ!Q9#R1~tBli0}s&9Q%oLdV$8oq%<1cPs}6Qpm+NtlZ4&qbSSw^o~Ta-rHse+ zGXdeW{if}gi+(igYK8IeMIVSeZqxf2djxss?v%`5z$;xMTTZ(kxX}@Le&@cvu3{#u zugpPXpr%_%m*e34eEF-Cg$I^M&0|5#H)>(58HT~J_pUWZC9gPM07`y-8_UlWP;t!z z;NE$hlt%$@$+#%lk65IwX}m^72zm?m<;u}~lda376#XBT*`9&JBr_19J1VpDrMVzO zcywSpftldOje_1-^cv9@OPADI`lo((5$>Nd5OYGCL}Q40_Bh}o14#I77xVSggAgNV=LgZ*w zxo~)6?e1Z8`f7FY>WtlCj+B|j!ZnrH`d~(Q-oCXxPI2hmA#E!Ct_0!~zE&jL${0&7 zT4X&h=E5#I_Bs~3cF(vrH}-9O1jaZdI+rqVeX1Tge7K>LJg4l)?7@O0EMAqkq+bg} zvWMPbXgSIcYJU=NZ6W2_8$Htb0F|f3Yk|@1spA;x@UV@zCn^RocK&PvSa}~h}f?B5N^{dHCKKRM3t)Z32dBU zb}PGpIJW+F6GkcLmI!!#dU!o+j0_Oh($En`>%>nlf9if2iWY(2^6%CEw{Qt1^j8w8 z1?~AL%YN;~;oecri#@qyp=o{ekq*T@r^Wi> zzK!F6t0a`7JspLaUvMjTSypEL2V{YNL2n863dbE687&cQBh&^aWRGT7XlSo)Ka{so zn~0u-E%O`0Jv8S@vP!R^FB)eT4zUJEqdG24aj~vB#wQr#p;^1Ds`Rg7)&1&Djo-*9 zF;Z)G{|L5T-?80kO2MJ?V$sg9UN4revsRoO%lKx@URDgD*ycvRvgq4-#X_f}h|#pE zsfCAv&MgB1HuOYr=TClBYP8wy;4rd3Dp)cDDzX&+%m?umFkxUT>9PS8Ys9VMFZBAl zcT|j&b3cxMur=t7tqrrCQd*qcXS`}YC+VDhfgj<^J~73efPU zkU|H|OO}^8V+#7~5?lLxhSK&Nie|;S$bS3Ic71#tJ|=F}rTWmU$lh&53|N~}vzPHb zXC~kx_aggt54}Lk;pzThIjN{Pf4Em$$Fr52j0`}(f+uvT`}WO#U6BO1rg@~aFXpNH zs32!F`O4m#(V^LDaDcY{7wAS0Bk*B*Hy{}KUP7lhl^8cO)REyqv}rMur$T!=JIynH z>sajwyt-V;Q&+c-NoQJZjaO?|rO+O`y1*7^cJ2K57oe@e@oALf0t>DX|l%u_p3ExbY}wzPu#O_R*Q38_Y$QZ(OH#9inB5Kb@$KA#TE&FbdhnrjKYt5^QR9u^bcbC zewms0B4TgL2VkB!DhV)CEh%I`-1RKVlsllMkg93H=ofiJdK4W2e?}OSzrU`RM?6zt z^(K9Dz&~eHTRJZoa5P8D^&*&5asCRA4{eK;e1&>m#@WY&FON~L29coC7mWRt$&~oM z^jDS2*E*bG0QVl>UNq2$FxH!#CCQRSacQ^@C1Sw2rKEc=P7}vFD>Y*4Fh#y)>;_Hp z;(`+^G1?Y*{yJsv114tw0`xcb1oI%3#mVVLl*03N%!X1(bYo==CtTUK_~5YiTDmfo+C(K2O#rc6G)jB`w#qlH z{z0vfL2eUyf1WvTDII4|E`F`XP#=ynY8e#7PpuvvmxuEqx_5L!NYBJ^|1Gzs2dE5? zz4Pq*3Q8FcD6Lo3`ma_O%3zo|C`$%*C{*Ro`##coTvw;(@G784(?pqExQW{E9aAhS z&Q&YWhBg@$vAskuupk^6PENE4%Fu-MK}Z6hi71!q~^9yy;6{{^Jg z1lWf>+35cWvo(CSD)B}3O77AJlVlh}a|-uO&!kftJj|~AwG%`?Q;)W`($^&1{<{9p z)4hk#&2D7`YWviOUz&Bf`ha${=y_j@;yZ4j)#$)wWHZtOvaCU#ny(pV12N0ov{X&|!QBKZL{fL`Q8U=~@D)mR!a? z+rHerw;)A%D=eX_Gw*7ZO z>UwS~h&4)n%dd+$pmN+gFYD|Km}qRp98si2`^5dWgId>;cKNoeUcY>VA}eBRPx4Y% z+&m4E?zPj|w6`h3SCdbF?}5KSAH1K`t>Y}xKO!3&aq#(YODjSaFEe%~`nGv5c~l^s zIca|Q{zBIeUN|SuEX_Xz190zrLE*FN5im0sk=fK&7U#=Qzh~a`2!hh8%2~x$ZP$Y6|JaW@eDz{XREr*P>dl-_X7jr1@2ZuaHLp^)GQX zkYTqxJRLM)B9Q5e^i&S~>iWT31Foc>P*rJiQ^l3~+V|dXLoGOk6xR{3gI30;wRKxU zEbXc(vEF`tN1jmT-6U?KD02KG_OS7(p&wdfNJnutGJEc8kaDG~T)PN1GOt47PFC38 z&?m~q6gM*(zd|3vHup@%QQz~xfMb7?{I&$}(!$9i(O1Ez$jI8mg4mUB1!L{>$q+%! z@@?nvG)`s-NHXSoP?IZWYw zPg0a7S6QJG{j%8)vKeH(`}$XUJK-X;%0zZiv<~lB6D7Me>l$Toivfz(5gv+6J>Yi~ zW>tG<$}Mh+r)n_3sxjZfK<9a(_HH^)k)E6a*>+Fx7rVomsUu*W|2GtpMT%=8xBm zrYoWBfza;MR8wTwVIxq*-6NF;#SuK-vgG^Grcr{Q%=qV}mEctYqobVFww9Ov2?3~h zfE==fcY1Jo;M?%j=G`t$-lPUc=zJ5xRXtiRQ|)B^Gz%b`4Hr8Z7l3DE<=AzG*DQ7w z=k6g==#Eiz(~)z`+RTe{73zNh?*lSotW7SCP0f2#i$nHO*CLiP?wrlG)~5WreFvDS zCghS2n}5k;mOGZ~J5q~1$zP+@F_q&}DdZ>; zmrE|wd{DLCqH5{8k`S4qr5$;feIDCSh%nkl-Lc2lcfjAOa8MGdpTf9mn zMKZ-zE&qI57OAv%l=xxPhv4=X#=WwuwV`K4OW@*(VO-BP40~HSmK?_sHJ!_((^@OZ zx4S%5;TWH~G9<39T*G?XWz!!-q0>+To$abX_F`(q+7B6yUgNlkqjb)FBRjj=u z+ooCRvnCU=ADOYWNQ(?<`2r{w9<*W}8s2!Frbz^MD$#GTIbeZPR0kLEgf!=Lz4Oe9 z2-tVm@?72G1mmMR$hEMtviECguYl0d71)%zuX^hpYmlp|rUmVo3f2VJ!cMCyPgjyO zbS8@0FxLsaVU(gV6^a3OSEpnI)F^6%J2}hZH2ei%K>7<8f&ncg$uso|P&Wg#Bk10f z^YPj9jp2LV=9T;Wm5@6Ri_Z8xR-Pc3Ufrp|#UD~UC1ZvDdX5ZIoRtU<&?=&D5&55a zfr*3e`oZrf?Rk&?a%1yGa0RdT;t+_xG(E=PFa(hMCtxh>&ZBDVNFB&>c}{(Zm{F)j z#m-7vcU~E16RYF0U@tAvA%Y2{q!~Bor%X}bGo%)+UnT{F(BGiA6o)jKGS)?;ua-+~ zU%#<9Y7m`ay`YHIo$36v-8ptn)y00WR5GNXHLgA%_%ZW-9Q)P7<-0Q$#V^D?A0CSc ztMudX<;73*h~N|oj2+dt%U(ijt`=?Mqej7%w-2n#wu^mw+QK zYlPccC9d+yR>(5p{yRHTX_Ov`=22P=RZ(jlCKE;^2?5c%x~&laEwI*}CHalT`z#u@ zpMJzQ2AiaG;&8pp(W5s|UcoTTO6oxbPNqN;j2+EAziQhBDj(p9RTimJZOj>!SMzBZ zEGjrssvX!LaWi&l>FO!RSS_WrSEqcl|)w^G20SyDg;G8Eac z)hByNsstaEd zqtd1vdigVcr53W{<>^jTbp+t<&V~Oj9o;rrgHFv%c;69=7E8?~>CW?}IOV|AaTqTI zSI2m(Aa=E5!U-Gd58{z|#Zs6-;1-#Q6>sS`dH_!LaXCL;KLevUA0aYx5!-0;y1sHz zGxfifF~91h#M})1Z@g=-oR>k7Zkju;M%55SoOGCFXbfp%@D_pIV<8(%pU+oNyN;=* zM(DIxHJ*^&X|Kk+m%dz$R9db*p=J2uO4Bv7v-8;d)?w{_KdJ}_C0 z@JPV|@MZZ1TE4t9dXkp#aA~RPlfq%@K!btlBnt%LLZ|Lnlms&U>+oUMED4M{(&U_tuAx74T;FtWo}}X7@ap);~nTd9s=qLB<4b6E~<1 zJ(^f?ez$REkF39dnAVGCwe{Y&b>65vCffcdazR7AL8-){v`nRZXU z)+xos?Jue;V3N6On`R@lH26c{s^0lj@Y>9~)rg39`9HV!RLD&e)k~qxrv&q6B~bgO z^4`E1tNdS|0;9On8>RjN4ooUZd_J?@dD^S-R4+Mb1UV+on^e?#8~cj28K-cu zL*wgc7sv0j9HsA`+h=Ov74~2VZ=3+MyR*eJ^Q*39dljHk~7PCrm5VZ|F$xg+ImGiV{D<5#o z=-ahVTwRwephnaA93Pg>zkrv(4K7)O7$Nhi;Ac;rqrN;$e0}K@ZgenuQ;!%e;Pb`Y ze6F?^lzGk+%Z%9)Nw`bzPZNyt^R4XFGJs!Zh5}F4eR4@-8D=##s*;XAQ!!K^#Y4+bH;=FqS>g(&TPdLf;?$5sF{9<{z z;N0hGBIQKrZ(21f{}>7X`k6?IC~&Xg2>+s_Vc?A3oN39wsv-z!zh@$8>ed_fYbKWr zyMW2NJrM`PXAI1`yA{=+v^v&r@#||H-J?^V3NP2Zzn@^xJ%1{P#1i4%a*Xv=d*9yC zVxmqxx7es9rtF4b186gmCP`gnj!TU@r~^xgK9QdG)j{{vy~`SEYX1?+MER8^;A8S$ zb-IJ5T9hYqe330jvKkUVs$Ok_byH<`Ktb9Y{AP=$MM~nt6-}Q9PM#hpy%Op-&Funo zQs5qto=&lRu+Mz8d>|0<2Pn1W+F1=sHe}z^#~a)B+4txp8yq>n_5JGENdFfwGIE-A zN=+e974rNh8{}#~XDl~bL=eeh>|FH#h0x5T+c(Q7&~?3EF7^0nd%`N(cq!W~$P_CL zw267-tD{z+5E6wxZ!8!FTjs#=lW;I=N&X>&%5 zH2A1{AE{_a=`vJ{j5@8G)buq=hgzc#MLNI}hV1FkC?n2=PQro3;1nJSiHF89amI2= z4M4zo9c|W|!d!5yhYBQZYhRIb?-qJ3RnDB3K^7r%uO6&J)Z-+BO^^iof#2FH{-Tt=o-B@{X-zH+yc^}``g_fQ;W>s`+B~=x#la&h_QAF_lEqMe(iy4S@*i_SD?2{ zKz;9*x^gThJ>fyA#m1~^EP!V4BoG$0;l9u3rqwBl1;b5fU7|*R%>ErD>|R_+#L|ha zP@{ypFs|3<$WsOqykQimre<_r+xGjd6R&foPoWd_l2v}~Y46(p>l?D}>4_CRr%`qh5x92^~h=sysFX)1@x$FyOwbDyqU^VG}D~-Ka|EdN z&osYEV*Qr@A^KABXDu;N6Y=KT??89lXL)HaWT97!a-HR}+e!U3CRX!uscF*82eZs7 z^(t~I4V8r<<73H(Po`A@Qc9l51``+ur`CSQ&MKBkDCeo0x45CwB^#K5@^i8O>^xvz z!V4IBJtlV<@}r^t@p8dn1n1ZR0+d2A+27s1H2T z-IY?Gcv@!gs*9zqhu*M^wO`WkYcaymXEGk|N*e;J=dfUkLojjsHpN`5Dc8$IL3G>h zoiPpXugWkTaZ}%J&T7zg9mAZyE$r>UWvN|t*N zZKO`zRi`UG-xiVZ5RxfYWR!z#cDKv8AjYP%zW75=M|ZCp^n{-Wh|&C@ z%A`M5TpZ}9F5`vMdb2Q^OeKq`0PO=!#=CC^c6N*xMKW0}rDof68~9V3G-e32CvXP! zDxD;PcDV1P_PX;v2pGW9e9{Xq+AC)QXpLxK4cNC30L~ z{_Tp#;I}ToU*{9gUx3@w)FQ#3&woOH&n*vHE6wZaLc8Y2E#^o45NZ^%i9BKKGJiaN zPs9#wW?%_uO-KhEYQ^2nUr&9ploM(-*AVe>D5oYx}uv6U>8z2-qA5 zAJ>X3+-e0sR*D66i1u;7%Mb=qj)3s7!M-q1hMXeS?*N3EcgutFJ49AG15%Gt!SCPh zs<*>0vosx|F=b*fvMMbC;?RmZlS%KdTm4oNhNrG9u?|nnn^6&0^=j;JOyy*LIDN2`SpfD`w74`VWHFgY5S^JC?5hB7uW52Fr_T&!2%^r;6IJ`}1%U56=f~fVwLRcqjUY zZWN1P%4yMRz#mc>s>wr`(HZ9L?_9V~U|6QUXO?DFV|iM;P^B6{R83LMqqE$2w1^b9 z-ey@{e6h1fu$e4UKh$l;clz}Fblnrx_vMEHqbJ|w%U|&J*pF&QjJ)_V^^Umr1c0lZ z4NZxYXNO99C+m%emZ~~ia@ssx6)c1HX|a+xM{;e_sD|$C1&$%D!Q`rssB!PcES#N! z992h3se!Gn2x{fO0H|gMV}T@G?t|ty``Sa?iV+*ycYMPpVMT!=>1LFn7_Xtq1>eH& z0Inm$&x^?UdHlE8}&eRKBK!F*s(+ z?(H5_E}`+{#tKX!bv}AThPV!#zp=PCf5bg>)ZTNQdNdC|_9cQRfr!23f_&64cb)mG zyL?xfHj=L$N$j2K-Vs4vKL|#m-*uT9tsJ^+E1!rNQx5&M37U-BZA|E6f5CkAiImxs z{~PbI$#iAg(t-%TMt?Y-3l;hC7v`GUFOg1r??@5ZZG3vJ7fL@$Qlhwjs;i=b6}72t z)y1G<@d960(zYoL1I3m7iK8#`7+(N?21}sPe{OSEzrAwpqTWLYt}wvgYF|ElA-=co z3si^6@tZ?@i;K$3^YYY^@f{P*G3>(VoD5%bg`bk_PnyGI$xby_f70gVd1&s3VHzvV zIZ}z^IEOi#e(C(d%SZ&A@A^%efPXoUoPG=x;e{iT&fKgHe~a(L{6t(+2g;4TPj=Wf z!jABq>uaa)fW#%VUoU5Fy!Ci**V8+({`7~sW7SwQCljz%B5XA9wUXh$<~7Fjvp8|b z683P~K8t@r-5@(f+-bdh(VO|74dZ9U;^;5#(`-4~q`|MPhv~QW!omyX91nL$ebu2> zs-Ss&kqQxjXxUUd6qa3hjjYvjYH{&@_4y2f5`Mz)5h)%a;KwQS!cw-RD4OeqJ8g~= z8`Mm3_XIw__}Nd1TK&@z?rF1&tW?M6r;vGU;a$eFk0;02F<<6BR-X}$eNNaj0@nPP z7+4dZMjmsqkn)w;OZ+-J`FyEu1`=)=oaqAh6Au)U!Xn!_2EQvrxwynxTWgsdtxgbN zjD8Z8<$Dt_V6^LME7*5~t5@(Kz|Oo{r>SH;Z5{moI_1U0BisL=;8im4HR;r1jcWR> z$bGAeNdal+ogF>Z_pz#`ZvFZ5YR2CNu5i>)ratm7vSZczb(+KBlXQ$_isW*Sfu&8| z5Qxbi0qncyjQA~U-7k+KKk*!q_bez|h#S5D9#-|*cz8Gm8BFyL6yI`xG)5wZR1)9; zrLPw#MoE1Qh#X_Y`-xDDci9DsU-Iv9{zX`)X`Dk&qRG1zJv=h2;J_@uA{h{Dq6 zg%YV#3&4&t@MSnWw+phNVll^bF;b`kxbbAa*YeBT1AcNNIs%NM zWNPic6E($jG5*_6oPkLr7+~ipI#%}!+PE7U|(64=am*$xk^iD}IPlG!JA`|-;U~RcBc&eqr@YIj( zpX1yyZ?s_er|^&^37Klpu_E9du}h;In*DOp6|tsSlw=b<9_gXL@SnG@EK>CLVE*|N zPS|a9X`!qAzkFfXTXQwraR>>v>3ntabb%X`E zMy(O@#sgjuqV%~(XzuK+$3Y&E-gT2#%j&{vE2I?12*$N$^E2cJjd|Q{F)69OpMWA zK(ru&1lNlG3y{qJR~xO%1UFW0JGP%(rwIX{74y{b?LMoi{{CC{y)-F_W`xhbV+9n> z3^&YsWjDq9PZmpHe*siNtjhYF-?kFNa@#EJdE{9RKoL&E!9m$yD#)P5B`9nnYU>44gs|pa*CdgZMd+Z zZ7OeKHfvM^1=|RrCM%CsMnv2>-eJ0v3JQMJZ^*=q+UHbBJtRtvQuO#r#{pPl_)T^8 zE6oDKA-%b_=}>=vm%_gQbv615YHeCEVNOoIJ}jgfc`~g=LhCSviQic10uPpjDpS7S zSXH>O3N;Q7W`)J|xdK;pOmmCZmkol`{cy=A=8~UlD_Y`> zF=;Ku>dZ2WSM(|Ak0m(##^#lJNXPLqO+pzFiy!(e{Y;^2?8B9epe(Bq5%C|Kd{i1O zh%!bo;-M{&TZO};_WnO|MfxrG6juZ(Et`i(q1|7=hCv^{r+xZKUlg=?58@!gu7=L7-Ofym1c_~paN`uUy#dB}6U~f6z4_e#0*c~QC~LqF7lGYMvpD^DfFRMo1nHZ<2W(pRTH zhmWIQ5`JZ@K8hyq#iwCVHv_)rj2Dq!B#YdvGu|pP{v05%&N>{|1Tkovx=ROJFi!Xg zAZ}tz#MB)UJyy-|)+L1+xH;n*rxC^@4*6;MWGJM(Hqq#(Y}%%$wZUb(pG7V(o7$0nnF;6(EfTP06f>}T zu^oafKW>;;<&-WBI1n=os&YLgJK7qrF3PTTXo?lp5dBwU+|Zi<@rb83J3TK_H^`G8XBR6OJKMbbU{9T1&tvK5r3~GA6)yUP}FEa%+ zs2Lfv2TUxI^-FViQyMwSuVK&N<4&6L2zO%1&2)ZQL>V2yqbB~$I+r6{Q;dm#^0!~a zvvY;TpfmlSDb#5iJiWe3Q?(N6&OoW=%=rrN-6o|Nq)~)bnYKbAy+U2zXx$kI@DioL zrcULE-jp9*6;eCs({Gk>txddtfk=-+(2)LT6u6d)%LN(F;^(4%4ZW7A>X$Ja~i z{A;V({MVrH=d-i2CQ{~MOzKfjjhFc*La@3cBX&c|%)jOpX^>W_D|Dvw3ux)@!({F} znSL*`rC37u8EXqkh4s;lQe8nGreLH!$^-!jKJtQ4%(|(ptJT)W-l<2J;zmniO}!1a z=56csqE$2}eWi28;ZA=L#8CNUK|V}>y@>xgwo8NXONsa}X^awu%3Ah_hehAJjA#G7 z-UI|rbs_mm$ww&FWj(n9{{nQxOwBwkYJ1j2088Z`jns8LVfiOC9vZ}1@LVft{gUi@ z$_(Xtk?xB#F<$YrR7E4L{X-aT(2k+W$|suri0$^212@5JBSW?E`FTF-*oQ7h1+V>x zz+{D@FJlPHf2N3sZCW4;ICEhwU61Y!kRD$>QS!spciTglYXl4KJN|amB1H$QcyccN z(DT~z^gk)mb2BL>OAYr*L8#O3*|qz;LLv-8b^5;=1Nx{0l84W+w_C3s#8r%CB~o#G z6H;Mm$Z6!VGZwz^Z-_$6axh<(I&YtM{L$Op-SC6)w^8X#f;0=SonN~~;3TpKPab9u z)v{H8Uf+Ez>AKiZu@DHp)rY9+5_786$R#;M{WHPBc5b*&=00VkOnGQDPZM;htXFV% z1fnK!WOuGGHpPQF2JYraqv>58f*PDJ0~o90$)f303A?xT&*r7F!pv$&hq=88kty~T zVZ>^_l|nyDeZ{p|fs-bL!CSFX+-QZpI{jGpwO3Elb^tNWS4&UaBeo9p9QB5o^SIA; zHViOlm1!CMW{2J9W!(q)#?7c#*oZR-{~$){C=jA$PGSV;4${xt!a;wzLdJqdAe?R|m$wz}*B5Pg#c2Z#{XUtWvEqtR!er{{n9t zrY6X$x|%vZ5Kr=r%hkY^E9P?H*{nVSF~_%SPJhkJ>^Yo%mHx=$w$}{7ILBx2(McPW zAhKxg=UUftLs_O;vA%4x-+8W(7w;z{HpdIbWse;D2v|3+W=r0F=G9(`rnC`QY(F*D zH`UkHHKu7z0lIx71U2`ImUMN=?(gcRvTT1wiWu$_sG^L&%QhefdqCL7RcNhN|G5>iRwwqrzA8a z_+WL#q0RGle!Qkbmi4Su7Sz`qn^^n>l-34UdE{G&Y|#oee%8U?eT#3<*#5;!(#v<* zEQY+8V-=BHKv7*;2Y;5!QcA&ter75~y%S5sn#j!n-k&NGEogSrpGO`Vc0`a-i?tEI z(DV(`_pbg67*6Dm*U<@75mTIdgTu{?RnO)?lllJu=s*|0yGvBudwc7-j3!)gb=@El z%uYDeDyjCV9rLIlv7n}U*_S5(X^~~6ni`0bGmM{qeJyO&N{@r=dDBwKShW|3QzvL6 z_tHw}Xqw}one$rnZhLe2(X|1k~HweZtjB(#u2Ge4aNq?N1 zLjAkzkCWtdpq&+7WlN~W?WabTT8Zi61stP$^Qe{7iBGj601b1BrWob*NEN{Cs*94K z=8CSiy7sV1D0o8dat|X`_4fK1DcU$$(o#5Lee|Y>sbRJCq%ujxJFeFt_WS9x724G; zyjA^NMFQvZ4FuX0HFp~8ZR%wg}SXVjW-jwjt;J3>X(KbMJne# zv!I<01#=QIgOt+$0I4oAQwH+i`CxOqRf*M7)WpCLgB|nlsvC?|cSzk>k_h9S00Ufl z#}@+>PIm*y8Y;1M37(=fC%!xAE>482HLoiN|bEGYzsT3|bJ+#TV*piBY zB_Ugkoaz^bT{S&@QZ$TVkBzwZ(+9*GM7I8&e; zUZkfUUV7S>uV#Bf*RRsACGPuEoGxUTodA7iC^izfDZf6x+`=_%btc7T7= zT5{EJ2A(t{4aa?EFAIFin>9B0@>?0>wzS2*ky0cL=@}S0;=azTej^fxE~&=^d+MtD z<;NVEAbL-I5!&KIOr&7(pq{>2W3;=%Nc5iCJPeFu;~ixvwAUeUScBVG3#HNj0QjLS z!}5uV@AuZ3@rJ&+MPLkA0!BVYkX{{8wO3Rzb^Ox%>wYCz+c4yJGtkct?zHXG)T+;h z3I{rOQeA06s4^)!&%G&UFj`{r+Xrv9i@ckJ5`uET_B!V?Y+klLVz^VaF)M&EuZYzr zVn#4~=~qi|sfIQ48{4ruCb!=JY%u_Sy|fKQ?c#uc2a{mQBaJ2MX~NaA%$Pa&(`w;; zfKZShB#zow)zmFjbFs+D&>l7tSYV_S!Jb824|Adiyu%eU#ErWH=SlKsj<|?rZNB{K z(g$#bq;r#?6S0FqQw3E^Jf~^SJLnJb3)D9LlKcwzmw2ACIz?+Nw2(npKW&ArDrYoNcH=`?vab9JSC+K62&ZWsh0>qc`=eR_U-;&+Iv_ zDDHGSV5StEB^@*>vdIlG*i?RFjAtKyb$M~0G&H3HF(>(B+Ze&u3Q}^FooKExqGB`d zF}NW?!(-!Lzf5F_7dvAzoTxqj0Qbili%M0gPU%R_G0$(e{+hR?kjYMQ9QsN~MQM@aQ6@WpJ-EiMDXBt+*aHqpcgF+w)c7f>-ZgU=EBz;SLHIwetP+_d zqj-p5NFUTP4uC%C1WJWn(+)Qxt9rkGwyY#Xj^4a9g-%JyB>bLz{{UT5#{{WOwZlZr zeZDnhq2o~_HcucBaDF)Z=n;_XL1{O}HlD=o!8jSvjb%gNt43FA5_7_XjT2W?801ML zOeuWrW83eazN@9DjjJA9?u@V?sPD(H{my}xKyfk%(Ts}96z@10KKy;V>W$4@GE8Eq z^qlr!Hs{;*!P6J`bQKXSVj}EM2exy^{Wbgk=L(oqq!L18Y~nY;$ULqNMlqn0FG$tY zOJ5T=q_+`lZKpZolZ^4f8hlYpEmaGNNX8D_00`r;`8ruzx@B#*2;xbbXglGFlXf`d zXYbzx57SKgEsIPDkUWHt%BQjKxAph^{`zDiJnQ^Pcs+3GFT|ftc)`%{GR;~A9qQj2 zp%uFU+Q=MvW?iHH8)J6Cv4eU}u9K&FX4Uw6i>aPi^VY^UAai#pv1g(tV5~LB>h6+dA4JN!H@oL#OfskA2 z?Nt<(x?0(7bstkSZ{@_aa#VzdUG2MShX6gos*`zqK0dY6%#VD(E%>sDHs#D095xl1Pq;I+IrGhmTdj?zCRcJ3w)dKi{M9I zR8~?;OGyn?y1p7~(-03>s-I+0}(>|EsW`s=BQ-Zf?nil?Bt z=UHN04oUBwbCQV~GB)H7zN6AoPRgg_2UgDQ8Wd&qf3B@62V{Pxy>@N!8%k3ks+2*4k{tH|mscy33g9Rbr+RJ}b;u|U8g(|qPv8ptM;0^M*U*r?pK`LUAgsq+4!{q4l$Lmpvu~cqgT#u8hTG-Yp zIDbezzBMX>Hl9`5!*9RFfqQgKq)De>qC5|<(=N$oW~5e_e!Oauf;eD`Hibs-eCU~O zqMneJaG-tk7OfzuZFGM?L#W8rEmfM8)SZj=<3hFFaZgB~83un{LW`{-s7@suodg$6 zY*cfF01m@Z1QB`M;CpHHu3DH;T8+BA^BZ!l?eG>J3+8ebghKPwn z3}78+npv<*7CrbG(OtUhw|d6^0L<(G*KNagag)^r)~gjg94S1JjoHf(HQK&dA-N=r zfZIqI_|V-?P*g*0c(cj>04-Hp>*TGtpq00L=S4o)pK8;)!&TI= zPazAm?HKQ?Yn%xuPZP9oxaU-T5xPF5g;SW?O!K4uSu3;Dj|$QeVWQiB#Z(_{XojYd zsp0awXWLl|!fNV|CJ5PyR3o3?TUNlUJq#tz;oqHcpJ!EkP(@^pWT!??8h)P46aN5V=ub+?PA$$F|@2=L`=<1tn$`Og@R#z^ZF+^A| zIPIWX`HcX;1TyiUM%sHAY?SJFDhIx!)K5B4C?aFL3zF~ zl~IBb`s!(tr*4k-8F#xf42=70%n!%?dk(blrpRkEO7t~VHB_;j5KO9y=h);c0s0+! zzP$%~5E(YRj`%wA{{Zpn;f-LD+2RAaji{;Pp`JEuhf^aJBm6sZNk03&b))g?)G5h) zOdVHQ6#YAF5HThQj9`(-^^!m9{#w{ygqvem_|sWmG|5Fv1v1o4G?J=DuGtu3*aMv7 zBo1|+d;;*cZx{Saj)Lu5QPbTEMr5t(Tij6ZG}Q1yWsxBS5NB|zhz|jzQrvJ%{{Rr* z!>nC9KTP!RO+nKZ8(&m?B@3z+p0p(+7kOoJT2=-{1d>2GATB^1IW+yID8;E+?S;Rr zdNPQu1f5%DrSidoK{e``S%=6zXTK+1{{Y_(k2X4Vi>z!-f>H?PK~`& zcvWrdDtpzES_-NeYb2zm>MDQY6sa9zQQ}%+;y~>ripq)^j9HTbRfvIXZmqu~P+I>0 zj9y|)2dAcarHw!H4DBDjHIaTae$Ae%UJvOuPP6H2eWqHfi}e)f4iG~+OgJ6=B=f=k z+0|qoGITAz_e)1|zeh;pC$_j+eLvIC0_~ZRM>-o!`hNbrg>iW<+~6O$wj!VoFI&K%VDxdvnePiMmtqAs0*aT2{h(lcu(2Gv(YOxuFLb z#}L05ILKqk&PJjBHnhQJZ^8W{Ls?HO>>i*8>N^*-2#VJ93M@tfRKVSjhl;Z z7&%`2X!5R@qWX`gxF?O=>1u}({w>$rPlwkUTb)eQl&wi4$na8m?6~&E^%*|gfsi=^ zURFoqk4Ra3JhapJac;fS*lbU8z29#gLiFi9&E=$ORAku9sevqm%N>D5^={6+0`c~_ zqVS8YZmeD*MQfZRjIJ=JCnM~2;lEGRRbRYc>0XA8vZXBb6|lVU#OS5U5&0F>m&}oy z9;ILujDkirs|-|=Y|EBSG?4!Q@rSDV-tFO^hMhUo_j6xGPikwtQ6!s_eNzyG;F0Nq zIAa*#VX@rl3rz~F-eWKRn$cg014l38L#eFRm|=p=Y>JYC+XXyAIN*XBSd~yH0~qJn zd+A1&p^P$M{EcpfC@L~a+?n@53jHvA05R?Fu5v*eN=smK+g1?L;z1wuf!|Or3?Y*Q z_H5@(uu+rWBTfGR3Dsljax1 zA18Sk9P5meeH~6o1h-nqL6G-x$T|VJS9!27VU2z@+Df!{U=+7up4t_<+`vf?p5q<$ zY-6&QBwqBTG&eq?Ws{hY`c!+JYi^da(><9KE+1~O_lH$c$9jJ`uuEhg-&+RFc8ZpU zHifdxy|lCWQ26>S?n@O76T%}5KI1?V-DzSHL$E8J`PIGN={(BJ**x>5{lqm~g^H9Q za8&ozWv#SQ-|N;TdE-&E@J69nuKRX}ZvOx}skl;0B(1x5mZ!EeomSDTf}!_DRYBnV z>FG38ZpO*#C}5^88yFut2)uQLR`v-d$o~4Go}pd_+~u3&It;qZ&aP9^KKf|J$ds8P zyQ!s?b`v>u_CFf6>FdkY)S7tNwvWD)HI;6YVBnvAbV1WMh^ecCgO(?qUgD^sOM5hI z63&$2ScX77^g&>#6V-v7Htb}3X+KHSPc=rt8?xi$O+JaKc`Hj80n2-hWNEI=F?%0Y zxL#Nz0N?@N=S8vF_n_RF)C&qPkT;@?asbioKa^qvBe!h{SnU37XeudMfpDtq+OdX- zMQXYX25j-A1%mT8nu_Njat51rs)?$i-bN1Gj&XDhXJNYozPS&xsJcTI)m+Y06M{~- zb#!8GmZ${tu6zm7;b-a!#m?f=9Zm3t#IMU|l(>3Wc2PY_pn@9AW zRYdQ#m;u|qv0cK?cDL7g)YWnW-1@lJBd#L4WO|+%vM$|2N3q8B zIpauvsk_3}XJh{WEc=Zt=Ia`X>A-5XC$R+f(B)rJd62MqH<9<$tZw!!vL>iCO4@e1 z(~{gXfOD;P;r!HVZ-64MNCRGO+`5c}R*p@QDagV2(<`I=XRV;M#SDzzYMcX}`m$K$ z)uAJjH?!3mb)+z%aG)G#Sx2qn@ZZG#x$#rOd-c96g!Vd*r?}8h7(QhriIkQUsyLXZ%-B;w22SLthP!DQe0w24^vi>V82U-(m5p zQm*t}%KF*m-M_9A@WbQZTUb0g>52}qv{ad?=<9lgCx)a$P^z9=CQ3M50*q7>fW&qh zE4lc0)P6j8J64wu{+rcsSJYgr(9@y%X{ss$w5cItT!}EM8zW%~agExn@#@x&gee6Vcuk@7$H#;#0R_J5{JRiYYKy;j9X8n!9o19LI$gg^@u`e$DuZL%vGYXrhK z9LZfgG)UOaPT5#D_8!^{s_EYpuANU+9fH#(u7P94RpzcR)pZ|rW5i3(3NQd2{q^zI zUIEtJo2P0l7Q2^G)XQ&}T!iiCPZdJ8K_huPLV|JzJCmbdWpvp)sBKWw6{w%5>LQG( zK7Foasz3*Fe6x|?8eiP{Tc`Slfu{O+FEr%gnrMNM2=*t2Q})h}tUd~Okq?RXs+lbk zcy{e2Jv~se(i(a=DWio{OCuI5G>^12g|og#ZU%4)=IJ|yo3Gv*lyKQ=ZF_7B36KqPUgGg@GXkHhH7DVS~BCl0XNOomKda)0e)VwRH_G z?#nH@m{QN?qk544040ea_GKVsFymMr+YI$$3Tjwnsi|%fD3H6k9PkGxQ7Fc0nQUbw zdsBb>ari51s+D5A*IcT6qCmIOJwyyiBZuGv?}7p3XC1YPyk`7iU;4V>E!_Ymv)kl} zM3rPXtB|n&0ESNl5$;nNCxS$ZrMj5zG_z7pShL466+E2wI%xP&(?7*3s;=^E zkIR$FN#$Uv2*z2r9>pKk5GVKTsNs?M9;O>FM9o>@xA__RZp7`$GCU$Zl4?t?`ng&kC6hM0GRX z=@ABfO_iNO@Of@H)0Ts%Y3AuwyrpHkt$KNZ-*U1udDNkJCR$hZs`fsTefy1TPs6WV z)!95v>9?f3#%OC17AlzJV$(!lJ40^3{NHZa@2vBe7~K{_ZmjcKU&rU-0_)NjPM+(z z`eVeaJzPebz6;MzB`ofw@~t%s)5xvIe&hVKHPQYvcqKvC_P(L6@ZYO?8tX|}9a*jE zo~VZJ1F}BthMMCN%dr0dg=)vnask)B{{VOkuw?a|Q$xf~O2Wmw%_Xba#n2TMt$A^nFffE+z@Y7fGo%2)xB$jG6Sg zF}7Jr0RR}>+MIHP)1^g|i(Q-YPNL{f5IVZQsyb7|cI!K}Yspt|pt;!PwcKdbunJ^} znn;;&Rd>ZRjEoSu8JAJADlb^W4{c=s0K>DbH&^vNM29kqbTBL9ftM`b{{YJCO4ilK zG;Tw78n&NCW^xq)So6j+-$M5au=O%3^4a5EUadGK2j!9&2!seHq=1oa>nv#M;%Z z3c0IViko>1SaE}(tA*{VWII=BC%%}s=uv4Th42cJGo=k|Oe+}>Wao`)g*}<&YeNI2 z?ki7Lk~0u9>eJTU*0D!QV@W+geCspNqooq41UVf3y8NlGtfMO6k&k@oWd$ZQXT;Xj z9a|M$Udo5`e!8}M<+eak3JiAEMYZ)kd0<(#4Dd%MM|5{DIefy>m&SW{)ejp*s&+Oc zsES*e%ZG`M0*!q=9U8>!jDB<(YpGXo(=OKjlat1XDymW{HX=}1jx(y-p_2vc-ky%* zCg;dJ_t2Hnrk+^a%Y3otk8MTj8mXnLa;Nn7(9JxONC*nq?as8uIw@?XuBNDxt(;|n z`5LsebnV8XNaT(|2xjE!-YQ8OCmBB)CALtZmkWNnXQyA4gnXk|Nc9 zobq<*?bBU2_Rx(aoANWAExLE8qLz*@n?FL>(-sTuN7JreG(bLctWs^gn@`l8hor6F^_vfW&EHB`=<$-#(d^qM zSWfM~8a1c3Nkj=bL{;aG`a0?9DWI?!CqcV!zJ}{=5zlp%5akKsb=`7poKy^6XJnyi zA(6i>mvUV$3fWRr?#7<<{XVtU=_Cv3CxAw%>-sM=p^_Z7dBDz^GC@pKEz+>5Mq`FP z=T09Ds-;e%rdH)yg7M!%6`eNGgkLCzUilhmbS)e&QFUcFv-0Gz+uSPJk^h zPeKrZlo-=t+39J)a?BKN8qWS6^<56ySj38wc|5Lt#*KEAoOE>YqTzP( zPwA)+CzU;X44qk8tVup$9G=|kL3l3|QCMK~gTT&^oq0~ODd zfmuMu7AY}0x zV*|6!S3bvCV+SscQkq&FH>v2&Xy;+t?CC~4bdLahQF?lx_NR3< zMFeG)ueAF{Id4L#AGXttdSQ9DtUU!xo>EC6gkgg5?Sb*wonqe}`d-n{zwI#TC@E4& zVV1#3UvilgGs#l(O02}50$o>Y$rxm%%4q7)Co$+%rXl+5>dDR0Ph;{_ZswG&ERy`3TUo1K0?VJ*(t^` zjyoKTk)PjK+wq;zwFIAW%av+aUP+cuR^gUr+5kBC;A+RFE!{;~N+Np=6*LOFOf#U6x*zFd z%8cZmay#?wrzMx5Iv&SM6I-lQ{%S9w<#v^@J^e==wP|#^(9uvkENc`C5i%SBwO5?{ zU<~NLpyK;ymcNhwqNC|=2Wfg%l9rx|YRJ|M!5%r2l7rzAC3YZjnN#B$YyJU04K~h^ z{5(O|eN}3O`p>FjN~r#-AX@9>{{U)~G;xDl;m1tW zneI|4-5o#@4alU9eAYySe8&?o*Soema#Gl7JeVV=ML<0m0YDh-{{T;IXq#}xpj8!e zZ1WrapneHiE}w>a{<^c;qlW!qBBiP#QMzaY1({Ac+5s)kZM=KyIerbiC$xBj_*A%E zR{WM*#TQf5QoO&zBC2%;7+x@@P*O5?A6C+M_SdeSHT3;0`@znpvDBcF8vA6lvaWlq zh*gRE;Oop!_HxSwtEfD7@RO+{3teujrmaX?AnXipB_z#)4*-}s$7ULs&R&zJv<@lG zjU8?LGg@k5uY$SXsp{sSnwcsq+`7`diLg%+f?^B_&j5F9ZPs4ukHXhm(Dc=w%SqJq zw`(m0Qq+SGxBe%%W z$+UCKU7P;^!=Hhu_P-Z;YUeA_^>vP#n&T9T(h8{5WtaS3Vul2L#+Es9E%rpA4!S(t zJ_dDPMEHAWxb*xp(Dh17B)3|MH;!nT31(PW0v153KsfF>0E}tT;I#$oq5NaD)b(AF zt^GT2tON=vmS|||4mY4Nu?VbL9YHz6o;A7++oY~G*wT;amKO@C6l|v>7y}8(`w!bx z9cB1wxpf~{)Ab#Ou8xk*Y2sU*4Ysl4YLOqP2M9>s2Lp!2JA3Ko-#rlO*vYWmsp<&1V<{gSMBW8$Za73jWiQB@wCqLI{=Xp1FfdoFnA6mb*ZYskLmO@Zl?lXQQRr|rEj zZA)#AkWtY@yK&vv`?I(8G50_8)Tm25C%Y5It?$ytC7W(ooSge=1*T}4EDRU-&blW( zgLRD5mB9$ci|kdO_(GT)PKU45?KMDfxYdn<7K|=f9Q$e*F3T&eYAS^t_i#0)d?cZg zdj&M}5*HZ*=ULsX{{UHXiAda8hIO_4Bk9MI$s9lkf&s&O@vOO|lnof-+=401RUDD| zV0mYa*w=b$>BAp2fgR3^ueRuDsoD}mj8}p*mbhFeq^oc;7m!KT2>XpL;(hfMR23CY z2L$|l=pu^T*9+>zfM<3y@u|~FlT?{ykTE@muDWg^4^u}}RQW-<&a`}EqNlbq?EW3m ze+gM8Lh_NvJ+uYZT`IH@NDz84?lq$9_0<0W!`0oF80R`!_<>hXT^x|p5x-gEDLEI) zF;UQx=}LN2R>?Ak+IYy*veidU@v5q;A>fP+BW?Ej%6qFz3OV(2#|KUO9c4v4E9O(O zp85FInJuEIXgHGHZh|gCgX}aHUi8ga6;2eK5Dx=VF88>Sf#s3P$^rK8q`hBPte1_c zmK(X!LK0)jNNIlvrKlt-2G72sKTntje67phwx#s7&1w}lHV?QuXGu)8HsTQgILB>6 zlZynCLJqbm>G!-yLC%)$gdYSPRmhSAN=0-(LR!Z<3D%DB4w{mgJh-GA zGwgJLMyHbTAW{ei9sTvXF4dW2sg64h&M&2oGDZl-28|;yPZU^D<>Q~%N`9WBmU&Yw zL|M*9`2nWP6b(;rjg;gS&nHyLD=dp3)mk8^ui-yrTSWYLKcV#}%Yk=s`k`<|^? zTe_az^jSp&W(8?*8OJ$4I!7uSie!F|qrIE#Sau-`51PEOQ;61cI;sjAg(N!)$ z;SYTrQ84;-~2R^i4txfK>Z^w4Mu5<$s7Xq@E&H1@HA3{{TH~KMU>u0RI3^ zsTtGNv*YcoD|Vhr{{S2sISFDX{k!YZ{{Rf_(SPvrg;ZlPj^pjEIdur5HZ94sJow>{;>qq0OiB5e; z(%ur%NmqD|VhLTtTB_|zkk}o90kt%y@Dk3iXMyBUSdH9`KRVV0rs;Y33U+*JE?Hhe z=r3iMJFjcfPN!(Y<7qj@s;m%6RZxHuS3K%3Q&3b?^~C3Qq_=G_`a;1p0xjxsjt(`u zQHhgnhO|~lq_#vJAO_!YqmG-i8cV!$(|Q7tkUjMRp^XKqCfUbftG<}K0aY7G>5Q=8 z>l~YC)fAI8ze-$g1EYhQ)iR==OaVC2~kKM zmZ2SY3=jeDqua{8zV}n~ZX-sVIyO58UE}=88pvxrEc{Z})z$w1w5IVrW>QKTdN>P5 zAwYp6+ngV4mT&(69FFu#2uOR2Oca{vBOFx0_9Hcx#}T4=Pv+?R9>@h6fo3BZ4^@)&jP4 z7mUA#-xg{*zrcQ>o2fb`vT)ZaOYGoq}sAo}vPiN--W-0-$zNoab4glZvKX;@h-l z4wnA_XrE3{)kR0*KSI>><27ep$aAzlU1fEcg1#31r$8 zBzh@_+w#=sJpTZMe%cVdei*tBs;&`5bh_MOrhZvtpCQh1&frFdE?ilA6Xk+ zqLSlO6@teU)e7oPGIPcTKEs^p{{Z0kf|h=n>lsqbWv8{nL{S@P8>W*PBX%-Ej(##n z#-(~N+S?{m9w6#)*%&QqyVl$lMmdCb?Emr>kt*AjRCUU#gWM4v$ zpYP7HN={DMp(<2H>NtOi(@dvqi1|~saxh2v>&K50?^eIVXZu}xPsQp9WIYMgk;!W6 zvkYZ~V1o`s+nafGaeNI=a4x60N>wOGw}=feNL_L zEGuoQrk<&4BxwO3ODi${bWM)=ZKv(}YTQk7r_&O5R*k`Cxh+*J6;xHRR$HfmXQ8Qq z)!?a##^sVW1cIyxA%;O3CAoCHi>PhOEymjgT)<<@DTx#34e)=qycg-}uf=!ar)9IH z?p3w@7LpkuwA^UEzJ)m?n9z{X0(l}QT;${(K6-Qgr5-9+qK2QS`~|gjQ;ubp$8oKr zEIr2AW0y|wPW`(V)r;)(-mk)LnwFtY`@d_d#|P?du^}Vu@r5M*x?uFnQ1t3YM`bT( zk`O=od8jW-Hh;srC2~j4eR-&V+LsgBMLk#HkE$bcfYG(oNgh7LQmF^rYQO&gnvcZC zjZoBEJPiCibtS5^px$HAVNW

OcBGYJ__MOyMBYnRj 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

01Ow8Y@Grv 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, + }, + ], + }, + ); + + 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 */} +
+