/* global React, ReactDOM, PRShell, PRViews, PRPassport, PRDash, PRModal, PRUsers */ const { useState, useEffect, useRef, useMemo } = React; const { Sidebar, Topbar, PageHead } = PRShell; const { TableView, KanbanView, CardsView } = PRViews; const { PassportView } = PRPassport; const { DashboardView } = PRDash; const { ModalRouter } = PRModal; const { UsersView } = PRUsers; const PAGE_META = { registry: { title: "Registry", sub: "Single source of truth for every R&D hypothesis. Lives free of procurement until handed off at Scale.", crumbs: ["Workspace", "Registry"] }, kanban: { title: "Pipeline", sub: "Hypotheses by funnel stage. Advance a card when the gate criteria are met.", crumbs: ["Workspace", "Pipeline"] }, cards: { title: "Cards", sub: "Grid view, optimised for scanning core-loops at a glance.", crumbs: ["Workspace", "Cards"] }, dashboard: { title: "Dashboard", sub: "Health of the pipeline: throughput, conversion, decision velocity.", crumbs: ["Workspace", "Dashboard"] }, passport: { title: "R&D Passport", sub: "Full record of a hypothesis — what travels into procurement on Scale.", crumbs: ["Workspace", "Registry", "Passport"] }, decision: { title: "Awaiting decision", sub: "Hypotheses that finished Cold Test and need a Kill / Pivot / Scale call.", crumbs: ["Views", "Awaiting decision"] }, scaled: { title: "Scaled", sub: "Handed off to production. Soft-launch tracking lives elsewhere.", crumbs: ["Views", "Scaled"] }, archive: { title: "Archive", sub: "Killed hypotheses. Searchable so the same idea isn't tested twice.", crumbs: ["Views", "Archive"] }, users: { title: "Users & roles", sub: "Manage team members, assign roles and permissions.", crumbs: ["Admin", "Users & roles"] }, }; // ── API helpers ─────────────────────────────────────────────────────────────── async function apiFetch(url, opts = {}) { const resp = await fetch(url, { headers: { "Content-Type": "application/json" }, ...opts }); if (resp.status === 401) { window.location.href = "/login"; throw new Error("Unauthorized"); } return resp; } // ── Hash routing ────────────────────────────────────────────────────────────── function parseHash() { const hash = window.location.hash.replace(/^#\/?/, ""); const parts = hash.split("/").filter(Boolean); if (!parts.length) return { section: "registry", id: null }; return { section: parts[0], id: parts[1] || null }; } // ── Overlay panel ───────────────────────────────────────────────────────────── function OverlayPanel({ openId, data, user, onClose, onDecide, onAdvance, onRefresh }) { const [visible, setVisible] = useState(false); useEffect(() => { if (openId) { // Next frame so CSS transition fires requestAnimationFrame(() => setVisible(true)); } else { setVisible(false); } }, [openId]); useEffect(() => { if (!openId) return; const onKey = (e) => { if (e.key === "Escape") onClose(); }; document.addEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey); }, [openId, onClose]); if (!openId) return null; return ( <>
{openId}
); } // ── App ─────────────────────────────────────────────────────────────────────── function App() { const [active, setActive] = useState("registry"); const [view, setView] = useState("table"); const [theme, setThemeState] = useState(() => localStorage.getItem("theme") || "light"); const [data, setData] = useState(null); const [activity, setActivity] = useState([]); const [user, setUser] = useState(null); const [loading, setLoading] = useState(true); const [modal, setModal] = useState(null); const [passportId, setPassportId] = useState(null); const [openId, setOpenId] = useState(null); const [filters, setFilters] = useState({ set: "all", stage: "", genre: "", author: "", risk: "" }); useEffect(() => { document.documentElement.setAttribute("data-theme", theme); localStorage.setItem("theme", theme); }, [theme]); // ── Hash routing ──────────────────────────────────────────────────────────── const applyHash = () => { const { section, id } = parseHash(); const sec = PAGE_META[section] ? section : "registry"; setActive(sec); if (sec === "passport" && id) setPassportId(id); else if (sec !== "passport") setPassportId(null); }; useEffect(() => { applyHash(); window.addEventListener("popstate", applyHash); return () => window.removeEventListener("popstate", applyHash); }, []); const navigate = (section, id = null) => { const hash = id ? `/${section}/${id}` : `/${section}`; window.history.pushState(null, "", "#" + hash); setActive(section); if (id) setPassportId(id); else setPassportId(null); }; // ── Auth + initial load ────────────────────────────────────────────────── useEffect(() => { bootstrap(); }, []); const bootstrap = async () => { try { const setup = await fetch("/api/setup/status"); if (setup.ok) { const { initialized } = await setup.json(); if (!initialized) { window.location.href = "/setup"; return; } } const me = await apiFetch("/api/auth/me"); if (!me.ok) return; setUser(await me.json()); await loadData(); } finally { setLoading(false); } }; const loadData = async () => { const [dataResp, actResp] = await Promise.all([ apiFetch("/api/data"), apiFetch("/api/activity"), ]); if (dataResp.ok) setData(await dataResp.json()); if (actResp.ok) setActivity(await actResp.json()); }; const refresh = () => loadData(); // ── Navigation ──────────────────────────────────────────────────────────── const handleOpen = (id) => setOpenId(id); // ── Actions ─────────────────────────────────────────────────────────────── const handleAdvance = async (rndId, notes) => { await apiFetch(`/api/hypotheses/${rndId}/advance`, { method: "POST", body: JSON.stringify({ notes: notes || null }), }); refresh(); }; const handleMove = async (rndId, targetStage) => { await apiFetch(`/api/hypotheses/${rndId}/move`, { method: "POST", body: JSON.stringify({ stage: targetStage }), }); refresh(); }; const handleDecide = (rndId) => setModal({ type: "decide", rndId }); const handleEdit = (rndId) => setModal({ type: "edit", rndId }); const handleLogout = async () => { await fetch("/api/auth/logout", { method: "POST" }); window.location.href = "/login"; }; // ── Filter logic ────────────────────────────────────────────────────────── const filteredData = useMemo(() => { if (!data) return null; let hyps = data.hypotheses; if (filters.set === "active") hyps = hyps.filter(h => !h.decision); if (filters.set === "mine") hyps = hyps.filter(h => h.author === data._meta?.currentUserId); if (filters.stage) hyps = hyps.filter(h => h.stage === filters.stage); if (filters.genre) hyps = hyps.filter(h => h.tags.includes(filters.genre)); if (filters.author) hyps = hyps.filter(h => h.author === filters.author); if (filters.risk) hyps = hyps.filter(h => h.risk === filters.risk); return { ...data, hypotheses: hyps }; }, [data, filters]); // ── Loading / guard ─────────────────────────────────────────────────────── if (loading) { return (
Loading…
); } if (!data) return null; const meta = { ...(PAGE_META[active] || PAGE_META.registry) }; const effectiveView = active === "registry" ? view : active === "kanban" ? "kanban" : active === "cards" ? "cards" : active === "dashboard" ? "dashboard": active === "passport" ? "passport" : active === "archive" ? "archive" : active === "decision" ? "decision" : active === "scaled" ? "scaled" : active === "users" ? "users" : "table"; const fd = filteredData; // Section subsets applied after user filters const archiveData = { ...fd, hypotheses: fd.hypotheses.filter(h => h.decision === "kill") }; const scaledData = { ...fd, hypotheses: fd.hypotheses.filter(h => h.decision === "scale") }; const decisionData = { ...fd, hypotheses: fd.hypotheses.filter(h => h.stage === "decision" && !h.decision) }; const dashData = { ...fd, _activity: activity }; if (active === "passport" && passportId) { meta.crumbs = ["Workspace", "Registry", passportId]; meta.sub = `${passportId} · The full record of a hypothesis. This is what travels into procurement on Scale.`; } const filterOptions = { stages: data.stages.map(s => ({ value: s.id, label: s.label })), genres: data.tags.map(t => ({ value: t, label: t })), authors: data.authors.map(a => ({ value: a.id, label: a.name })), risks: [{ value: "low", label: "Low" }, { value: "med", label: "Medium" }, { value: "high", label: "High" }], }; const showFilters = ["registry", "kanban", "cards", "archive", "decision", "scaled"].includes(active); return (
navigate(id)} theme={theme} setTheme={setThemeState} data={data} user={user} onLogout={handleLogout} />
setModal({ type: "new" })} user={user} /> {active !== "users" && ( )} {effectiveView === "table" && } {effectiveView === "kanban" && setModal({ type: "new" })} onMove={handleMove} />} {effectiveView === "cards" && } {effectiveView === "dashboard" && } {effectiveView === "passport" && ( )} {effectiveView === "archive" && } {effectiveView === "scaled" && } {effectiveView === "decision" && } {effectiveView === "users" && }
setOpenId(null)} onDecide={(rndId) => { setModal({ type: "decide", rndId }); }} onAdvance={handleAdvance} onRefresh={refresh} /> {modal && ( setModal(null)} onSuccess={(newId) => { setModal(null); refresh(); if (newId) setOpenId(newId); }} /> )}
); } const root = ReactDOM.createRoot(document.getElementById("root")); root.render();