feat(hubs): Procurement / People / IT hubs over real EA2 catalogue

Single Hub component parameterised by hub key. Each hub:
- Lists quick actions that look up a matching published process and
  start it as a real transaction
- Pulls the full published catalogue from /api/ea2/flow/processes and
  filters via a hub-specific regex (procurement|hr|it)
- Falls back to top-N when no matches, with a Studio CTA when empty

Landing surface gets a hero-hub row of chips: Procurement Hub, People
Hub, IT Hub, Talk to Pi, Team Chat. Same EA2-write doctrine as wizard.
This commit is contained in:
2026-06-14 12:47:24 +04:00
parent 47b1bf6998
commit b438e9a664
5 changed files with 248 additions and 0 deletions
+173
View File
@@ -0,0 +1,173 @@
import { useEffect, useState } from "react";
import { useApp } from "../state/store";
import { api } from "../lib/api";
import { wizardApi } from "../lib/wizardApi";
import { Branch, Layers, Pulse } from "../components/icons";
export type HubKey = "procurement" | "hr" | "it";
interface HubSpec {
title: string;
eyebrow: string;
intro: string;
match: RegExp;
quickActions: { label: string; subject: string; processHint: string }[];
}
const HUBS: Record<HubKey, HubSpec> = {
procurement: {
title: "Procurement Hub",
eyebrow: "Purchase requests, vendor approvals, three-way match",
intro:
"Everything a store manager needs to buy something — from a laptop to a forklift — and watch it move through the approval chain.",
match: /procure|purchase|vendor|po\b|payment/i,
quickActions: [
{ label: "Request a new laptop", subject: "store-204-laptop", processHint: "procurement" },
{ label: "Submit a quote for review", subject: "vendor-quote-Q3", processHint: "procurement" },
],
},
hr: {
title: "People Hub",
eyebrow: "Hiring, onboarding, leave, performance",
intro:
"All people movements run here. Start an onboarding for a new hire, file a leave request, or kick off a review cycle.",
match: /onboard|leave|people|hr\b|hiring|payroll/i,
quickActions: [
{ label: "Onboard a new hire", subject: "new-hire-2026Q3", processHint: "onboard" },
{ label: "Request annual leave", subject: "leave-7d-may", processHint: "leave" },
],
},
it: {
title: "IT Hub",
eyebrow: "Tickets, access requests, incident response",
intro:
"When something breaks, request access, or you need a new account — file it here and route it to the right on-call.",
match: /it\b|ticket|incident|access|account|service/i,
quickActions: [
{ label: "Open an incident", subject: "incident-store-204", processHint: "service" },
{ label: "Request system access", subject: "access-sap-RW", processHint: "access" },
],
},
};
interface PublishedFlow {
_key: string;
display_name: string;
description?: string;
name?: string;
}
async function loadPublishedFlows(): Promise<PublishedFlow[]> {
const res = await fetch(`${api.config.baseUrl}/api/ea2/flow/processes?limit=200`, {
headers: { Authorization: `Bearer ${sessionStorage.getItem("fm.mc.token.v1")}` },
});
if (!res.ok) throw new Error(`processes ${res.status}`);
const body = await res.json();
return ((body?.items || []) as any[])
.filter((it) => it.status === "published" && it.kind === "definition" && it.display_name)
.map((it) => ({
_key: it._key,
display_name: it.display_name,
description: it.description,
name: it.name,
}));
}
export default function Hub({ hub }: { hub: HubKey }) {
const spec = HUBS[hub];
const pushToast = useApp((s) => s.pushToast);
const setScene = useApp((s) => s.setScene);
const [flows, setFlows] = useState<PublishedFlow[] | null>(null);
const [busy, setBusy] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
loadPublishedFlows()
.then((rows) => !cancelled && setFlows(rows))
.catch((err) => !cancelled && pushToast("err", `Catalogue failed: ${err.message}`));
return () => {
cancelled = true;
};
}, [hub]);
const matching = (flows || []).filter((f) => spec.match.test(`${f.display_name} ${f.description || ""} ${f.name || ""}`)).slice(0, 8);
const fallback = (flows || []).slice(0, 6);
const visible = matching.length > 0 ? matching : fallback;
const startInstance = async (flowKey: string, subject: string, label: string) => {
setBusy(flowKey);
try {
const res = await wizardApi.startInstance(flowKey, subject);
pushToast("ok", `Started ${label}${subject ? ` for ${subject}` : ""}${res.transaction_id?.slice(0, 8) || "ok"}`);
setScene("mission");
} catch (err: any) {
pushToast("err", `Start failed: ${err.message}`);
} finally {
setBusy(null);
}
};
const tryQuickAction = async (label: string, subject: string, processHint: string) => {
if (!flows) return;
const match = flows.find((f) =>
new RegExp(processHint, "i").test(`${f.display_name} ${f.description || ""} ${f.name || ""}`)
);
if (!match) {
pushToast("err", `No published process matching "${processHint}". Build one in Studio.`);
return;
}
await startInstance(match._key, subject, label);
};
return (
<div className="hub-scene">
<header className="hub-head">
<div className="mc-hero-eyebrow"><Layers size={12} /> {spec.eyebrow}</div>
<h2 className="mc-hero-title">{spec.title}</h2>
<p className="hub-intro">{spec.intro}</p>
</header>
<section className="hub-quick">
<div className="hub-section-label">QUICK ACTIONS</div>
<div className="hub-quick-grid">
{spec.quickActions.map((qa) => (
<button
key={qa.label}
className="hub-quick-card"
onClick={() => tryQuickAction(qa.label, qa.subject, qa.processHint)}
disabled={!flows}
>
<div className="hub-quick-title"><Pulse size={12} /> {qa.label}</div>
<div className="hub-quick-meta">Looks up "{qa.processHint}" in your catalogue</div>
</button>
))}
</div>
</section>
<section className="hub-catalogue">
<div className="hub-section-label">RELATED PROCESSES</div>
{flows === null && <div className="hub-empty">Loading catalogue</div>}
{flows !== null && visible.length === 0 && (
<div className="hub-empty">
No matching published processes yet. Open the <button className="link-inline" onClick={() => setScene("studio")}>Process Studio</button> to build one.
</div>
)}
<div className="hub-catalogue-grid">
{visible.map((f) => (
<div key={f._key} className="hub-flow-card">
<div className="hub-flow-title"><Branch size={11} /> {f.display_name}</div>
{f.description && <div className="hub-flow-desc">{f.description.slice(0, 160)}</div>}
<button
className="btn btn-secondary"
disabled={busy === f._key}
onClick={() => startInstance(f._key, "", f.display_name)}
>
{busy === f._key ? "Starting…" : "Start an instance"}
</button>
</div>
))}
</div>
</section>
</div>
);
}