// 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 { if (typeof localStorage === "undefined") return {}; try { const raw = localStorage.getItem(LS_KEY); if (!raw) return {}; return JSON.parse(raw) as Partial; } 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; refreshLive: () => Promise; 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; /** 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; /** 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) => Promise; startInstance: (defKey: string, businessSubject?: string) => Promise; } 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) => 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((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; }