1. Approvals tenantId hydration race
loadQueue used to fall back to 'show all rows' when tenantId was
null. Now early-returns [] until tenantId hydrates, and the effect
depends on tenantId so it reloads once auth completes.
2. Settings persona quick-switch threw 'Password required'
signIn() in Settings called loginAs(email) without options.method.
Now explicit { method: 'dev' } so the documented dev-login path
is used.
189 lines
8.3 KiB
TypeScript
189 lines
8.3 KiB
TypeScript
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 tenantId = useApp((s) => s.tenantId);
|
|
const loadQueue = async () => {
|
|
if (!tenantId) {
|
|
setItems([]);
|
|
return;
|
|
}
|
|
try {
|
|
const rows = await api.workItems();
|
|
const scoped = rows.filter((it) => (it as any).tenant_id === tenantId);
|
|
setItems(scoped.map((it) => ({ ...it, age_label: ageLabel(it) })));
|
|
} catch (err: any) {
|
|
pushToast("err", `Queue failed: ${err.message}`);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { void loadQueue(); }, [tenantId]);
|
|
|
|
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) {
|
|
const msg = err.message || "";
|
|
if (/runtime-values|action_execution_failed/i.test(msg)) {
|
|
pushToast("err", "Backend can't accept this action yet — runtime-values isn't reachable. The frontend sent the right shape.");
|
|
} else {
|
|
pushToast("err", `Action failed: ${msg.slice(0, 100)}`);
|
|
}
|
|
} 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>
|
|
);
|
|
}
|