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:
@@ -10,6 +10,7 @@ import Login from "./scenes/Login";
|
||||
import SsoCallback from "./scenes/SsoCallback";
|
||||
import Chat from "./scenes/Chat";
|
||||
import Agent from "./scenes/Agent";
|
||||
import Hub from "./scenes/Hub";
|
||||
import CommandBar from "./components/CommandBar";
|
||||
import Toaster from "./components/Toaster";
|
||||
import Console from "./components/Console";
|
||||
@@ -159,6 +160,9 @@ export default function App() {
|
||||
{scene === "studio" && <Wizard />}
|
||||
{scene === "chat" && <Chat />}
|
||||
{scene === "agent" && <Agent />}
|
||||
{scene === "hub-procurement" && <Hub hub="procurement" />}
|
||||
{scene === "hub-hr" && <Hub hub="hr" />}
|
||||
{scene === "hub-it" && <Hub hub="it" />}
|
||||
{scene === "settings" && <Settings />}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1937,3 +1937,25 @@ select.studio-input { background: var(--bp-paper); }
|
||||
.agent-scene { grid-template-columns: 1fr; height: auto; }
|
||||
.agent-sidebar { max-height: 240px; }
|
||||
}
|
||||
|
||||
.hub-scene { padding: 24px; max-width: 1200px; margin: 0 auto; background: var(--bp-paper); min-height: calc(100vh - 60px); }
|
||||
.hub-head { margin-bottom: 24px; }
|
||||
.hub-intro { font-size: 13px; color: var(--bp-muted); max-width: 720px; line-height: 1.5; margin-top: 6px; }
|
||||
.hub-section-label { font-family: var(--bp-mono); font-size: 10px; letter-spacing: 0.08em; color: var(--bp-muted); margin-bottom: 10px; text-transform: uppercase; }
|
||||
.hub-quick { margin-bottom: 32px; }
|
||||
.hub-quick-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
||||
.hub-quick-card { text-align: left; padding: 14px; background: var(--bp-paper); border: 1px solid var(--bp-navy); cursor: pointer; transition: background 0.1s; display: flex; flex-direction: column; gap: 6px; }
|
||||
.hub-quick-card:hover:not(:disabled) { background: color-mix(in srgb, var(--bp-amber) 15%, var(--bp-paper)); }
|
||||
.hub-quick-card:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.hub-quick-title { font-size: 12px; font-weight: 700; color: var(--bp-navy); display: flex; align-items: center; gap: 6px; }
|
||||
.hub-quick-meta { font-family: var(--bp-mono); font-size: 10px; color: var(--bp-muted); }
|
||||
.hub-catalogue-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
|
||||
.hub-flow-card { padding: 12px; background: var(--bp-paper); border: 1px solid var(--bp-navy); display: flex; flex-direction: column; gap: 8px; }
|
||||
.hub-flow-title { font-size: 12px; font-weight: 700; color: var(--bp-navy); display: flex; align-items: center; gap: 6px; }
|
||||
.hub-flow-desc { font-size: 11px; color: var(--bp-muted); line-height: 1.4; }
|
||||
.hub-empty { padding: 16px; font-size: 11px; color: var(--bp-muted); }
|
||||
.link-inline { background: transparent; border: 0; padding: 0; color: var(--bp-navy); text-decoration: underline; cursor: pointer; font: inherit; }
|
||||
|
||||
.hero-hub-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
|
||||
.hub-chip { padding: 8px 14px; background: var(--bp-paper); border: 1px solid var(--bp-navy); color: var(--bp-navy); font-family: var(--bp-mono); font-size: 11px; letter-spacing: 0.04em; cursor: pointer; transition: background 0.1s; }
|
||||
.hub-chip:hover { background: color-mix(in srgb, var(--bp-amber) 20%, var(--bp-paper)); }
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -75,6 +75,14 @@ export default function Landing() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="hero-hub-row">
|
||||
<button className="hub-chip" onClick={() => setScene("hub-procurement")}>Procurement Hub</button>
|
||||
<button className="hub-chip" onClick={() => setScene("hub-hr")}>People Hub</button>
|
||||
<button className="hub-chip" onClick={() => setScene("hub-it")}>IT Hub</button>
|
||||
<button className="hub-chip" onClick={() => setScene("agent")}>Talk to Pi</button>
|
||||
<button className="hub-chip" onClick={() => setScene("chat")}>Team Chat</button>
|
||||
</div>
|
||||
|
||||
<div className="hero-stats">
|
||||
<div className="stat">
|
||||
<Pulse size={12} />
|
||||
|
||||
Reference in New Issue
Block a user