1/** 2 * System Admin Page - Orchestrator 3 * Manages left-panel navigation and lazy-loads tab modules on demand. 4 * Mirrors the settings.js pattern exactly. 5 */ 6 7import { createTemplateLoader, renderTo } from '../utils/templates.js'; 8import { offlineAwareErrorDescription } from '../utils/async.js'; 9import { auth } from '../auth.js'; 10 11// ======================================== 12// MODULE STATE 13// ======================================== 14 15const tabs = { 16 'project-tasks': () => import('./system-admin/project-tasks.js'), 17 'doc-control-tasks': () => import('./system-admin/doc-control-tasks.js'), 18 'precon-tasks': () => import('./system-admin/precon-tasks.js'), 19 'tender-tasks': () => import('./system-admin/tender-tasks.js'), 20 'tender-package': () => import('./system-admin/tender-package.js'), 21 'access-pages': () => import('./system-admin/access-pages.js'), 22 'maintenance': () => import('./system-admin/maintenance.js') 23}; 24 25let currentModule = null; 26let currentTabId = null; 27const ensureTemplates = createTemplateLoader('system-admin/sub-templates'); 28let _navigating = false; 29// MCF-0710: the failure pane's retry is the page's own listener, not a module's, so it needs a 30// page-lifetime signal to be unbound by destroySystemAdminPage(). 31let pageAbort = null; 32let switchGeneration = 0; 33let pageGeneration = 0; 34 35// ======================================== 36// PUBLIC API 37// ======================================== 38 39/** 40 * Initialize the system admin page and load the requested tab. 41 * @param {string|null} tabId - Tab to activate (defaults to 'project-tasks') 42 */ 43export async function initSystemAdminPage(tabId = null) { 44 // Admin guard â redirect non-A users 45 const userType = auth.getUser()?.userType; 46 if (userType !== 'A') { 47 page.redirect('/'); 48 return; 49 } 50 51 // ensureTemplates() fetches on a cold load, so teardown CAN land in this gap (card law 20). 52 const gen = ++pageGeneration; 53 await ensureTemplates(); 54 if (gen !== pageGeneration) return; // destroyed or re-mounted while the templates loaded 55 56 pageAbort = new AbortController(); 57 58 const nav = document.getElementById('sysadmin-nav'); 59 const initialTab = (tabId && tabs[tabId]) ? tabId : 'project-tasks'; 60 61 // Bind nav click events 62 nav?.addEventListener('click', (e) => { 63 const btn = e.target.closest('.settings-nav-btn'); 64 if (!btn) return; 65 66 const targetId = btn.dataset.tab; 67 if (targetId && targetId !== currentTabId) { 68 switchTab(targetId); 69 } 70 }, { signal: pageAbort.signal }); // MCF-0732: the page-lifetime owner created two lines up 71 72 // Load initial tab (suppress router re-entry) 73 _navigating = true; 74 try { 75 switchTab(initialTab); 76 } finally { 77 _navigating = false; 78 } 79} 80 81// ======================================== 82// TAB SWITCHING 83// ======================================== 84 85async function switchTab(tabId) { 86 if (tabId === currentTabId) return; 87 88 // Two fast clicks both get here (currentTabId is only assigned after the import resolves), so 89 // every publication below an await belongs to the newest switch or to nobody (card law 20). 90 const gen = ++switchGeneration; 91 92 // Update nav active state 93 const nav = document.getElementById('sysadmin-nav'); 94 nav?.querySelectorAll('.settings-nav-btn').forEach(btn => { 95 btn.classList.toggle('active', btn.dataset.tab === tabId); 96 }); 97 98 // Show loading in content area. Both hops of this chain ride the shared renderer: a retry press 99 // lands on the pane below, and an unwired hop between press and repaint drops focus to <body>. 100 const content = document.getElementById('sysadmin-content'); 101 if (content) { 102 await renderTo(content, '#tpl-system-admin-tab-loading', {}, undefined, { restoreFocus: true }); 103 } 104 105 // Cleanup previous module 106 if (currentModule?.destroy) { 107 currentModule.destroy(); 108 } 109 110 try { 111 if (!tabs[tabId]) throw new Error(`Unknown tab: ${tabId}`); 112 113 // Update URL via router (unless we're already navigating from router) 114 const newUrl = `/system-admin/${tabId}`; 115 if (!_navigating) { page.show(newUrl); } 116 117 // Lazy-load and init module 118 const module = await tabs[tabId](); 119 if (gen !== switchGeneration) return; // a newer switch overtook this one 120 currentModule = module; 121 currentTabId = tabId; 122
123 await module.init(); 124 125 } catch (error) { 126 if (error.name === 'AbortError') return; 127 if (gen !== switchGeneration) return; // a newer switch owns the content region now 128 console.error(`[SystemAdmin] Failed to load tab ${tabId}:`, error); 129 130 // MCF-0712(a): the failed tab already owns currentTabId, so its own nav button and the 131 // pane's retry would both no-op on the guard at the top of this function. Release it â 132 // and tear the half-initialised module down, since nulling the handle is the last chance. 133 currentModule?.destroy?.(); 134 currentModule = null; 135 currentTabId = null; 136 137 // A failed lazy import is this pane's likeliest cause and its message carries an absolute 138 // URL, which is not user copy (DESIGN_BIBLE §Error State: no raw endpoint in the slot). 139 // The copy commits to no cause: a stale SW precache, a rotated chunk or a wrong MIME type 140 // all land here with a perfectly healthy connection. 141 const message = /dynamically imported module/i.test(error.message) 142 ? "Couldn't load this tab's code. Try again, or reload the page." 143 : offlineAwareErrorDescription(error); 144 145 if (content) { 146 await renderTo(content, '#tpl-system-admin-tab-error', { message }, undefined, { restoreFocus: true }); 147 content.querySelector('#btn-sysadmin-tab-retry')?.addEventListener('click', async () => { 148 try { 149 await switchTab(tabId); 150 } catch (err) { 151 console.error(`[SystemAdmin] Retry of tab ${tabId} failed:`, err); 152 } 153 }, { once: true, signal: pageAbort?.signal }); 154 } 155 } 156} 157 158/** 159 * Cleanup on page transition 160 */ 161export function destroySystemAdminPage() { 162 pageGeneration++; // any init still awaiting its templates must not publish into a dead page 163 switchGeneration++; // ...and an in-flight switchTab must not bind a retry to a nulled signal 164 if (currentModule?.destroy) { 165 currentModule.destroy(); 166 } 167 currentModule = null; 168 currentTabId = null; 169 _navigating = false; 170 pageAbort?.abort(); 171 pageAbort = null; 172} 173 174export function navigateSystemAdminPage(tabId = null) { 175 _navigating = true; 176 try { 177 const target = (tabId && tabs[tabId]) ? tabId : 'project-tasks'; 178 if (target !== currentTabId) { 179 switchTab(target); 180 } 181 } finally { 182 _navigating = false; 183 } 184}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.