feat(approvals): real work-item queue + active-step actions wired to EA2

New Approvals scene at landing chip + scene route. Pulls live work
items from /api/ea2/work-items?view=all and renders:

- Status counter (running/waiting/blocked)
- Hub filter (chip row, dynamic from response)
- Queue list (display_name + age + active step + hub + requester)
- Selected case detail: active step, dispatch kind, form fields,
  available actions

Clicking an action POSTs to /api/runtime/transactions/<id>/actions/<id>
with the signed-in user as actor and refreshes both the transaction
and the queue. This proves runtime EA2 writes are working end-to-end
beyond just the wizard.
This commit is contained in:
2026-06-14 15:57:23 +04:00
parent e52c4a7d53
commit 224c259bfd
5 changed files with 217 additions and 1 deletions
+2
View File
@@ -13,6 +13,7 @@ import Agent from "./scenes/Agent";
import Hub from "./scenes/Hub"; import Hub from "./scenes/Hub";
import GeoAttendance from "./scenes/GeoAttendance"; import GeoAttendance from "./scenes/GeoAttendance";
import Explainer from "./scenes/Explainer"; import Explainer from "./scenes/Explainer";
import Approvals from "./scenes/Approvals";
import CommandBar from "./components/CommandBar"; import CommandBar from "./components/CommandBar";
import Toaster from "./components/Toaster"; import Toaster from "./components/Toaster";
import Console from "./components/Console"; import Console from "./components/Console";
@@ -167,6 +168,7 @@ export default function App() {
{scene === "hub-it" && <Hub hub="it" />} {scene === "hub-it" && <Hub hub="it" />}
{scene === "geo-attendance" && <GeoAttendance />} {scene === "geo-attendance" && <GeoAttendance />}
{scene === "explainer" && <Explainer />} {scene === "explainer" && <Explainer />}
{scene === "approvals" && <Approvals />}
{scene === "settings" && <Settings />} {scene === "settings" && <Settings />}
</div> </div>
+36
View File
@@ -2026,3 +2026,39 @@ select.studio-input { background: var(--bp-paper); }
width: 6px; height: 6px; border-radius: 50%; width: 6px; height: 6px; border-radius: 50%;
background: var(--bp-amber); background: var(--bp-amber);
} }
.approvals-scene { padding: 24px; max-width: 1400px; margin: 0 auto; background: var(--bp-paper); min-height: calc(100vh - 60px); }
.approvals-head { margin-bottom: 20px; max-width: 880px; }
.approvals-intro { font-size: 13px; color: var(--bp-muted); line-height: 1.5; margin-top: 6px; }
.approvals-stats { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.approvals-stat { font-family: var(--bp-mono); font-size: 11px; padding: 4px 10px; border: 1px solid var(--bp-navy); }
.approvals-stat-running { background: color-mix(in srgb, var(--bp-amber) 25%, var(--bp-paper)); }
.approvals-stat-waiting { color: var(--bp-muted); }
.approvals-stat-blocked { background: color-mix(in srgb, #c25555 20%, var(--bp-paper)); }
.approvals-filter-row { display: flex; gap: 6px; margin-top: 12px; flex-wrap: wrap; }
.approvals-split { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; }
.approvals-queue, .approvals-detail { border: 1px solid var(--bp-navy); padding: 16px; background: var(--bp-paper); display: flex; flex-direction: column; gap: 10px; }
.approvals-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; max-height: 70vh; overflow-y: auto; }
.approvals-row { width: 100%; text-align: left; padding: 10px 12px; background: var(--bp-paper); border: 1px solid color-mix(in srgb, var(--bp-navy) 18%, transparent); color: var(--bp-navy); cursor: pointer; display: flex; flex-direction: column; gap: 4px; }
.approvals-row:hover { background: color-mix(in srgb, var(--bp-amber) 12%, var(--bp-paper)); }
.approvals-row.active { background: color-mix(in srgb, var(--bp-amber) 28%, var(--bp-paper)); border-color: var(--bp-navy); }
.approvals-row-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; }
.approvals-row-title { font-size: 13px; font-weight: 700; }
.approvals-row-age { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); flex-shrink: 0; }
.approvals-row-meta { display: flex; gap: 8px; flex-wrap: wrap; font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); }
.approvals-row-step { color: var(--bp-navy); }
.approvals-row-hub { padding: 1px 6px; border: 1px solid color-mix(in srgb, var(--bp-navy) 30%, transparent); }
.approvals-card { padding: 14px; background: var(--bp-paper); border: 1px solid var(--bp-navy); display: flex; flex-direction: column; gap: 10px; }
.approvals-card-title { font-size: 14px; font-weight: 700; color: var(--bp-navy); display: flex; align-items: center; gap: 6px; }
.approvals-card-meta { font-family: var(--bp-mono); font-size: 11px; color: var(--bp-muted); }
.approvals-fields { padding: 8px 0; border-top: 1px solid color-mix(in srgb, var(--bp-navy) 20%, transparent); }
.approvals-fields-label { font-family: var(--bp-mono); font-size: 10px; letter-spacing: 0.08em; color: var(--bp-muted); margin-bottom: 4px; }
.approvals-fields ul { list-style: none; padding: 0; margin: 0; font-size: 12px; }
.approvals-fields li { padding: 3px 0; }
.approvals-field-type { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); }
.approvals-actions { display: flex; gap: 8px; flex-wrap: wrap; padding-top: 8px; border-top: 1px solid color-mix(in srgb, var(--bp-navy) 20%, transparent); }
@media (max-width: 900px) {
.approvals-split { grid-template-columns: 1fr; }
.approvals-list { max-height: 50vh; }
}
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useState } from "react";
import { useApp } from "../state/store";
import { api, type WorkItem, type RuntimeTransaction } from "../lib/api";
import { Branch, Layers, Pulse } from "../components/icons";
interface DecoratedWorkItem extends WorkItem {
age_label: string;
}
const HUB_LABELS: Record<string, string> = {
procurement: "Procurement",
hr: "People",
it: "IT",
manufacturing: "Manufacturing",
finance: "Finance",
};
function ageLabel(item: WorkItem): string {
const days = item.age_days ?? 0;
if (days === 0) return "today";
if (days === 1) return "1 day";
if (days < 14) return `${days} days`;
if (days < 60) return `${Math.floor(days / 7)} wk`;
return `${Math.floor(days / 30)} mo`;
}
export default function Approvals() {
const userEmail = useApp((s) => s.userEmail);
const actor = useApp((s) => s.actor);
const pushToast = useApp((s) => s.pushToast);
const setScene = useApp((s) => s.setScene);
const [items, setItems] = useState<DecoratedWorkItem[] | null>(null);
const [hubFilter, setHubFilter] = useState<string>("all");
const [activeKey, setActiveKey] = useState<string | null>(null);
const [tx, setTx] = useState<RuntimeTransaction | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const loadQueue = async () => {
try {
const rows = await api.workItems();
setItems(rows.map((it) => ({ ...it, age_label: ageLabel(it) })));
} catch (err: any) {
pushToast("err", `Queue failed: ${err.message}`);
}
};
useEffect(() => { void loadQueue(); }, []);
useEffect(() => {
if (!activeKey) return;
let cancelled = false;
api.transaction(activeKey).then((r) => { if (!cancelled) setTx(r); });
return () => { cancelled = true; };
}, [activeKey]);
const hubs = Array.from(new Set((items || []).map((it) => it.hub).filter((h): h is string => !!h)));
const visible = (items || []).filter((it) => hubFilter === "all" || it.hub === hubFilter).slice(0, 50);
const counts = { running: 0, waiting: 0, blocked: 0 };
for (const it of items || []) {
if (it.status === "running") counts.running++;
else if (it.status === "waiting") counts.waiting++;
else if (it.status === "blocked" || it.status === "stuck") counts.blocked++;
}
const handleAction = async (actionId: string) => {
if (!tx?.transaction_id || !actor) return;
setBusy(actionId);
try {
const res = await api.executeAction(tx.transaction_id, actionId, actor, {});
pushToast("ok", `Action recorded · ${res.status || "ok"}`);
const fresh = await api.transaction(tx.transaction_id);
setTx(fresh);
await loadQueue();
} catch (err: any) {
pushToast("err", `Action failed: ${err.message}`);
} finally {
setBusy(null);
}
};
return (
<div className="approvals-scene">
<header className="approvals-head">
<div className="mc-hero-eyebrow"><Layers size={12} /> Approvals · {userEmail.split("@")[0]}</div>
<h2 className="mc-hero-title">Work that needs you</h2>
<p className="approvals-intro">
Every running process instance for your tenant. Pick a row to see the active step and the actions available right now. Acting on a row writes the decision back to EA2.
</p>
<div className="approvals-stats">
<span className="approvals-stat approvals-stat-running">{counts.running} running</span>
<span className="approvals-stat approvals-stat-waiting">{counts.waiting} waiting</span>
{counts.blocked > 0 && <span className="approvals-stat approvals-stat-blocked">{counts.blocked} blocked</span>}
</div>
<div className="approvals-filter-row">
<button className={`hub-chip ${hubFilter === "all" ? "active" : ""}`} onClick={() => setHubFilter("all")}>All hubs</button>
{hubs.map((h) => (
<button key={h} className={`hub-chip ${hubFilter === h ? "active" : ""}`} onClick={() => setHubFilter(h)}>{HUB_LABELS[h] || h}</button>
))}
</div>
</header>
<div className="approvals-split">
<section className="approvals-queue">
<div className="hub-section-label">QUEUE · {visible.length}</div>
{items === null && <div className="hub-empty">Loading</div>}
{items !== null && visible.length === 0 && (
<div className="hub-empty">
Nothing waiting in this hub. Open the <button className="link-inline" onClick={() => setScene("studio")}>Process Studio</button> to publish a new flow, or check back later.
</div>
)}
<ul className="approvals-list">
{visible.map((it) => (
<li key={it.transaction_id}>
<button
className={`approvals-row ${it.transaction_id === activeKey ? "active" : ""}`}
onClick={() => setActiveKey(it.transaction_id)}
>
<div className="approvals-row-head">
<span className="approvals-row-title">{(it as any).display_name || it.business_subject || "Untitled case"}</span>
<span className="approvals-row-age">{it.age_label}</span>
</div>
<div className="approvals-row-meta">
<span className="approvals-row-step">{it.active_step_display_name || it.next_action || "—"}</span>
{it.hub && <span className="approvals-row-hub">{HUB_LABELS[it.hub] || it.hub}</span>}
{(it as any).requester_display_name && <span className="approvals-row-by">by {(it as any).requester_display_name}</span>}
</div>
</button>
</li>
))}
</ul>
</section>
<section className="approvals-detail">
<div className="hub-section-label">ACTIVE STEP</div>
{!activeKey && <div className="hub-empty">Select a case on the left to see its current step and actions.</div>}
{activeKey && tx && (
<div className="approvals-card">
<div className="approvals-card-title">
<Branch size={11} /> {tx.active_step?.display_name || "Active step"}
</div>
{tx.business_subject && <div className="approvals-card-meta">Subject · {tx.business_subject}</div>}
{(tx.active_step as any)?.dispatch_kind && (
<div className="approvals-card-meta">Dispatch · {(tx.active_step as any).dispatch_kind}</div>
)}
{(tx.active_step as any)?.form_fields && (tx.active_step as any).form_fields.length > 0 && (
<div className="approvals-fields">
<div className="approvals-fields-label">Form fields</div>
<ul>
{(tx.active_step as any).form_fields.slice(0, 6).map((f: any) => (
<li key={f.name}>{f.label || f.name} <span className="approvals-field-type">· {f.type}</span></li>
))}
</ul>
</div>
)}
<div className="approvals-actions">
{(tx.available_actions || []).map((a: any) => (
<button
key={a.id}
className={`btn ${a.kind === "approve" || a.kind === "submit" ? "btn-primary" : "btn-secondary"}`}
disabled={!a.enabled || busy === a.id}
onClick={() => handleAction(a.id)}
>
<Pulse size={11} /> {busy === a.id ? "Sending…" : a.display_label || a.id}
</button>
))}
{(!tx.available_actions || tx.available_actions.length === 0) && (
<div className="hub-empty">No actions available on this step.</div>
)}
</div>
</div>
)}
{activeKey && !tx && <div className="hub-empty">Loading case</div>}
</section>
</div>
</div>
);
}
+1
View File
@@ -84,6 +84,7 @@ export default function Landing() {
)} )}
<button className="hub-chip" onClick={() => setScene("agent")}>Command Assistant</button> <button className="hub-chip" onClick={() => setScene("agent")}>Command Assistant</button>
<button className="hub-chip" onClick={() => setScene("chat")}>Team Chat</button> <button className="hub-chip" onClick={() => setScene("chat")}>Team Chat</button>
<button className="hub-chip" onClick={() => setScene("approvals")}>Approvals queue</button>
<button className="hub-chip" onClick={() => setScene("explainer")}>What is FlowMaster?</button> <button className="hub-chip" onClick={() => setScene("explainer")}>What is FlowMaster?</button>
</div> </div>
+1 -1
View File
@@ -18,7 +18,7 @@ import { buildLiveScenariosFromApi } from "../lib/buildScenarios";
import { api, type ApiCall, type Actor } from "../lib/api"; import { api, type ApiCall, type Actor } from "../lib/api";
import type { ProcessScenario } from "../data/types"; import type { ProcessScenario } from "../data/types";
export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback" | "chat" | "agent" | "hub-procurement" | "hub-hr" | "hub-it" | "geo-attendance" | "explainer"; export type SceneId = "landing" | "mission" | "history" | "studio" | "settings" | "login" | "sso-callback" | "chat" | "agent" | "hub-procurement" | "hub-hr" | "hub-it" | "geo-attendance" | "explainer" | "approvals";
export type DataMode = "snapshot" | "live"; export type DataMode = "snapshot" | "live";
export type Theme = "dark" | "light"; export type Theme = "dark" | "light";