diff --git a/src/App.tsx b/src/App.tsx index de2c08d..440b2d2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -13,6 +13,7 @@ import Agent from "./scenes/Agent"; import Hub from "./scenes/Hub"; import GeoAttendance from "./scenes/GeoAttendance"; import Explainer from "./scenes/Explainer"; +import Approvals from "./scenes/Approvals"; import CommandBar from "./components/CommandBar"; import Toaster from "./components/Toaster"; import Console from "./components/Console"; @@ -167,6 +168,7 @@ export default function App() { {scene === "hub-it" && } {scene === "geo-attendance" && } {scene === "explainer" && } + {scene === "approvals" && } {scene === "settings" && } diff --git a/src/index.css b/src/index.css index cad2113..a6438ba 100644 --- a/src/index.css +++ b/src/index.css @@ -2026,3 +2026,39 @@ select.studio-input { background: var(--bp-paper); } width: 6px; height: 6px; border-radius: 50%; 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; } +} diff --git a/src/scenes/Approvals.tsx b/src/scenes/Approvals.tsx new file mode 100644 index 0000000..f0c25c4 --- /dev/null +++ b/src/scenes/Approvals.tsx @@ -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 = { + 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(null); + const [hubFilter, setHubFilter] = useState("all"); + const [activeKey, setActiveKey] = useState(null); + const [tx, setTx] = useState(null); + const [busy, setBusy] = useState(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 ( +
+
+
Approvals · {userEmail.split("@")[0]}
+

Work that needs you

+

+ 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. +

+
+ {counts.running} running + {counts.waiting} waiting + {counts.blocked > 0 && {counts.blocked} blocked} +
+
+ + {hubs.map((h) => ( + + ))} +
+
+ +
+
+
QUEUE · {visible.length}
+ {items === null &&
Loading…
} + {items !== null && visible.length === 0 && ( +
+ Nothing waiting in this hub. Open the to publish a new flow, or check back later. +
+ )} +
    + {visible.map((it) => ( +
  • + +
  • + ))} +
+
+ +
+
ACTIVE STEP
+ {!activeKey &&
Select a case on the left to see its current step and actions.
} + {activeKey && tx && ( +
+
+ {tx.active_step?.display_name || "Active step"} +
+ {tx.business_subject &&
Subject · {tx.business_subject}
} + {(tx.active_step as any)?.dispatch_kind && ( +
Dispatch · {(tx.active_step as any).dispatch_kind}
+ )} + {(tx.active_step as any)?.form_fields && (tx.active_step as any).form_fields.length > 0 && ( +
+
Form fields
+
    + {(tx.active_step as any).form_fields.slice(0, 6).map((f: any) => ( +
  • {f.label || f.name} · {f.type}
  • + ))} +
+
+ )} +
+ {(tx.available_actions || []).map((a: any) => ( + + ))} + {(!tx.available_actions || tx.available_actions.length === 0) && ( +
No actions available on this step.
+ )} +
+
+ )} + {activeKey && !tx &&
Loading case…
} +
+
+
+ ); +} diff --git a/src/scenes/Landing.tsx b/src/scenes/Landing.tsx index b965305..4b74a05 100644 --- a/src/scenes/Landing.tsx +++ b/src/scenes/Landing.tsx @@ -84,6 +84,7 @@ export default function Landing() { )} + diff --git a/src/state/store.ts b/src/state/store.ts index 13c1b0b..3520bcb 100644 --- a/src/state/store.ts +++ b/src/state/store.ts @@ -18,7 +18,7 @@ import { buildLiveScenariosFromApi } from "../lib/buildScenarios"; import { api, type ApiCall, type Actor } from "../lib/api"; 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 Theme = "dark" | "light";