// Main app — shell, nav, persona switching, page routing const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{ "persona": "fbp", "scopeFilter": "All in scope", "density": "comfortable", "showAuditTrail": true }/*EDITMODE-END*/; // Nav groups const NAV_GROUPS = [ { label: "Rates", items: [ { id: "rates", label: "Rate Repository", icon: "table" }, { id: "health", label: "Rate Health", icon: "shield" }, { id: "roe", label: "ROE Table", icon: "globe" }, { id: "calc", label: "Rate Calculator", icon: "calc" }, { id: "ai", label: "AI Assistant", icon: "sparkle" }, { id: "export",label: "Export", icon: "export" }, ], }, { label: "Contracts & Finance", items: [ { id: "rebate", label: "Rebate Monitor", icon: "key" }, { id: "volume", label: "Volume Tracker", icon: "table" }, { id: "tco", label: "TCO Analysis", icon: "bolt" }, ], }, { label: "Access", items: [ { id: "access", label: "Access Requests", icon: "key" }, ], }, { label: "Administration", items: [ { id: "admin", label: "Admin Console", icon: "settings", adminOnly: true }, ], }, { label: "Resources", items: [ { id: "docs", label: "Documentation", icon: "book" }, ], }, ]; function canSee(page, perms) { if (perms.accessOnly) return page === "access" || page === "docs"; switch (page) { case "rates": return perms.viewRates; case "health": return perms.rateHealth; case "roe": return perms.roeTable; case "calc": return true; case "ai": return perms.aiAssistant; case "export": return perms.export; case "rebate": return perms.rebateMonitor; case "volume": return perms.volumeTracker; case "tco": return perms.tcoAnalysis; case "access": return true; case "admin": return perms.admin; case "docs": return true; } return true; } function App() { const [tweaks, setTweak] = useTweaks(TWEAK_DEFAULTS); // Use real SSO user when available; fall back to persona simulator for local dev const [ssoUser, setSsoUser] = useState(window.OCMS_USER || null); const [permsMap, setPermsMap] = useState(window.OCMS_PERMS || PERMS); const [tweaksPanelOpen, setTweaksPanelOpen] = useState(false); useEffect(() => { const handler = (e) => { setSsoUser(e.detail); if (e.detail?.role && PERSONAS[e.detail.role]) { setTweak("persona", e.detail.role); } }; window.addEventListener('ocms:user-ready', handler); return () => window.removeEventListener('ocms:user-ready', handler); }, []); useEffect(() => { const handler = (e) => setPermsMap(e.detail); window.addEventListener('ocms:perms-ready', handler); return () => window.removeEventListener('ocms:perms-ready', handler); }, []); useEffect(() => { const handler = (e) => { setPage(e.detail); setShowGuide(false); }; window.addEventListener('tcs:navigate', handler); return () => window.removeEventListener('tcs:navigate', handler); }, []); useEffect(() => { const onMsg = (e) => { if (e.data?.type === '__edit_mode_dismissed') { setTweaksPanelOpen(false); if (ssoUser?.role && PERSONAS[ssoUser.role]) { setTweak("persona", ssoUser.role); } // Refresh permissions from API in case admin changed them if (window.OCMS_PERMS) setPermsMap({ ...window.OCMS_PERMS }); } }; window.addEventListener('message', onMsg); return () => window.removeEventListener('message', onMsg); }, [ssoUser]); const _devEnv = ['localhost', '127.0.0.1', 'ocms-cdt.maersk.io'].includes(window.location.hostname); const _isAdmin = ssoUser ? (ssoUser.role === 'admin' || (window.location.hostname === 'localhost' && ssoUser.roles.length === 0)) : true; const showTweaks = _devEnv && _isAdmin; // Persona switcher only overrides when TweaksPanel is actually open. // While SSO auth is still in progress (ssoUser=null), use PERSONAS.unknown so no data // screens render and no API calls are made before the token is ready. // In dev, the tweaks panel can override to a simulator persona at any time. const user = (showTweaks && tweaksPanelOpen) ? (PERSONAS[tweaks.persona] || PERSONAS.fbp) : (ssoUser || PERSONAS.unknown); const perms = user.role === 'unknown' ? PERMS.unknown : (permsMap[user.role] || permsMap.read || PERMS.read); function getDefaultPage(perms, role) { if (perms.accessOnly) return "access"; if (role === "admin") return "admin"; if (role === "rate_calculator") return "calc"; return "rates"; } const defaultPage = getDefaultPage(perms, user.role); const [page, setPage] = useState(defaultPage); const [visited, setVisited] = useState(() => new Set([defaultPage])); useEffect(() => { setVisited(v => { v.add(page); return new Set(v); }); }, [page]); useEffect(() => { if (!canSee(page, perms)) setPage(getDefaultPage(perms, user.role)); }, [tweaks.persona]); // When auth completes, redirect to the role-appropriate default page. // useState(defaultPage) only runs at mount when ssoUser=null (role='unknown'), // so page would be stuck on "access" without this correction. useEffect(() => { if (ssoUser) { setPage(getDefaultPage(perms, ssoUser.role)); startMetaRefreshTimer(); // prime metadata cache + start 20-min background refresh } }, [ssoUser?.role]); const [healthIssueCount, setHealthIssueCount] = useState(null); // Fetch validation issue count eagerly on login so the sidebar badge shows without visiting the health page useEffect(() => { if (!ssoUser || !perms.rateHealth) return; apiFetch("/api/health/validation") .then(v => setHealthIssueCount((v?.findings || []).length)) .catch(() => {}); }, [ssoUser?.role]); const [showWelcome, setShowWelcome] = useState(true); const dismissWelcome = () => setShowWelcome(false); const [showGuide, setShowGuide] = useState(false); const [openRate, setOpenRate] = useState(null); const [showAdd, setShowAdd] = useState(false); const [showAddPrefill, setShowAddPrefill] = useState(null); const [showImport, setShowImport] = useState(false); const [showAI, setShowAI] = useState(false); const [toast, setToast] = useState(""); const [rateRefreshKey, setRateRefreshKey] = useState(0); const [healthRefreshKey, setHealthRefreshKey] = useState(0); const [rateNavCount, setRateNavCount] = useState(null); const [accessPendingCount, setAccessPendingCount] = useState(0); const [accessCountKey, setAccessCountKey] = useState(0); useEffect(() => { if (!ssoUser || !perms.viewRates) return; apiFetch("/api/rates/count").then(r => { if (r?.total != null) setRateNavCount(r.total); }).catch(() => {}); }, [ssoUser, perms.viewRates, rateRefreshKey]); // Fetch pending access request count — returns 0 for non-approvers without 403 useEffect(() => { if (!ssoUser) return; apiFetch("/api/access-requests/pending-count") .then(r => setAccessPendingCount(r?.count ?? 0)) .catch(() => setAccessPendingCount(0)); }, [ssoUser, accessCountKey]); const fmtCount = n => n == null ? "" : n >= 1000 ? (n / 1000).toFixed(1).replace(/\.0$/, "") + "k" : String(n); const allNavItems = NAV_GROUPS.flatMap(g => g.items); const pageMeta = allNavItems.find(p => p.id === page); const groupMeta = NAV_GROUPS.find(g => g.items.some(i => i.id === page)); return (
{/* SIDEBAR */} {/* TOP BAR */}
TCS / {groupMeta && <>{groupMeta.label}/} {pageMeta?.label || "—"}
{ const t = e.currentTarget.querySelector(".scope-tip"); if (t) t.style.display = "block"; }} onMouseLeave={e => { const t = e.currentTarget.querySelector(".scope-tip"); if (t) t.style.display = "none"; }}> Scope: {user.regions.length > 1 ? `${user.regions.length} regions` : (user.regions[0] || "—")} {(user.regions.length > 0 && user.regions[0] !== "—") || user.areas?.length > 0 ? (
Regions
{user.regions.map(r =>
· {r}
)} {user.areas && user.areas.length > 0 && ( <>
Areas
{user.areas.map(a =>
· {a}
)} )}
) : null}
{perms.aiAssistant && ( )}
{ssoUser ? ssoUser.name : user.name}
{user.role}
{/* MAIN */}
{/* Stateful pages: lazy-mount on first visit, then CSS hide/show to preserve state */} {visited.has("rates") && perms.viewRates && (
setShowAdd(true)} onAddPrefill={(prefill) => { setShowAddPrefill(prefill); setShowAdd(true); }} onImport={() => setShowImport(true)} onOpenAI={() => setShowAI(true)} onToast={setToast} refreshKey={rateRefreshKey} />
)} {page === "rates" && !perms.viewRates && setPage("calc")} />} {visited.has("health") && perms.rateHealth && (
{ setShowAddPrefill(prefill); setShowAdd(true); }} onOpenRate={setOpenRate} refreshKey={healthRefreshKey} onValidationData={v => setHealthIssueCount((v?.findings || []).length)} />
)} {page === "health" && !perms.rateHealth && setPage("calc")} />} {visited.has("roe") && perms.roeTable && (
)} {page === "roe" && !perms.roeTable && setPage("calc")} />} {visited.has("calc") && (
)} {visited.has("ai") && perms.aiAssistant && (
)} {page === "ai" && !perms.aiAssistant && setPage("calc")} />} {visited.has("export") && perms.export && (
)} {page === "export" && !perms.export && setPage("calc")} />} {visited.has("rebate") && canSee("rebate", perms) && (
)} {page === "rebate" && !canSee("rebate", perms) && setPage("calc")} />} {visited.has("volume") && canSee("volume", perms) && (
)} {page === "volume" && !canSee("volume", perms) && setPage("calc")} />} {visited.has("tco") && canSee("tco", perms) && (
)} {page === "tco" && !canSee("tco", perms) && setPage("calc")} />} {visited.has("access") && (
setAccessCountKey(k => k + 1)} />
)} {visited.has("admin") && perms.admin && (
)} {visited.has("docs") && (
)}
{openRate && setOpenRate(null)} perms={perms} onEdit={rate => { setOpenRate(null); setShowAddPrefill(rate); setShowAdd(true); }} onDuplicate={rate => { setOpenRate(null); setShowAddPrefill({ ...rate, id: undefined, status: "Draft", created_at: undefined, modified_at: undefined }); setShowAdd(true); }} />} {showAdd && { setShowAdd(false); setShowAddPrefill(null); }} onSave={msg => { setShowAdd(false); setShowAddPrefill(null); setToast(msg); setRateRefreshKey(k => k + 1); setHealthRefreshKey(k => k + 1); }} />} {showImport && setShowImport(false)} onDone={msg => { setShowImport(false); setToast(msg); setRateRefreshKey(k => k + 1); }} />} {showAI && setShowAI(false)} />} setToast("")} /> {showWelcome && } {showGuide && setShowGuide(false)} />} {/* TWEAKS PANEL — local/dev env + admin role only */} {showTweaks && setTweak("persona", v)} options={[ { value: "rate_calculator", label: "Calc only" }, { value: "read", label: "Read" }, { value: "edit", label: "Rate Focal (edit)" }, { value: "fbp", label: "FBP" }, { value: "procurement", label: "Procurement" }, { value: "admin", label: "Admin" }, ]} />
rate_calculator · Calculator only · rates hidden
read · browse rates + Health Dashboard
edit · Rate Focal — create / import rates + Volume upload
fbp · adds AI, Export, Rebate, TCO
procurement · adds contract upload + Rebate settlement
admin · full system + Admin console
setTweak("showAuditTrail", v)} />
}
); } function NoAccess({ role, feature, canDo, onGoCalc }) { return (
{feature} is not available for your role
Your role {role} does not include access to {feature}. You can use the {canDo} for cost estimation, or submit a new access request.
); } // ─── Tour Guide Overlay ─────────────────────────────────────────────────────── const TOUR_STEPS = { rates: { admin: [ { selector: ".filterbar", title: "Filter Rates", body: "Narrow by terminal, cost type, equipment, status, or date range. Filters stack — use multiple at once.", position: "bottom" }, { selector: ".grid-4", title: "KPI Summary", body: "Active rates, total loaded, drafts, and expiring rates at a glance across your current scope.", position: "bottom" }, { selector: ".page-header .btn.primary",title: "Add Rate", body: "Create a single rate via the form — set terminal, cost type, effective date, and local rate.", position: "bottom" }, { selector: ".table-wrap", title: "Rate Table", body: "Click any row to open the full rate drawer with slab tiers, change history, and edit options.", position: "top" }, ], edit: [ { selector: ".page-header .btn.primary",title: "Add Rate", body: "Create a new rate — fill in terminal, cost type, effective date, and rate value, then save or save as draft.", position: "bottom" }, { selector: ".filterbar", title: "Filter & Find", body: "Filter by your terminals, cost type, or status to locate existing rates quickly before editing.", position: "bottom" }, { selector: ".table-wrap", title: "Batch Edit", body: "Tick multiple rows with the checkbox, then click Edit Selected in the toolbar to update shared fields in one action.", position: "top" }, ], _default: [ { selector: ".filterbar", title: "Filter Rates", body: "Use filters to narrow rates by terminal, cost type, status, or date range.", position: "bottom" }, { selector: ".table-wrap", title: "Rate Table", body: "Click any row to view full rate details and audit history. Your role allows viewing and exporting only.", position: "top" }, ], }, health: { admin: [ { selector: ".grid-4", title: "Health KPIs", body: "Overall completeness %, rates expiring in 60 days, and active QV errors — refreshed on each load.", position: "bottom" }, { selector: ".tabs", title: "Three Tabs", body: "Switch between Completeness, Expiry Alerts, and Quality Validation using the tab bar.", position: "bottom" }, { selector: ".page-header .btn", title: "Run Validation", body: "Click Run Validation to scan all Active rates for QV rule violations — results appear in the Validation tab.", position: "bottom" }, { selector: ".table-wrap", title: "QV Findings", body: "Click any rate ID in the validation table to jump directly to that rate in Rate Repository and fix the issue.", position: "top" }, ], _default: [ { selector: ".grid-4", title: "Health KPIs", body: "See completeness %, expiry alert count, and active QV error count across your scope.", position: "bottom" }, { selector: ".tabs", title: "Three Tabs", body: "Switch between Completeness, Expiry Alerts, and Quality Validation to see detailed breakdowns.", position: "bottom" }, { selector: ".table-wrap", title: "Completeness Table", body: "Review which rate combinations are Present, Missing, or Expiring across your terminals.", position: "top" }, ], }, roe: { admin: [ { selector: ".page-header .btn.primary",title: "Add Exchange Rate", body: "Create a ROE entry with currency, rate value, and effective/expiry date. Covers a date range.", position: "bottom" }, { selector: ".table-wrap", title: "ROE Table", body: "Edit or expire entries here. Adding a missing ROE resolves QV-07 warnings in Rate Health.", position: "top" }, ], _default: [ { selector: ".table-wrap", title: "ROE Table", body: "Browse active exchange rates per currency. Used by the Rate Calculator to convert local currency to USD.", position: "top" }, ], }, calc: { _default: [ { selector: ".card", title: "Configure Scenario", body: "Select terminal, cost type, equipment type, and container size to set up your cost scenario.", position: "right" }, { selector: ".btn.teal", title: "Load Rates", body: "Click to fetch Active rates for the selected terminal and cost type, then enter quantities to calculate.", position: "top" }, ], }, access: { admin: [ { selector: ".filterbar", title: "Filter by Status", body: "Switch between Pending, Approved, and Rejected to manage requests. Pending items need your action.", position: "bottom" }, { selector: ".card .table-wrap", title: "Review Requests", body: "Click a row to see the requester's role, region, and justification, then Approve or Reject.", position: "top" }, ], _default: [ { selector: ".page-header .btn.primary",title: "New Request", body: "Submit a request for a role and region. Provide a clear business reason to help the approver decide quickly.", position: "bottom" }, { selector: ".card .table-wrap", title: "Track Status", body: "Your submitted requests appear here with their current status: Pending, Approved, or Rejected.", position: "top" }, ], }, admin: { admin: [ { selector: ".page-header", title: "Admin Console", body: "Select a section from the left sidebar: Users, Permissions, Metadata, Coverage Matrix, Approval Routing, or Audit Log.", position: "bottom" }, { selector: ".table-wrap", title: "Manage Data", body: "View and edit reference data, user roles, and system configuration in the table below.", position: "top" }, ], _default: [ { selector: null, title: "Admin Console", body: "This section is restricted to Admin role users. Contact your system administrator to request elevated access.", position: null }, ], }, docs: { _default: [ { selector: ".filterbar, .search", title: "Search Articles", body: "Type any keyword to find articles, tags, or content across the TCS Knowledge Base.", position: "bottom" }, { selector: ".card", title: "Browse by Category", body: "Use the left panel to navigate by section: Modules, Workflows, FAQ, and Release Notes.", position: "right" }, ], }, _default: { _default: [ { selector: null, title: "Welcome to TCS", body: "Use the left menu to navigate between Rate Repository, Rate Health, ROE Table, Rate Calculator, and more.", position: null }, { selector: null, title: "Page Guide", body: "Click the ? button on any page to open this guided tour — it adapts to the current page and your role.", position: null }, ], }, }; function TourOverlay({ page, role, onClose }) { const pageDef = TOUR_STEPS[page] || TOUR_STEPS._default; const steps = pageDef[role] || pageDef._default || TOUR_STEPS._default._default; const [idx, setIdx] = React.useState(0); const [rect, setRect] = React.useState(null); const step = steps[idx]; const PAD = 10; const isLast = idx === steps.length - 1; const W = 320; const measure = React.useCallback(() => { if (!step?.selector) { setRect(null); return; } const el = document.querySelector(step.selector); if (!el) { setRect(null); return; } setRect(el.getBoundingClientRect()); }, [idx, step]); React.useEffect(() => { setRect(null); if (!step?.selector) return; const el = document.querySelector(step.selector); if (!el) return; el.scrollIntoView({ behavior: "smooth", block: "nearest" }); const t = setTimeout(measure, 340); return () => clearTimeout(t); }, [idx]); React.useEffect(() => { window.addEventListener("resize", measure); return () => window.removeEventListener("resize", measure); }, [measure]); const spot = (rect && rect.width > 0 && rect.height > 0) ? { top: rect.top - PAD, left: rect.left - PAD, width: rect.width + PAD * 2, height: rect.height + PAD * 2, } : null; const tipStyle = () => { const m = 16; if (!spot) return { position: "fixed", top: "50%", left: "50%", transform: "translate(-50%,-50%)", zIndex: 10000, width: W }; const pos = step.position || "bottom"; let s = { position: "fixed", zIndex: 10000, width: W }; const cx = spot.left + spot.width / 2; if (pos === "bottom") { s.top = spot.top + spot.height + m; s.left = Math.max(m, Math.min(cx - W / 2, window.innerWidth - W - m)); } else if (pos === "top") { s.bottom = window.innerHeight - spot.top + m; s.left = Math.max(m, Math.min(cx - W / 2, window.innerWidth - W - m)); } else if (pos === "right") { s.top = Math.max(m, spot.top); s.left = Math.min(spot.left + spot.width + m, window.innerWidth - W - m); } else { s.top = Math.max(m, spot.top); s.right = window.innerWidth - spot.left + m; } return s; }; return ( <>
{spot && (
)}
{steps.map((_, i) => (
))} {idx + 1} / {steps.length}
{step.title}
{step.body}
{idx > 0 && }
); } const root = ReactDOM.createRoot(document.getElementById("root")); root.render();