Files
canvas-frontend/src/state/store.ts
T
shad 071166cae1 feat(approvals): tenant-scoped queue (no cross-tenant work-items)
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.
2026-06-14 16:01:54 +04:00

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;
}