Plumbed me.tenant_id through api.ping + loginAs into the store as tenantId. Approvals.loadQueue filters /api/ea2/work-items by tenant_id matching the signed-in user so the queue only shows actionable rows. Probe of EA2 work-items returned 73/80 in canvas's tenant (a0000000-...), 6 in hms_dev, 1 in hub-cnt-f56ce0 -- those 7 are now hidden.
400 lines
13 KiB
TypeScript
400 lines
13 KiB
TypeScript
// Global UI state for Mission Control.
|
|
import { create } from "zustand";
|
|
import { liveScenarios as snapshotLive } from "../data/live";
|
|
import { isDevArtefact } from "../lib/flowCuration";
|
|
const syntheticScenarios: any[] = [];
|
|
|
|
function curateScenarios(rows: ProcessScenario[]): ProcessScenario[] {
|
|
return rows.filter((s) => !isDevArtefact({ _key: s.defKey, display_name: s.defName, name: s.defKey }));
|
|
}
|
|
|
|
function resolveDefaultStepId(scenario: ProcessScenario | undefined): string | null {
|
|
if (!scenario) return null;
|
|
const ids = new Set(scenario.steps.map((s) => s.id));
|
|
if (scenario.defaultStepId && ids.has(scenario.defaultStepId)) return scenario.defaultStepId;
|
|
return scenario.steps[0]?.id ?? null;
|
|
}
|
|
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" | "approvals";
|
|
export type DataMode = "snapshot" | "live";
|
|
export type Theme = "dark" | "light";
|
|
|
|
export interface Toast {
|
|
id: number;
|
|
kind: "info" | "ok" | "warn" | "err";
|
|
msg: string;
|
|
}
|
|
|
|
const LS_KEY = "fm.mc.prefs.v1";
|
|
|
|
interface Prefs {
|
|
mode: DataMode;
|
|
scenarioId: string;
|
|
email: string;
|
|
theme: Theme;
|
|
pollEverySec: number;
|
|
consoleOpen: boolean;
|
|
recents: string[];
|
|
}
|
|
|
|
function loadPrefs(): Partial<Prefs> {
|
|
if (typeof localStorage === "undefined") return {};
|
|
try {
|
|
const raw = localStorage.getItem(LS_KEY);
|
|
if (!raw) return {};
|
|
return JSON.parse(raw) as Partial<Prefs>;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function savePrefs(p: Prefs) {
|
|
if (typeof localStorage === "undefined") return;
|
|
try { localStorage.setItem(LS_KEY, JSON.stringify(p)); } catch { /* swallow */ }
|
|
}
|
|
|
|
interface AppState {
|
|
scene: SceneId;
|
|
setScene: (s: SceneId) => void;
|
|
|
|
mode: DataMode;
|
|
setMode: (m: DataMode) => Promise<void>;
|
|
refreshLive: () => Promise<void>;
|
|
|
|
liveLoading: boolean;
|
|
liveError: string | null;
|
|
liveTotals: { workItems: number; distinctDefs: number } | null;
|
|
liveFetchedAt: number | null;
|
|
|
|
scenarios: ProcessScenario[];
|
|
|
|
scenarioId: string;
|
|
setScenarioId: (id: string) => void;
|
|
|
|
selectedStepId: string | null;
|
|
setSelectedStepId: (id: string | null) => void;
|
|
|
|
cmdOpen: boolean;
|
|
setCmdOpen: (v: boolean) => void;
|
|
|
|
recents: string[];
|
|
pushRecent: (label: string) => void;
|
|
|
|
inspectorTab: "overview" | "rules" | "evidence" | "raw" | "runs";
|
|
setInspectorTab: (t: AppState["inspectorTab"]) => void;
|
|
|
|
toasts: Toast[];
|
|
pushToast: (kind: Toast["kind"], msg: string) => void;
|
|
dismissToast: (id: number) => void;
|
|
|
|
/** Identity for action execution (loaded after first successful me()). */
|
|
actor: Actor | null;
|
|
userEmail: string;
|
|
userDisplayName: string | null;
|
|
tenantId: string | null;
|
|
isAuthed: boolean;
|
|
setUserEmail: (e: string) => void;
|
|
loginAs: (email: string, password?: string, options?: { method?: "password" | "dev" }) => Promise<void>;
|
|
|
|
/** Live polling — lightweight tick that only refreshes the active
|
|
* scenario's work-items + headline runtime. Full refresh is manual. */
|
|
pollEverySec: number;
|
|
setPollEverySec: (n: number) => void;
|
|
pollTimer: number | null;
|
|
startPolling: () => void;
|
|
stopPolling: () => void;
|
|
pollLiveTick: () => Promise<void>;
|
|
|
|
/** API call log */
|
|
consoleOpen: boolean;
|
|
setConsoleOpen: (v: boolean) => void;
|
|
apiLog: ApiCall[];
|
|
clearApiLog: () => void;
|
|
|
|
/** Theme */
|
|
theme: Theme;
|
|
setTheme: (t: Theme) => void;
|
|
|
|
/** Action handlers (real backend mutations). */
|
|
executeAction: (txId: string, actionId: string, values?: Record<string, unknown>) => Promise<void>;
|
|
startInstance: (defKey: string, businessSubject?: string) => Promise<string | null>;
|
|
}
|
|
|
|
const SNAPSHOT_SCENARIOS: ProcessScenario[] = curateScenarios([...snapshotLive, ...syntheticScenarios]);
|
|
const initialScenario = SNAPSHOT_SCENARIOS[0];
|
|
|
|
let toastSeq = 0;
|
|
const MAX_API_LOG = 200;
|
|
|
|
async function runLiveFetch(
|
|
set: (partial: Partial<AppState>) => void,
|
|
get: () => AppState,
|
|
) {
|
|
const prevMode = get().mode;
|
|
set({ mode: "live", liveLoading: true, liveError: null });
|
|
try {
|
|
const ping = await api.ping();
|
|
if (!ping.ok) throw new Error(ping.reason || "backend unreachable");
|
|
if (ping.user_id) {
|
|
set({
|
|
actor: { mode: "direct_user", user_id: ping.user_id },
|
|
userEmail: ping.user ?? get().userEmail,
|
|
tenantId: ping.tenant_id ?? get().tenantId,
|
|
});
|
|
}
|
|
const { scenarios, workItems, distinctDefs } = await buildLiveScenariosFromApi();
|
|
const curatedLive = curateScenarios([...scenarios, ...syntheticScenarios]);
|
|
const merged = curatedLive.length > 0 ? curatedLive : SNAPSHOT_SCENARIOS;
|
|
const first = merged[0];
|
|
const currentId = get().scenarioId;
|
|
const currentStepId = get().selectedStepId;
|
|
const stillThere = merged.find((s) => s.id === currentId);
|
|
const stepStillThere = stillThere?.steps.some((st) => st.id === currentStepId);
|
|
const nextScenario = stillThere ?? first;
|
|
set({
|
|
scenarios: merged,
|
|
liveTotals: { workItems: workItems.length, distinctDefs },
|
|
liveFetchedAt: Date.now(),
|
|
liveLoading: false,
|
|
scenarioId: nextScenario?.id ?? currentId,
|
|
selectedStepId: stepStillThere ? currentStepId : resolveDefaultStepId(nextScenario),
|
|
});
|
|
get().pushToast(
|
|
"ok",
|
|
prevMode === "live"
|
|
? `Refreshed · ${scenarios.length} live processes`
|
|
: `Live mode · ${scenarios.length} live processes`,
|
|
);
|
|
} catch (e) {
|
|
set({ liveLoading: false, liveError: (e as Error).message, mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS });
|
|
get().pushToast("err", `Live mode failed: ${(e as Error).message.slice(0, 80)} — falling back to snapshot`);
|
|
}
|
|
}
|
|
|
|
const prefs = loadPrefs();
|
|
|
|
export const useApp = create<AppState>((set, get) => {
|
|
const persist = () => {
|
|
const s = get();
|
|
savePrefs({
|
|
mode: s.mode,
|
|
scenarioId: s.scenarioId,
|
|
email: s.userEmail,
|
|
theme: s.theme,
|
|
pollEverySec: s.pollEverySec,
|
|
consoleOpen: s.consoleOpen,
|
|
recents: s.recents,
|
|
});
|
|
};
|
|
|
|
return {
|
|
scene: "landing",
|
|
setScene: (scene) => set({ scene }),
|
|
|
|
mode: prefs.mode ?? "snapshot",
|
|
setMode: async (mode) => {
|
|
if (mode === "snapshot") {
|
|
if (get().mode === "snapshot") return;
|
|
set({ mode: "snapshot", scenarios: SNAPSHOT_SCENARIOS, liveError: null, liveLoading: false });
|
|
get().stopPolling();
|
|
get().pushToast("info", "Switched to snapshot mode (bundled JSON)");
|
|
persist();
|
|
return;
|
|
}
|
|
await runLiveFetch(set, get);
|
|
get().startPolling();
|
|
persist();
|
|
},
|
|
refreshLive: async () => {
|
|
if (get().mode !== "live") return;
|
|
await runLiveFetch(set, get);
|
|
},
|
|
|
|
liveLoading: false,
|
|
liveError: null,
|
|
liveTotals: null,
|
|
liveFetchedAt: null,
|
|
|
|
scenarios: SNAPSHOT_SCENARIOS,
|
|
|
|
scenarioId: (prefs.scenarioId && SNAPSHOT_SCENARIOS.some((s) => s.id === prefs.scenarioId)) ? prefs.scenarioId : initialScenario?.id ?? "",
|
|
setScenarioId: (id) => {
|
|
const sc = get().scenarios.find((s) => s.id === id);
|
|
set({
|
|
scenarioId: id,
|
|
selectedStepId: sc?.defaultStepId ?? null,
|
|
});
|
|
if (sc) get().pushRecent(`Scenario: ${sc.family.label}`);
|
|
persist();
|
|
},
|
|
|
|
selectedStepId: initialScenario?.defaultStepId ?? null,
|
|
setSelectedStepId: (id) => set({ selectedStepId: id }),
|
|
|
|
cmdOpen: false,
|
|
setCmdOpen: (cmdOpen) => set({ cmdOpen }),
|
|
|
|
recents: prefs.recents ?? [],
|
|
pushRecent: (label) => {
|
|
set((s) => ({ recents: [label, ...s.recents.filter((r) => r !== label)].slice(0, 8) }));
|
|
persist();
|
|
},
|
|
|
|
inspectorTab: "overview",
|
|
setInspectorTab: (inspectorTab) => set({ inspectorTab }),
|
|
|
|
toasts: [],
|
|
pushToast: (kind, msg) => {
|
|
const id = ++toastSeq;
|
|
set((s) => ({ toasts: [...s.toasts, { id, kind, msg }] }));
|
|
setTimeout(() => get().dismissToast(id), 4500);
|
|
},
|
|
dismissToast: (id) =>
|
|
set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
|
|
|
actor: null,
|
|
userEmail: prefs.email ?? "dev@flow-master.ai",
|
|
userDisplayName: null,
|
|
tenantId: null,
|
|
isAuthed: typeof sessionStorage !== "undefined" && !!sessionStorage.getItem("fm.mc.token.v1"),
|
|
setUserEmail: (e) => { set({ userEmail: e }); persist(); },
|
|
loginAs: async (email, password, options) => {
|
|
api.clearToken();
|
|
api.config = { ...api.config, email };
|
|
set({ userEmail: email });
|
|
persist();
|
|
try {
|
|
if (options?.method === "dev") {
|
|
await api.devLogin(email);
|
|
} else {
|
|
if (!password) throw new Error("Password required");
|
|
await api.passwordLogin(email, password);
|
|
}
|
|
const me = await api.me();
|
|
set({
|
|
actor: { mode: "direct_user", user_id: me.user_id },
|
|
userDisplayName: me.display_name ?? null,
|
|
tenantId: me.tenant_id ?? null,
|
|
isAuthed: true,
|
|
});
|
|
get().pushToast("ok", `Signed in as ${me.email}`);
|
|
if (get().mode === "live") await get().refreshLive();
|
|
} catch (e) {
|
|
get().pushToast("err", `Login failed: ${(e as Error).message.slice(0, 80)}`);
|
|
throw e;
|
|
}
|
|
},
|
|
|
|
pollEverySec: prefs.pollEverySec ?? 30,
|
|
setPollEverySec: (n) => {
|
|
set({ pollEverySec: Math.max(5, Math.min(300, n)) });
|
|
persist();
|
|
if (get().pollTimer) { get().stopPolling(); get().startPolling(); }
|
|
},
|
|
pollTimer: null,
|
|
startPolling: () => {
|
|
if (typeof window === "undefined") return;
|
|
const existing = get().pollTimer;
|
|
if (existing) window.clearInterval(existing);
|
|
const id = window.setInterval(() => {
|
|
const s = get();
|
|
if (s.mode !== "live" || s.liveLoading) return;
|
|
void s.pollLiveTick();
|
|
}, get().pollEverySec * 1000);
|
|
set({ pollTimer: id });
|
|
},
|
|
stopPolling: () => {
|
|
if (typeof window === "undefined") return;
|
|
const id = get().pollTimer;
|
|
if (id) window.clearInterval(id);
|
|
set({ pollTimer: null });
|
|
},
|
|
pollLiveTick: async () => {
|
|
const s = get();
|
|
if (s.mode !== "live" || s.liveLoading) return;
|
|
try {
|
|
const workItems = await api.workItems();
|
|
const sc = s.scenarios.find((x) => x.id === s.scenarioId);
|
|
let headlineRt = null;
|
|
if (sc?.headlineTx) {
|
|
headlineRt = await api.transaction(sc.headlineTx);
|
|
}
|
|
const updatedScenarios = s.scenarios.map((scen) => {
|
|
if (!scen.live) return scen;
|
|
const myCases = workItems.filter((w) => w.definition_key === scen.defKey);
|
|
const newQueue = myCases.slice(0, 8).map((c, i) => ({
|
|
id: `${c.transaction_id || scen.defKey}-${i}`,
|
|
stepId: scen.queue[i]?.stepId ?? scen.defaultStepId,
|
|
title: `${c.short_id ?? c.transaction_id?.slice(0, 8) ?? "case"} · ${c.active_step_display_name || c.next_action || "case"}`,
|
|
waitingOn: (c.status === "running" || c.status === "waiting_for_user") ? "approval" as const
|
|
: c.status === "waiting_for_agent" ? "agent" as const
|
|
: "input" as const,
|
|
ageDays: c.age_days ?? 0,
|
|
status: c.status,
|
|
}));
|
|
if (scen.id === s.scenarioId && headlineRt) {
|
|
return { ...scen, queue: newQueue, raw: { ...(scen.raw as object), headlineRt } };
|
|
}
|
|
return { ...scen, queue: newQueue };
|
|
});
|
|
set({ scenarios: updatedScenarios, liveTotals: { workItems: workItems.length, distinctDefs: s.liveTotals?.distinctDefs ?? 0 }, liveFetchedAt: Date.now() });
|
|
} catch {
|
|
// silent — full refresh will retry
|
|
}
|
|
},
|
|
|
|
consoleOpen: prefs.consoleOpen ?? false,
|
|
setConsoleOpen: (v) => { set({ consoleOpen: v }); persist(); },
|
|
apiLog: [],
|
|
clearApiLog: () => set({ apiLog: [] }),
|
|
|
|
theme: prefs.theme ?? "dark",
|
|
setTheme: (t) => {
|
|
set({ theme: t });
|
|
document.documentElement.dataset.theme = t;
|
|
persist();
|
|
},
|
|
|
|
executeAction: async (txId, actionId, values = {}) => {
|
|
const a = get().actor;
|
|
if (!a?.user_id) {
|
|
get().pushToast("warn", "Sign in with Live mode first to act on real transactions.");
|
|
return;
|
|
}
|
|
try {
|
|
const r = await api.executeAction(txId, actionId, a, values);
|
|
get().pushToast("ok", `Action "${actionId}" → ${r.status}${r.next_step?.display_name ? ` · next: ${r.next_step.display_name}` : ""}`);
|
|
if (get().mode === "live") await get().refreshLive();
|
|
} catch (e) {
|
|
get().pushToast("err", `Action failed: ${(e as Error).message.slice(0, 140)}`);
|
|
}
|
|
},
|
|
|
|
startInstance: async (defKey, businessSubject) => {
|
|
try {
|
|
const tx = await api.startTransaction(defKey, businessSubject);
|
|
get().pushToast("ok", `Started ${tx.transaction_id.slice(0, 8)} · ${tx.active_step?.display_name ?? tx.status}`);
|
|
if (get().mode === "live") await get().refreshLive();
|
|
return tx.transaction_id;
|
|
} catch (e) {
|
|
get().pushToast("err", `Start failed: ${(e as Error).message.slice(0, 140)}`);
|
|
return null;
|
|
}
|
|
},
|
|
};
|
|
});
|
|
|
|
export const scenarioById = (id: string): ProcessScenario | undefined =>
|
|
useApp.getState().scenarios.find((s) => s.id === id);
|
|
|
|
api.onCall((c) => {
|
|
useApp.setState((s) => ({ apiLog: [...s.apiLog, c].slice(-MAX_API_LOG) }));
|
|
});
|
|
|
|
if (typeof document !== "undefined") {
|
|
document.documentElement.dataset.theme = useApp.getState().theme;
|
|
}
|