// 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 */}
{/* 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}
{idx > 0 && }
>
);
}
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render();