1/** 2 * Financial Center / Job Costing â Contractor / Sub Contractor only. 3 * 4 * List-detail shell mirroring hr.js. The Overview list (left) renders cross- 5 * project financial rollups; the Detail (right) hosts 13 sub-module tabs that 6 * are all "Coming Soon" placeholders for now. Sub-modules port one-by-one. 7 */ 8 9import { accountingApi } from '../api/accounting.js'; 10import { accountingTimePeriodsApi } from '../api/accounting-time-periods.js'; 11import { renderTo, createTemplateLoader } from '../utils/templates.js'; 12import { safeAsync } from '../utils/async.js'; 13import { renderSubModuleTabs } from '../utils/sub-module-icons.js'; 14import { destroyTabNav } from '../utils/tab-nav-mode.js'; 15import { createCollapsibleListPanel } from '../utils/collapsible-list-panel.js'; 16import { createAccountingPeriodContext } from '../utils/accounting-period-context.js'; 17import { isMobileOrTablet } from '../utils/mobile.js'; 18import { renderAccountingDashboard, destroyAccountingDashboard } from './accounting-dashboard.js'; 19import { renderAccountingAllInvoices, destroyAccountingAllInvoices } from './accounting-all-invoices.js'; 20import { initAccountingSettings, destroyAccountingSettings, showAccountingSettingsTab } from './accounting-settings.js'; 21import { showEditPeriodModal, showEndPeriodModal } from './accounting-cost-analysis-modals.js'; 22import { showModal } from '../utils/modal.js'; 23import { runWithBusyButton } from '../utils/dom.js'; 24import { flagFieldError } from '../utils/form-validation.js'; 25import { downloadBlob } from '../utils/download.js'; 26import { accountingOverviewApi } from '../api/accounting-overview.js'; 27 28import { initAccountingOverview, destroyAccountingOverview, OVERVIEW_PAGE_LINKS } from './accounting-overview.js'; 29import { initAccountingBudget, destroyAccountingBudget, BUDGET_PAGE_LINKS } from './accounting-budget.js'; 30import { initAccountingBudgetedHrs, destroyAccountingBudgetedHrs, BUDGETED_HRS_PAGE_LINKS } from './accounting-budgeted-hrs.js'; 31import { initAccountingProgressApplications, destroyAccountingProgressApplications, getCurrentProgressApplicationId, getCurrentProgressApplicationTab, syncProgressApplicationFromRoute, PROGRESS_APPLICATIONS_PAGE_LINKS } from './accounting-progress-applications.js'; 32import { initAccountingPaymentCertificate, destroyAccountingPaymentCertificate, PAYMENT_CERTIFICATE_PAGE_LINKS } from './accounting-payment-certificate.js'; 33import { initAccountingCostAnalysis, destroyAccountingCostAnalysis, getCurrentCostCodeId, getCurrentCostCodeTab, syncCostCodeFromRoute, COST_ANALYSIS_PAGE_LINKS } from './accounting-cost-analysis.js'; 34import { initAccountingLaborAnalysis, destroyAccountingLaborAnalysis, LABOR_ANALYSIS_PAGE_LINKS } from './accounting-labor-analysis.js'; 35import { initAccountingCashFlowReport, destroyAccountingCashFlowReport, CASH_FLOW_REPORT_PAGE_LINKS } from './accounting-cash-flow-report.js'; 36import { initAccountingSuppliers, destroyAccountingSuppliers, getCurrentSupplierId, syncSupplierFromRoute, ACCOUNTING_SUPPLIERS_PAGE_LINKS } from './accounting-suppliers.js'; 37import { initAccountingInvoices, destroyAccountingInvoices, getCurrentInvoiceId, syncInvoiceFromRoute, INVOICES_PAGE_LINKS } from './accounting-invoices.js'; 38import { initAccountingChangeOrders, destroyAccountingChangeOrders, getCurrentChangeOrderId, getCurrentChangeOrderTab, syncChangeOrderFromRoute, CHANGE_ORDERS_PAGE_LINKS } from './accounting-change-orders.js'; 39import { initAccountingPurchaseOrders, destroyAccountingPurchaseOrders, getCurrentPurchaseOrderId, getCurrentPurchaseOrderTab, syncPurchaseOrderFromRoute, PURCHASE_ORDERS_PAGE_LINKS } from './accounting-purchase-orders.js'; 40import { initAccountingDeliveries, destroyAccountingDeliveries, getCurrentDeliveryId, syncDeliveryFromRoute, ACCOUNTING_DELIVERIES_PAGE_LINKS } from './accounting-deliveries.js'; 41import { initAccountingNetSuiteExport, destroyAccountingNetSuiteExport, NETSUITE_EXPORT_PAGE_LINKS } from './accounting-netsuite-export.js'; 42import { companyFeatures } from '../services/company-features.js'; 43 44// Sub-module tab registry â order matches the legacy Accounting header (image 2). 45// `gated` entries appear only when the matching company module toggle is on. 46const ACCOUNTING_SUB_MODULES = [ 47 { id: 'overview', label: 'Overview', icon: 'bar-chart-3' }, 48 { id: 'budget', label: 'Budget', icon: 'calculator' }, 49 { id: 'budgeted-hrs', label: 'Budgeted HRS', icon: 'clock' }, 50 { id: 'progress-applications', label: 'Progress Applications', icon: 'file-bar-chart' }, 51 { id: 'payment-certificate', label: 'Payment Certificate', icon: 'badge-check' }, 52 { id: 'cost-analysis', label: 'Cost Analysis', icon: 'trending-up' }, 53 { id: 'labor-analysis', label: 'Labor Analysis', icon: 'users-round' }, 54 { id: 'cash-flow-report', label: 'Cash Flow Report', icon: 'banknote' }, 55 { id: 'suppliers', label: 'Suppliers', icon: 'truck' }, 56 { id: 'invoices', label: 'Invoices', icon: 'receipt' }, 57 { id: 'change-orders', label: 'Change Orders', icon: 'git-pull-request' }, 58 { id: 'purchase-orders', label: 'Purchase Orders', icon: 'shopping-cart' }, 59 { id: 'deliveries', label: 'Deliveries', icon: 'package' }, 60 { id: 'netsuite-export', label: 'NetSuite Export', icon: 'arrow-left-right', gated: 'netsuite' }, 61]; 62 63/** Sub-modules visible for this company (filters out gated-but-disabled ones). */ 64function visibleSubModules() { 65 return ACCOUNTING_SUB_MODULES.filter(m => 66 !m.gated || (m.gated === 'netsuite' ? companyFeatures.isNetSuiteEnabled() : true)); 67} 68 69let abortController = null;
70const ensureTemplates = createTemplateLoader('components/accounting-templates'); 71let projects = []; 72let filteredProjects = []; 73// The list pane is showing the V7 failure pane rather than a project set. Needed because 74// #accounting-search and the filter popover live in the page SHELL (accounting-main.html:29,:31), 75// OUTSIDE the subtree loadProjects replaces â so after a failed load one keystroke reaches 76// applyFilters -> renderProjectList, which would paint "No projects yet" (an empty `projects` with 77// no filter applied reads as genuinely empty) over the error pane and take the retry with it. 78// An empty list and a failed list are different facts; this is the one bit that tells them apart. 79let projectsLoadFailed = false; 80// The THIRD state, and the one the two booleans above could not express: in flight. A read that has 81// been issued but not answered is neither "failed" nor "have data", and the dashboard composes over 82// `projects` from three callers that can all fire inside that window â so without this a mid-read 83// filter toggle published $0/$0/$0 as fact, from a request that had not answered yet 84// (CA-203 [functionality:1]/[functionality:2] = [ux:2]). Cleared INSIDE both ownership guards, so a 85// superseded page's answer cannot lower the live page's flag. 86let projectsLoading = false; 87// The real cause, carried so BOTH panes can name it. The list pane computes this in its onError arm 88// and always could; the dashboard pane, six inches away at >=1024px, was stuck on the generic 89// {{else}} fallback while the list named the 500 (CA-203 [functionality:3] = [ux:1]). One 90// expression feeds both â never a second computation that could disagree with the first. 91let projectsLoadDescription = ''; 92let filterPopoverEl = null; 93// G5 ruling (2026-07-27): promoted-precon projects (projectTypeFlag 'P') ship in the 94// payload but are hidden by DEFAULT â the filter popover reveals them on demand 95// (discoverable un-gating vs legacy's hard 'D'-only filter, which even Dan couldn't un-gate). 96const filterState = { showPrecon: false }; 97 98/** The list/dashboard base set â type filter applied, search NOT applied. */ 99function visibleProjects() { 100 return projects.filter(p => filterState.showPrecon || p.projectTypeFlag !== 'P'); 101} 102let currentProjectId = null; 103let projectSelectionGeneration = 0; 104/** 105 * SELECTION-scoped abort controller â the lifetime of "this project is the selected one", 106 * strictly shorter than the page's `abortController`. The page-level period edit/end dialogs 107 * are opened from the period bar, which lives in the page SHELL and therefore survives every 108 * in-page transition; without this, project A's period form sat open under project B's header 109 * and its save completed with a success toast that refreshed nothing (MCF-0615). Aborted at 110 * every point the selection stops being current, and replaced on the next commit. 111 * 112 * It is deliberately NOT the value handed to `createAccountingPeriodContext`'s `signal:` â 113 * that one doubles as the context's own self-destroy hook (accounting-period-context.js:134-137), 114 * so pointing it here would destroy the shared context on the first project switch. The context 115 * takes this one through `getActionSignal`, which rides the ACTION path only. 116 */ 117let selectionAbort = null; 118 119/** End the current selection scope: closes its dialogs and aborts their in-flight mutations. */ 120function endSelectionScope() { 121 selectionAbort?.abort(); 122 selectionAbort = null; 123} 124let currentSubModule = 'overview'; 125let activeSubModule = null; 126let inDashboard = false; 127let inSettings = false; 128let inAllInvoices = false; // the cross-project All Invoices module view (R7) 129let _navigating = false; 130// Deep-link target stashed from the route until the matching sub-module mounts 131// and can open it (e.g. /accounting/:id/change-orders/:recordId). 132let pendingRecordId = null; 133// Inner-tab segment for sub-modules whose detail has deep-linkable tabs (progapp: 134// /accounting/:id/progress-applications/:recordId/:tab). Consumed with pendingRecordId. 135let pendingTab = null; 136 137// Sub-modules whose detail view is URL-addressable. Each maps its PAGE_LINKS to 138// the hooks the orchestrator needs: read the open record id (for buildUrl) and 139// reconcile it with a route change for back/forward (syncFromRoute). An OPTIONAL 140// getCurrentTab lets a sub-module whose detail has deep-linkable inner tabs append 141// a `/tab` segment (progapp + cost-analysis + purchase-orders + change-orders); 142// syncFromRoute then takes (recId, tab). 143// An OPTIONAL detailSegment overrides the URL segment while a record is open â 144// cost-analysis's detail deep-links as `cost-codes/:costCodeId` (the segment 145// predates the fold-in and stays canonical for bookmarks + cross-module links). 146// Data-driven so the next deep-linkable sub-module is one row, not five edits. 147// See Developer/Layout/Deep-Link-Routing-Playbook.md. 148const DEEP_LINK_HOOKS = [ 149 { links: CHANGE_ORDERS_PAGE_LINKS, getCurrentId: getCurrentChangeOrderId, getCurrentTab: getCurrentChangeOrderTab, syncFromRoute: syncChangeOrderFromRoute }, 150 { links: INVOICES_PAGE_LINKS, getCurrentId: getCurrentInvoiceId, syncFromRoute: syncInvoiceFromRoute }, 151 { links: PURCHASE_ORDERS_PAGE_LINKS, getCurrentId: getCurrentPurchaseOrderId, getCurrentTab: getCurrentPurchaseOrderTab, syncFromRoute: syncPurchaseOrderFromRoute }, 152 { links: ACCOUNTING_SUPPLIERS_PAGE_LINKS, getCurrentId: getCurrentSupplierId, syncFromRoute: syncSupplierFromRoute }, 153 { links: PROGRESS_APPLICATIONS_PAGE_LINKS, getCurrentId: getCurrentProgressApplicationId, getCurrentTab: getCurrentProgressApplicationTab, syncFromRoute: syncProgressApplicationFromRoute }, 154 { links: COST_ANALYSIS_PAGE_LINKS, getCurrentId: getCurrentCostCodeId, getCurrentTab: getCurrentCostCodeTab, syncFromRoute: syncCostCodeFromRoute, detailSegment: 'cost-codes' }, 155 { links: ACCOUNTING_DELIVERIES_PAGE_LINKS, getCurrentId: getCurrentDeliveryId, syncFromRoute: syncDeliveryFromRoute }, 156]; 157 158function deepLinkHookFor(subModule) { 159 return DEEP_LINK_HOOKS.find((h) => h.links.includes(subModule)) || null; 160} 161 162// URL-segment aliases: a route segment that resolves to a different owning 163// sub-module. `/accounting/:id/cost-codes/:costCodeId` opens the Cost Analysis 164// sub-module with that cost code's detail view (buildUrl writes the alias back 165// via the hook's detailSegment, so the round trip is symmetric). 166const SUB_MODULE_ALIASES = { 'cost-codes': 'cost-analysis' }; 167 168function normalizeSubModule(id) { 169 return (id && SUB_MODULE_ALIASES[id]) || id; 170} 171 172/** 173 * Sub-modules that keep the list expanded when active. Overview gets the same 174 * treatment as Personal Info in HR â it's the "summary" view where you might 175 * want to quickly compare projects, so we don't auto-collapse. 176 */ 177const EXPANDED_SUB_MODULES = new Set(['overview']); 178 179/** 180 * Sub-modules that share the project's time period selector. When one of these
181 * is the active tab, the period bar is visible and dispatches period-changed 182 * events to the active sub-module's page module. 183 */ 184// Budgeted HRS is deliberately NOT here â its data is period-independent 185// (base cost-code hours), so it renders without the period bar (scorecard G13). 186// Budget is excluded for the SAME reason (R3 ruling 2026-07-29): legacy Budget 187// is period-BLIND â no period param on its endpoint, no period UI on the tab, 188// edits write base cc rows â and the new module consumes no period either. The 189// bar was visible on Budget but wired to nothing. 190const PERIOD_SCOPED_SUB_MODULES = new Set([ 191 'cost-analysis', 192 'labor-analysis', 193 'cash-flow-report', 194]); 195 196/** Shared period context â one selector/listener lifecycle per Accounting page mount. */ 197let periodContext = null; 198 199const listPanel = createCollapsibleListPanel({ 200 listColumnId: 'accounting-list-column', 201 toggleBtnId: 'btn-accounting-toggle-list', 202 miniContainerId: 'accounting-list-mini', 203 miniRowTemplateId: '#tpl-accounting-project-row-mini', 204 miniRowSelector: '.accounting-project-row-mini', 205 miniRowIdAttribute: 'data-project-id', 206 getItems: () => filteredProjects, 207 getSelectedId: () => currentProjectId, 208 onMiniRowClick: (id) => selectProject(id, { subModule: currentSubModule }), 209 expandedSubModules: EXPANDED_SUB_MODULES, 210}); 211 212// ======================================== 213// PUBLIC API 214// ======================================== 215 216/** Route record ids are numeric except the shared item-routes "task-{ProjectTaskID}" form 217 * (message/dashboard "Go To Progress Application" â legacy resolved PROGBILL by task id too, 218 * BECC:6702). Pass those through as strings; the owning sub-module resolves them. */ 219function parseRecordId(recordId) { 220 if (!recordId) return null; 221 const n = Number(recordId); 222 return Number.isFinite(n) ? n : String(recordId); 223} 224 225export async function initAccountingPage(selectedId = null, subModule = null, recordId = null, tab = null, opts = {}) { 226 abortController = new AbortController(); 227 projectSelectionGeneration += 1; 228 // THIS mount's identity, captured in the closure rather than read back off the module. The 229 // router installs `currentDestroyFn` BEFORE it awaits this function (router.js:256 then :259), 230 // so a teardown that preempts the live transition â auth.logout({ preemptCurrentTransition: 231 // true }), reached from api/base.js's 401 path and from offline replay with no user navigation 232 // at all â runs destroyAccountingPage() while the two awaits below are still in flight. Destroy 233 // nulls `abortController` (:289), so any guard written against the module-scope value is inert 234 // at exactly the moment it is needed; the captured signal is the only term that survives. 235 // Same shape and same mechanism as wwwroot/js/pages/portal/portal-enter.js:41-54. 236 const mountSignal = abortController.signal; 237 238 subModule = normalizeSubModule(subModule); // cost-codes â cost-analysis (+ record) 239 projects = []; 240 filteredProjects = []; 241 endSelectionScope(); 242 currentProjectId = null; 243 currentSubModule = subModule && isKnownSubModule(subModule) ? subModule : 'overview'; 244 pendingRecordId = parseRecordId(recordId); 245 pendingTab = tab || null; 246 247 await ensureTemplates(); 248 // Torn down mid-mount: initListHandlers' first statement is an UN-chained 249 // `abortController.signal` read (:529, and :754 in initTabHandlers), so the continuation would 250 // throw a TypeError that handleTransition swallows (:266-268), leaving a half-mounted page. 251 if (mountSignal.aborted) return; 252 253 initListHandlers(); 254 initTabHandlers(); 255 periodContext?.destroy(); 256 periodContext = createAccountingPeriodContext({ 257 containerId: 'accounting-period-bar', 258 getProjectId: () => currentProjectId, 259 fetchPeriods: (projectId, signal) => accountingTimePeriodsApi.getByProject(projectId, { signal }), 260 onChange: (periodId, period, projectId) => { 261 document.dispatchEvent(new CustomEvent('accounting:period-changed', { 262 detail: { periodId, period, projectId }, 263 })); 264 }, 265 editPeriod: ({ projectId, period, signal }) =>
265 showEditPeriodModal({ projectId, period, signal }), 266 endPeriod: ({ projectId, period, signal }) => showEndPeriodModal({ projectId, currentPeriod: period, signal }), 267 lockPeriod: async ({ projectId, period, signal }) => { 268 return accountingTimePeriodsApi.lock(projectId, period.timePeriodId, { signal }); 269 }, 270 unlockPeriod: async ({ projectId, period, signal }) => { 271 return accountingTimePeriodsApi.unlock(projectId, period.timePeriodId, { signal }); 272 }, 273 // The ACTION path (dialog + its mutation) rides the SELECTION's lifetime, not the page's: 274 // the period bar outlives every in-page transition, so a page-lifetime signal left project 275 // A's edit/end dialog open and savable under project B's header (MCF-0615). Read per event, 276 // never captured â `selectionAbort` is replaced on every commit. The page signal stays the 277 // fallback for the window between selections, where no action can start anyway. 278 getActionSignal: () => selectionAbort?.signal ?? abortController?.signal, 279 signal: abortController.signal, 280 errorLabel: 'Accounting', 281 }); 282 await loadProjects(); 283 // The likelier window, and the quieter failure: loadProjects swallows its own 401 inside 284 // safeAsync, so init resumes normally on a page destroy has already disposed and re-arms 285 // `inDashboard` at :991. Nothing resets it, so the NEXT mount takes the idempotent arm and 286 // never un-hides #accounting-dashboard-view â an empty detail column with no error. 287 if (mountSignal.aborted) return; 288 289 _navigating = true; 290 try { 291 if (opts.isSettings) { 292 await showAccountingSettings(opts.settingsTab); // /accounting/settings[/:tab] 293 } else if (opts.isAllInvoices) { 294 await showAccountingAllInvoices(); // /accounting/all-invoices (R7) 295 } else if (opts.isDashboard) { 296 await showAccountingDashboard(); // explicit /accounting/dashboard â mobile takes over 297 } else if (selectedId) { 298 await selectProject(Number(selectedId), { subModule: currentSubModule }); 299 } else { 300 // Bare /accounting â the dashboard IS the neutral detail state (module-dashboard 301 // playbook). No mobile takeover: phones keep the list first. 302 await showAccountingDashboard({ takeover: false }); 303 } 304 } finally { 305 _navigating = false; 306 } 307} 308 309export function destroyAccountingPage() { 310 destroyTabNav(document.getElementById('sub-module-tabs')); 311 projectSelectionGeneration += 1; 312 closeFilterPopover(); 313 destroyAccountingDashboard(); 314 hideAccountingDashboard(); 315 hideAccountingSettings(); 316 hideAccountingAllInvoices(); // releases its DataView (hide* calls destroy*) 317 teardownActiveSubModule(); 318 periodContext?.destroy(); 319 periodContext = null; 320 abortController?.abort(); 321 abortController = null; 322 projects = []; 323 filteredProjects = []; 324 projectsLoadFailed = false; 325 projectsLoading = false; 326 projectsLoadDescription = ''; 327 // Filter state, not covered by the loadProjects() reset path (Bible §1 "always-flag 328 // list") â without this it silently stays sticky across a full nav-away/back, and 329 // the popover's indicator dot (freshly rendered hidden by the main template) would 330 // then disagree with what's actually showing. Mirrors contract-admin's filterState reset. 331 filterState.showPrecon = false; 332 endSelectionScope(); 333 currentProjectId = null; 334 currentSubModule = 'overview'; 335 inDashboard = false; 336 _navigating = false; 337 pendingRecordId = null; 338 listPanel.reset(); 339} 340 341export async function navigateAccountingPage(id = null, subModule = null, recordId = null, tab = null, opts = {}) { 342 _navigating = true; 343 try { 344 subModule = normalizeSubModule(subModule); // cost-codes â cost-analysis (+ record) 345 const recId = parseRecordId(recordId); 346 const tabSeg = tab || null; 347 if (opts.isSettings) { 348 await showAccountingSettings(opts.settingsTab); 349 } else if (opts.isAllInvoices) { 350 await showAccountingAllInvoices(); // /accounting/all-invoices (R7) 351 } else if (opts.isDashboard) { 352 await showAccountingDashboard(); 353 } else if (id && id !== currentProjectId) { 354 pendingRecordId = recId; 355 pendingTab = tabSeg; 356 await selectProject(Number(id), { subModule: subModule || 'overview' }); 357 } else if (id && subModule && isKnownSubModule(subModule)) { 358 if (subModule !== currentSubModule) { 359 pendingRecordId = recId; 360 pendingTab = tabSeg; 361 switchAccountingSubModule(subModule); 362 } else { 363 // Same project + same sub-module: back/forward between a list and a 364 // specific detail (and, for tabbed detail, between inner tabs). Sync 365 // in place â no re-init, no URL re-push (the URL already matches). 366 deepLinkHookFor(subModule)?.syncFromRoute(recId, tabSeg); 367 } 368 } else if (!id) { 369 currentProjectId = null; 370 showEmptyDetail(); // also exits the dashboard + restores mobile columns 371 await showAccountingDashboard({ takeover: false }); // desktop's neutral state = the dashboard 372 } 373 } finally { 374 _navigating = false;
375 } 376} 377 378// ======================================== 379// LIST LOAD + RENDER 380// ======================================== 381 382async function loadProjects() { 383 const listEl = document.getElementById('accounting-list'); 384 if (!listEl) return; 385 // Captured before the first await â the page-instance identity the failure continuation checks. 386 // Same two-part test selectProject uses at :546-549: not aborted (teardown) AND still the same 387 // controller (a re-init installed a new one), so a superseded page's failure owns nothing. 388 const pageSignal = abortController?.signal; 389 // Cleared before the skeleton, not after the read: a retry must drop the previous failure the 390 // moment it repaints, or a keystroke landing during the re-read would still be refused. 391 projectsLoadFailed = false; 392 // Raised in the same breath, and its description dropped with it: the previous failure's cause 393 // must not survive into the window where a NEW read is in flight, or the dashboard would paint 394 // last attempt's 500 under this attempt's skeleton. 395 projectsLoading = true; 396 projectsLoadDescription = ''; 397 398 await renderTo(listEl, '#tpl-accounting-list-loading', {}, undefined, { restoreFocus: true }); 399 // The dashboard's own in-flight state, and it belongs HERE rather than in the retry branch: 400 // loadProjects has three callers (init, the retry, and a future refresh) and this covers all of 401 // them with one publication that sits BEFORE the first await of the read, so there is no 402 // post-await window for it to go stale in. `[]` not visibleProjects() on purpose â the skeleton 403 // must not be handed rows it is not going to render, and handing it the stale set is how a 404 // "loading" pane quietly keeps composing the previous answer. The family census the steward 405 // returned is the shape argument: 8 of the 9 V7 retries repaint their own pane, and this was 406 // the sole exception (CA-203 [functionality:1]). 407 if (inDashboard) await renderAccountingDashboard([], { loading: true }); 408 409 await safeAsync(async () => { 410 const response = await accountingApi.getProjectList({ signal: pageSignal }); 411 // Aborted, or a re-init installed a new controller, between resolve and publish â own 412 // nothing. `projects` is module-scope and feeds the list, the dashboard and selectProject. 413 if (!pageSignal || pageSignal.aborted || abortController?.signal !== pageSignal) return; 414 // Lowered INSIDE the guard, exactly like projectsLoadFailed is raised inside its own: a 415 // superseded page's answer must not lower the LIVE page's in-flight flag and let its 416 // dashboard compose over a set that instance never read. 417 projectsLoading = false; 418 projects = response.projects || []; 419 filteredProjects = visibleProjects(); 420 await renderProjectList(); 421 // The dashboard composes over these same rows â keep it live across list refreshes. 422 if (inDashboard) await renderAccountingDashboard(visibleProjects()); 423 }, {
424 label: 'Accounting', 425 toast: 'Failed to load Job Costing projects', 426 // MCF-0567/MCF-0584: without this the skeleton rendered above stayed on screen for the life 427 // of the visit â a toast that scrolls away was the only trace a real failure ever happened, 428 // and this is the LIST, so the whole module was unreachable with no way back. 429 // safeAsync calls onError for REAL errors only (utils/async.js:47 returns on abort), so a 430 // teardown still paints nothing; the re-check below covers a teardown or a re-init that 431 // lands between the rejection and this continuation. 432 // 433 // The dashboard is deliberately NOT *composed* here (the success path's `inDashboard` 434 // branch): with no rows, composing it over an empty set would overwrite the KPI pane with 435 // zeros as though the project set were genuinely empty. CA-203 sharpened that from "not 436 // re-rendered" to "not composed" â the arm's foot DOES repaint the pane, with the failure 437 // state and an empty array, which publishes no money at all. The ban was always on the 438 // zeros, never on telling the user what happened; reading it as "touch nothing" is what 439 // left the pane composing $0 in the first place, and later left a skeleton orphaned. 440 onError: async (err) => { 441 if (!pageSignal || pageSignal.aborted || abortController?.signal !== pageSignal) return; 442 // Set INSIDE the ownership guard: a superseded page's failure must not latch the live 443 // page's list into a failed state. The in-flight flag drops in the same place and for 444 // the same reason â this read is over, and only THIS instance may say so. 445 projectsLoading = false; 446 projectsLoadFailed = true; 447 const host = document.getElementById('accounting-list'); 448 if (!host) return; 449 // The REAL cause, not a guess: a 500, a 403 and a parse failure all ANSWERED, so the 450 // template's fallback commits to nothing and the real message wins when we have one â 451 // with ONE exception, the rule utils/task-form-load.js:42-49 already earned. 452 // OfflineCacheMissError's message is "Not available offline: <endpoint>", and a raw 453 // endpoint path is not user copy. "Couldn't reach the server" rather than "you're 454 // offline" because that error fires for ANY network failure plus a cache miss: the user 455 // may be online with the API down. 456 const description = err?.name === 'OfflineCacheMissError' 457 ? "Couldn't reach the server, and this list isn't cached for offline use." 458 : (err?.message || ''); 459 // ONE expression, TWO panes. The dashboard reads this rather than recomputing the same 460 // ternary, because two computations of "the real cause" are two things that can 461 // disagree â and the offline arm above is the exact sentence a second author would get 462 // wrong (a raw endpoint path is not user copy; utils/task-form-load.js:42-49 is the 463 // earned rule). Assigned INSIDE the ownership guard with the flags, and reset in 464 // exactly two places (loadProjects' head, destroyAccountingPage) â no third reset site, 465 // per the second-truth warning the footer comment below already carries. 466 projectsLoadDescription = description; 467 await renderTo(host, '#tpl-accounting-list-error', { description }, undefined, { restoreFocus: true }); 468 // The column FOOTER is not inside the subtree the pane just replaced 469 // (accounting-main.html:44-46), so without this it keeps asserting a definite count six 470 // pixels under a pane saying the count could not be loaded: the literal "0 projects" 471 // from the shell template on a first-load failure, or the last SUCCESSFUL number on a 472 // failed refresh. `projectsLoadFailed` guarantees the freeze rather than relieving it â 473 // its early return in renderProjectList sits above that footer's only writer. Copy that 474 // commits to nothing, by the same test the {{else}} fallback passes: not "0 projects", 475 // not a bare dash, nothing that reads as a measured value. 476 // NO reset is added here on purpose. The existing path already covers it: loadProjects 477 // clears projectsLoadFailed before the skeleton, and a successful renderProjectList 478 // rewrites this element from the real set. A second reset would be a second truth. 479 // 480 // Ownership RE-checked across the render await above. This used to say "the only 481 // publication in this arm that sits past an async boundary", and CA-203 made that false 482 // by adding the dashboard repaint below â so the guard now covers TWO publications, and 483 // both sit under it with no await between them. A teardown or re-init landing inside 484 // renderTo would otherwise let this write clobber a freshly-mounted page's real count, 485 // and nothing would correct it until that page's next successful load. Bailing here is 486 // safe: the new instance clears projectsLoadFailed before its own skeleton. 487 if (pageSignal.aborted || abortController?.signal !== pageSignal) return; 488 const countEl = document.getElementById('accounting-count'); 489 if (countEl) countEl.textContent = 'Count unavailable'; 490 // The dashboard's OWN ending for this read, and it is required rather than symmetrical 491 // decoration: the pre-read paint above put a SKELETON in that pane, and a skeleton is a 492 // promise. Without this line a retry pressed from the dashboard's own error pane would 493 // shimmer forever on a second failure â the pane would have replaced a live retry 494 // button with an animation, which is a worse dead end than the frozen retry this card 495 // set out to fix. This is NOT the compose the arm's own comment above forbids: it hands 496 // the renderer an empty array with `failed`, so no money is published and no `$0` tile 497 // exists â the two are different acts on the same call, and the ban is on the money. 498 // Guarded by the re-check above, and it is the LAST publication in the arm. 499 if (inDashboard) await renderAccountingDashboard([], { failed: true, description }); 500 // No re-bind here, and that is the point: #accounting-list's delegation is bound ONCE 501 // at init (initListHandlers) on the persistent element and survives every subtree 502 // swap, so the retry branch there is already live over this pane. 503 }, 504 }); 505} 506 507async function renderProjectList({ filtered = false } = {}) { 508 // The V7 failure pane owns the list until a retry succeeds. Every caller that is NOT 509 // loadProjects â the shell-hosted search input and the filter popover, both outside the 510 // subtree the pane replaced â would otherwise repaint an empty `projects` as "No projects 511 // yet" and remove the only recovery affordance on the screen. 512 if (projectsLoadFailed) return; 513 const listEl = document.getElementById('accounting-list'); 514 const countEl = document.getElementById('accounting-count'); 515 if (!listEl) return; 516 517 if (filteredProjects.length === 0) { 518 await renderTo(listEl, '#tpl-accounting-list-empty', { filtered }); 519 } else { 520 await renderTo(listEl, '#tpl-accounting-project-row', filteredProjects); 521 522 // Re-apply the selection highlight after a re-render (e.g. search/filter), 523 // so the active card stays lit while the list refilters. Mirrors divisions/hr. 524 if (currentProjectId != null) { 525 listEl.querySelector(`.accounting-project-card[data-project-id="${currentProjectId}"]`) 526 ?.classList.add('list-row-selected'); 527 } 528 } 529 530 if (countEl) { 531 const n = visibleProjects().length; 532 const hidden = projects.length - n; 533 countEl.textContent = `${n} project${n === 1 ? '' : 's'}${hidden > 0 ? ` (${hidden} pre-con hidden)` : ''}`; 534 } 535 536 if (listPanel.isCollapsed()) listPanel.renderMiniList(); 537} 538 539function applyFilters(searchTerm = '') { 540 const term = (searchTerm || '').toLowerCase(); 541 filteredProjects = visibleProjects().filter(p => { 542 if (!term) return true; 543 const haystack = [p.projectNumber, p.projectTitle].map(v => (v || '').toLowerCase()).join(' '); 544 return haystack.includes(term); 545 }); 546 const isFilteredEmpty = !!term && filteredProjects.length === 0 && projects.length > 0; 547 renderProjectList({ filtered: isFilteredEmpty }); 548} 549 550// ======================================== 551// FILTER POPOVER (mirrors contract-admin's btn-ca-filter) 552// ======================================== 553 554function toggleFilterPopover() { 555 if (filterPopoverEl) { 556 closeFilterPopover(); 557 return; 558 } 559 560 const filterBtn = document.getElementById('btn-accounting-filter'); 561 if (!filterBtn) return; 562 563 filterPopoverEl = document.createElement('div'); 564 filterPopoverEl.className = 'absolute right-0 top-full mt-2 w-64 card-st p-4 shadow-sb-lg z-50'; 565 566 const tpl = $.templates('#tpl-accounting-filter-popover'); 567 filterPopoverEl.innerHTML = tpl.render({}, { 568 showPrecon: filterState.showPrecon, 569 preconCount: projects.filter(p => p.projectTypeFlag === 'P').length, 570 }); 571 572 // The search-row wrapper carries `relative` in accounting-main.html â Bible §3 keeps 573 // static layout properties in markup, not in a JS style assignment. 574 filterBtn.parentElement.appendChild(filterPopoverEl); 575 576 filterPopoverEl.querySelectorAll('input[type="checkbox"]').forEach(cb => { 577 cb.addEventListener('change', async () => {
578 if (cb.dataset.filter === 'type' && cb.value === 'precon') { 579 filterState.showPrecon = cb.checked; 580 } 581 applyFilters(document.getElementById('accounting-search')?.value || ''); 582 updateFilterIndicator(); 583 // The dashboard composes over the same visible set â keep it in step. The failure flag 584 // rides along: this handler is reachable while the list is showing its V7 error pane 585 // (the popover lives in the shell, outside the subtree that pane replaced), and without 586 // the flag a filter toggle would quietly repaint the KPI tiles as $0 over the empty 587 // `projects` a failed read left behind. 588 if (inDashboard) await renderAccountingDashboard(visibleProjects(), { failed: projectsLoadFailed, loading: projectsLoading, description: projectsLoadDescription }); 589 }); 590 }); 591} 592 593function closeFilterPopover() { 594 if (filterPopoverEl) { 595 filterPopoverEl.remove(); 596 filterPopoverEl = null; 597 } 598} 599 600function updateFilterIndicator() { 601 // Dot = filter differs from default (default hides pre-con). 602 document.getElementById('accounting-filter-indicator') 603 ?.classList.toggle('hidden', !filterState.showPrecon); 604} 605 606// ======================================== 607// EVENT HANDLERS (LIST) 608// ======================================== 609 610function initListHandlers() { 611 const opts = { signal: abortController.signal }; 612 613 document.getElementById('accounting-search')?.addEventListener('input', (e) => { 614 applyFilters(e.target.value); 615 }, opts); 616 617 document.getElementById('btn-accounting-filter')?.addEventListener('click', () => toggleFilterPopover(), opts); 618 619 // Click-outside closes the filter popover (mirrors contract-admin). 620 document.addEventListener('click', (e) => { 621 if (filterPopoverEl && !filterPopoverEl.contains(e.target) && !e.target.closest('#btn-accounting-filter')) { 622 closeFilterPopover(); 623 } 624 }, opts); 625 626 document.getElementById('accounting-list')?.addEventListener('click', (e) => { 627 // The V7 list-error pane's retry. It rides THIS delegation rather than binding its own: 628 // #accounting-list is the persistent scroll region (accounting-main.html:41) and this 629 // listener is bound once per page instance, so the retry is live even when the very first 630 // load is the one that failed. loadProjects() re-renders the skeleton first. 631 if (e.target.closest('#btn-accounting-list-retry')) { 632 void loadProjects(); 633 return; 634 } 635 const card = e.target.closest('.accounting-project-card'); 636 if (!card) return; 637 const id = Number(card.dataset.projectId); 638 if (id) selectProject(id, { subModule: currentSubModule }); 639 }, opts); 640 641 listPanel.attachHandlers(abortController.signal); 642 643 document.getElementById('btn-accounting-mobile-back')?.addEventListener('click', () => { 644 showEmptyDetail(); 645 if (!_navigating) page('/accounting'); 646 }, opts); 647 648 // Dashboard (the module HOME) â the list-column link, its collapsed-rail mini, mobile back, 649 // and the delegated all-projects table row click/keyboard (rows carry data-dash-project). 650 document.getElementById('btn-accounting-dashboard')?.addEventListener('click', () => page('/accounting/dashboard'), opts); 651 document.getElementById('btn-accounting-dashboard-mini')?.addEventListener('click', () => page('/accounting/dashboard'), opts); 652 document.getElementById('btn-accounting-dashboard-back')?.addEventListener('click', () => page('/accounting'), opts); 653 document.getElementById('btn-accounting-settings-back')?.addEventListener('click', () => page('/accounting'), opts); 654 document.getElementById('btn-accounting-all-invoices-back')?.addEventListener('click', () => page('/accounting'), opts); 655 const dashContent = document.getElementById('accounting-dashboard-content'); 656 dashContent?.addEventListener('click', (e) => { 657 // The V7 dashboard-error pane's retry. It rides THIS delegation rather than binding its 658 // own: #accounting-dashboard-content is the persistent pane host (accounting-main.html:203) 659 // and this listener is bound once per page instance, so the retry is live even when the 660 // very first load is the one that failed â the same contract #btn-accounting-list-retry 661 // has on #accounting-list. loadProjects() re-renders the list skeleton first, and its own 662 // success arm re-composes this pane (`if (inDashboard) await renderAccountingDashboard`), 663 // so no second compose is issued here; a repeated failure re-latches projectsLoadFailed 664 // and deliberately leaves the honest pane standing. 665 if (e.target.closest('#btn-accounting-dashboard-retry')) { 666 void loadProjects(); 667 return; 668 } 669 // Combined multi-project report (legacy company-wide export) â delegated 670 // because the dashboard pane re-renders on every visit. 671 if (e.target.closest('#acc-dashboard-export-report')) { 672 openOverviewReportModal(); 673 return; 674 }
675 const row = e.target.closest('[data-dash-project]'); 676 if (row) page(`/accounting/${row.dataset.dashProject}`); 677 }, opts); 678 dashContent?.addEventListener('keydown', (e) => { 679 if (e.key !== 'Enter' && e.key !== ' ') return; 680 const row = e.target.closest('[data-dash-project]'); 681 if (row) { e.preventDefault(); page(`/accounting/${row.dataset.dashProject}`); } 682 }, opts); 683 684 // Module-level tab strip (Dashboard â grows as module views arrive). 685 document.getElementById('accounting-module-tabs')?.addEventListener('click', (e) => { 686 const btn = e.target.closest('[data-module-tab]'); 687 if (!btn) return; 688 const target = { 689 dashboard: '/accounting/dashboard', 690 'all-invoices': '/accounting/all-invoices', 691 settings: '/accounting/settings', 692 }[btn.dataset.moduleTab]; 693 if (target) page(target); 694 }, opts); 695} 696 697/** 698 * Combined multi-project accounting report â legacy's company-wide dashboard 699 * export (#overviewreportmodal â one overview PDF per project, combined). 700 * Picker list = the already-loaded rollup rows; zero new reads. 701 */ 702async function openOverviewReportModal() { 703 const projects = visibleProjects().map((p) => ({ 704 projectId: p.projectId, 705 projectNumber: p.projectNumber || '', 706 projectTitle: p.projectTitle || '(no title)', 707 })); 708 if (!projects.length) { 709 window.showToast?.('No projects to report on', 'info'); 710 return; 711 } 712 713 const modal = await showModal('#tpl-acc-overview-report-modal', { projects }, { signal: abortController?.signal }); 714 const root = modal.element; 715 716 root.querySelector('#acc-report-select-all')?.addEventListener('change', (e) => { 717 root.querySelectorAll('.acc-report-project').forEach((cb) => { cb.checked = e.target.checked; }); 718 }); 719 720 const downloadBtn = root.querySelector('#acc-overview-report-download'); 721 downloadBtn?.addEventListener('click', async () => { 722 const ids = [...root.querySelectorAll('.acc-report-project:checked')].map((cb) => Number(cb.value)).filter(Number.isFinite); 723 if (!ids.length) { 724 // Legacy parity: "You Must Select At Least One Project" â inline flag on the 725 // project list, not a bare toast (PATTERNS_BIBLE §9b / flagFieldError). 726 const listEl = root.querySelector('#acc-report-project-list') || root; 727 flagFieldError(root, listEl, 'Select at least one project', { clearOn: 'click' }); 728 return; 729 } 730 await runWithBusyButton(downloadBtn, 'Buildingâ¦', async () => { 731 const { blob, fileName } = await accountingOverviewApi.exportCombinedPdf(ids); 732 downloadBlob(blob, fileName || 'Project Accounting Reports.pdf', { successMessage: 'PDF downloaded' }); 733 modal.close(); 734 }, { errorTitle: 'Export failed', resetOnSuccess: true }); 735 }); 736} 737 738// ======================================== 739// DETAIL (SELECT + TAB SWITCH) 740// ======================================== 741 742async function selectProject(id, { subModule = null } = {}) { 743 const project = projects.find(p => p.projectId === id); 744 if (!project) { 745 window.showToast?.('Project not found', 'info', { 746 description: 'It may have been archived or you no longer have access.', 747 }); 748 return; 749 } 750 751 const projectChanged = id !== currentProjectId; 752 if (projectChanged) teardownActiveSubModule(); 753 // The DEPARTING project's selection scope ends here â same instant its sub-module is torn 754 // down. A re-select of the SAME project (a sub-module switch, a deep-link sync) changes no 755 // selection, so an open dialog for that project survives it. 756 if (projectChanged) endSelectionScope(); 757 const generation = ++projectSelectionGeneration; 758 const pageSignal = abortController?.signal; 759 const isCurrentSelection = () => !pageSignal?.aborted 760 && abortController?.signal === pageSignal 761 && projectSelectionGeneration === generation 762 && currentProjectId === id; 763 currentProjectId = id; 764 // The selection commits here, with `currentProjectId`. `endSelectionScope()` above nulled it 765 // on a change;
765 the guard keeps a same-project re-select on the controller it already has. 766 if (!selectionAbort) selectionAbort = new AbortController(); 767 currentSubModule = subModule && isKnownSubModule(subModule) ? subModule : 'overview'; 768 769 hideAccountingDashboard(); // leaving the module home to open a project 770 hideAccountingSettings(); 771 hideAccountingAllInvoices(); 772 hideModuleTabs(); // the strip belongs to the module views, not a project 773 774 document.getElementById('accounting-detail-empty')?.classList.add('hidden'); 775 document.getElementById('accounting-detail-content')?.classList.remove('hidden'); 776 777 // Below 1024px the two columns share one viewport: the shell ships the detail column 778 // `hidden lg:flex`, so a selection that un-hides only the INNER content paints into a 779 // `display:none` column and the phone shows the unchanged list with no feedback. Swap to 780 // the detail column and surface the back-to-list header, exactly as the fifteen peer 781 // list-detail modules do in their mobile show-detail helper â exemplar: 782 // `wwwroot/js/pages/team.js:188-204`. (Spelled out rather than naming that helper's 783 // identifier, so a §28 census grepping for it still counts DEFINITIONS, not comments.) 784 // Sits on the shared select path, so list click, cold deep-link and Back/Forward reach it; the 785 // class writes are idempotent, so an in-page re-select is a no-op. 786 if (isMobileOrTablet()) { 787 document.getElementById('accounting-list-column')?.classList.add('hidden'); 788 const detailColumn = document.getElementById('accounting-detail-column'); 789 if (detailColumn) { detailColumn.classList.remove('hidden', 'lg:flex'); detailColumn.classList.add('flex', 'w-full'); } 790 document.getElementById('accounting-mobile-back-header')?.classList.remove('hidden'); 791 } 792 793 const headerContainer = document.getElementById('accounting-detail-header'); 794 try { 795 if (headerContainer) { 796 const staging = document.createElement('div'); 797 await renderTo(staging, '#tpl-accounting-project-header', project); 798 if (!isCurrentSelection() || !headerContainer.isConnected) return; 799 headerContainer.replaceChildren(...staging.childNodes); 800 } 801 802 if (!isCurrentSelection()) return; 803 804 // Selection marks the NEW project the moment its header commits. These depend only on `id` 805 // â nothing below decides them â so leaving them at the tail meant the list kept marking 806 // the PREVIOUS project through both awaits, under the new project's header and pane. 807 // `isCurrentSelection()` above already carries the generation guard. CA-199 (MCF-0606 808 // shape (b)). One site: the tail copy is gone, not duplicated. 809 // Canonical `.list-row-selected` (in-place card-st border recolor) â NOT an outset ring, 810 // which the overflow-y scroll container clips on the right edge. Matches divisions/hr. 811 document.querySelectorAll('.accounting-project-card').forEach(card => { 812 card.classList.toggle('list-row-selected', Number(card.dataset.projectId) === id); 813 }); 814 if (listPanel.isCollapsed()) listPanel.updateMiniSelection(); 815 816 renderSubModuleTabs(visibleSubModules(), currentSubModule, 'accounting'); 817 818 // Mount partition â MCF-0607 repair shape (c). 819 // THE TRAP, documented in Developer/PM/work-orders/done/CA-196.md Feasibility: the three 820 // PERIOD_SCOPED_SUB_MODULES read `periodContext.getActivePeriodId()` at DISPATCH time 821 // (activateSubModule :765/:767/:769), so a blind mount-before-refresh would hand 822 // cost-analysis / labor-analysis / cash-flow-report the PREVIOUS project's period id. Those 823 // three keep today's order exactly. The other ELEVEN â including the default `overview` 824 // landing â consume no period, so they mount FIRST and fill the switch window with their own 825 // skeletons instead of the blank pane CA-196's teardown clear leaves for a whole round-trip. 826 const periodScoped = PERIOD_SCOPED_SUB_MODULES.has(currentSubModule); 827 if (!periodScoped) showActivePanel(currentSubModule); 828
829 // Refresh the period selector when the project changes â periods are per-project. 830 if (projectChanged) { 831 await periodContext?.refresh(); 832 if (!isCurrentSelection()) return; 833 } 834 835 // RE-EVALUATED, deliberately not `periodScoped`. That capture is a routing decision taken 836 // before the await; `currentSubModule` is read live here, and its in-page writer â 837 // switchAccountingSubModule, reached from the delegated listener bound once on the 838 // persistent #sub-module-tabs strip (initTabHandlers) â stays live for the whole round-trip 839 // by design. A mid-flight tab click from a non-scoped tab to a period-scoped one would 840 // otherwise mount that module with `timePeriodId: null` (this function's own pre-await 841 // clear emptied the selector) and the stale capture would then skip the corrective 842 // re-dispatch that existed before the partition. A value captured before an await owns 843 // nothing after it. CA-199 [functionality:1]. 844 if (PERIOD_SCOPED_SUB_MODULES.has(currentSubModule)) showActivePanel(currentSubModule); 845 } catch (error) { 846 if (error?.name === 'AbortError' || !isCurrentSelection()) return; 847 throw error; 848 } 849 850 if (!isCurrentSelection()) return; 851 852 if (!_navigating) { 853 page(buildUrl()); 854 } 855} 856 857function initTabHandlers() { 858 const opts = { signal: abortController.signal }; 859 const tabContainer = document.getElementById('sub-module-tabs'); 860 if (!tabContainer) return; 861 862 tabContainer.addEventListener('click', (e) => { 863 const btn = e.target.closest('.accounting-tab-btn'); 864 if (!btn) return; 865 const id = btn.dataset.tab; 866 if (id && id !== currentSubModule) switchAccountingSubModule(id); 867 }, opts); 868} 869 870 871function switchAccountingSubModule(moduleId) { 872 if (!isKnownSubModule(moduleId)) return; 873 currentSubModule = moduleId; 874 875 document.querySelectorAll('#sub-module-tabs .accounting-tab-btn').forEach(btn => { 876 const on = btn.dataset.tab === moduleId; 877 btn.classList.toggle('active', on); 878 btn.setAttribute('aria-selected', String(on)); 879 }); 880 881 showActivePanel(moduleId); 882 883 if (!_navigating) page(buildUrl()); 884} 885 886function showActivePanel(moduleId) { 887 const root = document.getElementById('accounting-detail-content'); 888 if (!root) return; 889 root.querySelectorAll('.tab-panel').forEach(p => p.classList.add('hidden')); 890 document.getElementById(`accounting-tab-${moduleId}`)?.classList.remove('hidden'); 891 if (window.lucide) { 892 const panel = document.getElementById(`accounting-tab-${moduleId}`); 893 if (panel) window.lucide.createIcons({ root: panel }); 894 } 895 896 listPanel.syncForSubModule(moduleId); 897 898 // Show / hide the shared period selector based on the active tab. 899 periodContext?.setVisible(PERIOD_SCOPED_SUB_MODULES.has(moduleId)); 900 901 activateSubModule(moduleId); 902} 903 904function activateSubModule(moduleId) { 905 if (activeSubModule && activeSubModule !== moduleId) { 906 teardownActiveSubModule(); 907 } 908 909 const signal = abortController?.signal; 910 911 // Shared deep-link wiring for sub-modules with URL-addressable detail views. 912 // initialId opens a record on a cold/deep-link mount (silent); onNavigate 913 // writes the URL on user-driven open/close. Inert for non-deep-link modules. 914 const onNavigate = () => { if (!_navigating) page(buildUrl()); }; 915 const deepLinkOpts = { initialId: pendingRecordId, initialTab: pendingTab, onNavigate }; 916 917 if (OVERVIEW_PAGE_LINKS.includes(moduleId)) { 918 initAccountingOverview(currentProjectId, signal); 919 } else if (BUDGET_PAGE_LINKS.includes(moduleId)) { 920 initAccountingBudget(currentProjectId, signal); 921 } else if (BUDGETED_HRS_PAGE_LINKS.includes(moduleId)) { 922 initAccountingBudgetedHrs(currentProjectId, signal); 923 } else if (PROGRESS_APPLICATIONS_PAGE_LINKS.includes(moduleId)) { 924 initAccountingProgressApplications(currentProjectId, signal, deepLinkOpts); 925 } else if (PAYMENT_CERTIFICATE_PAGE_LINKS.includes(moduleId)) { 926 initAccountingPaymentCertificate(currentProjectId, signal); 927 } else if (COST_ANALYSIS_PAGE_LINKS.includes(moduleId)) { 928 initAccountingCostAnalysis(currentProjectId, signal, { ...deepLinkOpts, timePeriodId: periodContext?.getActivePeriodId() ?? null }); 929 } else if (LABOR_ANALYSIS_PAGE_LINKS.includes(moduleId)) { 930 initAccountingLaborAnalysis(currentProjectId, signal, { timePeriodId: periodContext?.getActivePeriodId() ?? null }); 931 } else if (CASH_FLOW_REPORT_PAGE_LINKS.includes(moduleId)) { 932 initAccountingCashFlowReport(currentProjectId, signal, { timePeriodId: periodContext?.getActivePeriodId() ?? null }); 933 } else if (ACCOUNTING_SUPPLIERS_PAGE_LINKS.includes(moduleId)) { 934 initAccountingSuppliers(currentProjectId, signal, deepLinkOpts); 935 } else if (INVOICES_PAGE_LINKS.includes(moduleId)) { 936 initAccountingInvoices(currentProjectId, signal, deepLinkOpts); 937 } else if (CHANGE_ORDERS_PAGE_LINKS.includes(moduleId)) { 938 initAccountingChangeOrders(currentProjectId, signal, deepLinkOpts); 939 } else if (PURCHASE_ORDERS_PAGE_LINKS.includes(moduleId)) { 940 initAccountingPurchaseOrders(currentProjectId, signal, deepLinkOpts); 941 } else if (ACCOUNTING_DELIVERIES_PAGE_LINKS.includes(moduleId)) { 942 initAccountingDeliveries(currentProjectId, signal, deepLinkOpts); 943 } else if (NETSUITE_EXPORT_PAGE_LINKS.includes(moduleId)) { 944 initAccountingNetSuiteExport(currentProjectId, signal); 945 } 946 947 // Consume the deep-link target once â only the just-mounted sub-module uses it. 948 pendingRecordId = null; 949 pendingTab = null; 950 activeSubModule = moduleId; 951} 952 953function teardownActiveSubModule() { 954 if (!activeSubModule) return; 955 if (OVERVIEW_PAGE_LINKS.includes(activeSubModule)) { 956 destroyAccountingOverview(); 957 } else if (BUDGET_PAGE_LINKS.includes(activeSubModule)) { 958 destroyAccountingBudget(); 959 } else if (BUDGETED_HRS_PAGE_LINKS.includes(activeSubModule)) { 960 destroyAccountingBudgetedHrs(); 961 } else if (PROGRESS_APPLICATIONS_PAGE_LINKS.includes(activeSubModule)) { 962 destroyAccountingProgressApplications(); 963 } else if (PAYMENT_CERTIFICATE_PAGE_LINKS.includes(activeSubModule)) { 964 destroyAccountingPaymentCertificate(); 965 } else if (COST_ANALYSIS_PAGE_LINKS.includes(activeSubModule)) { 966 destroyAccountingCostAnalysis(); 967 } else if (LABOR_ANALYSIS_PAGE_LINKS.includes(activeSubModule)) { 968 destroyAccountingLaborAnalysis(); 969 } else if (CASH_FLOW_REPORT_PAGE_LINKS.includes(activeSubModule)) { 970 destroyAccountingCashFlowReport(); 971 } else if (ACCOUNTING_SUPPLIERS_PAGE_LINKS.includes(activeSubModule)) { 972 destroyAccountingSuppliers(); 973 } else if (INVOICES_PAGE_LINKS.includes(activeSubModule)) { 974 destroyAccountingInvoices(); 975 } else if (CHANGE_ORDERS_PAGE_LINKS.includes(activeSubModule)) { 976 destroyAccountingChangeOrders(); 977 } else if (PURCHASE_ORDERS_PAGE_LINKS.includes(activeSubModule)) { 978 destroyAccountingPurchaseOrders(); 979 } else if (ACCOUNTING_DELIVERIES_PAGE_LINKS.includes(activeSubModule)) { 980 destroyAccountingDeliveries(); 981 } else if (NETSUITE_EXPORT_PAGE_LINKS.includes(activeSubModule)) { 982 destroyAccountingNetSuiteExport(); 983 } 984 // Clear the departing sub-module's rendered pane. The destroy dispatch above releases module 985 // state, controllers and listeners but performs ZERO DOM work, so without this the previous 986 // project's rows and money totals stay on screen underneath the NEW project's already-committed 987 // header â for the whole `await periodContext?.refresh()` network round-trip (selectProject 988 // :660-671) plus the replacement module's own template fetch, before that module paints its 989 // first skeleton. On mobile the detail column is a full-viewport takeover, so it is the entire 990 // screen. Honest-blank replaces stale-wrong for that window;
990 the mid-load skeleton stays owned 991 // by each module's own init. CA-196 (MCF-0588), executing CA-190 [ux:1] shape (b). 992 // 993 // Empty the panel's element CHILDREN; never remove the container elements themselves. Every 994 // sub-module resolves its container by id and early-returns when it is missing (it would then 995 // render nothing at all), the delegated handlers are bound to those persistent elements behind 996 // module-scope `*HandlersAttached` latches that destroy deliberately does NOT reset, and 997 // project-deliveries.js:303-311 keeps its bind latch in `container.dataset` â all three survive 998 // only because the element does. Runs AFTER the destroy dispatch: destroyAccountingBudget 999 // (accounting-budget.js:147) reads `#acc-budget-rows` from inside the container to tear down its 1000 // SearchableDropdowns, so clearing first would silently skip that cleanup. 1001 const departingPanel = document.getElementById(`accounting-tab-${activeSubModule}`); 1002 if (departingPanel) { 1003 Array.from(departingPanel.children).forEach((container) => container.replaceChildren()); 1004 } 1005 activeSubModule = null; 1006} 1007 1008function showEmptyDetail() { 1009 hideAccountingDashboard(); 1010 hideAccountingSettings(); 1011 hideAccountingAllInvoices(); 1012 hideModuleTabs(); 1013 teardownActiveSubModule(); 1014 document.getElementById('accounting-detail-content')?.classList.add('hidden'); 1015 document.getElementById('accounting-detail-empty')?.classList.remove('hidden'); 1016 // Restore the mobile column state. Four paths hide the list column below 1024px â the 1017 // dashboard takeover, a project selection, Settings and All Invoices â and this restore 1018 // reverses all four. Already in this state on desktop and on a phone that never left the 1019 // list, so it is a no-op there (CA-207). 1020 const listColumn = document.getElementById('accounting-list-column'); 1021 const detailColumn = document.getElementById('accounting-detail-column'); 1022 listColumn?.classList.remove('hidden'); 1023 if (detailColumn) { 1024 detailColumn.classList.remove('flex', 'w-full'); 1025 detailColumn.classList.add('hidden', 'lg:flex'); 1026 } 1027 document.getElementById('accounting-mobile-back-header')?.classList.add('hidden'); 1028 // Clear the expanded-list highlight on deselect (mirrors divisions/hr) â the 1029 // list stays visible with no detail, so a stale `.list-row-selected` would linger. 1030 document.querySelectorAll('.accounting-project-card').forEach(card => { 1031 card.classList.remove('list-row-selected'); 1032 }); 1033 endSelectionScope(); 1034 currentProjectId = null; 1035} 1036 1037// ======================================== 1038// MODULE VIEWS â dashboard pane + tab strip (module-dashboard playbook) 1039// ======================================== 1040 1041/** Show the module-level tab strip with the given tab active (the team-detail idiom). */ 1042function showModuleTabs(active) { 1043 // The list-column dashboard link mirrors the row-selection treatment while its view is open. 1044 document.getElementById('btn-accounting-dashboard')?.classList.toggle('list-row-selected', active === 'dashboard'); 1045 const strip = document.getElementById('accounting-module-tabs'); 1046 if (!strip) return; 1047 strip.classList.remove('hidden'); 1048 strip.querySelectorAll('[data-module-tab]').forEach(btn => 1049 btn.classList.toggle('active', btn.dataset.moduleTab === active)); 1050} 1051 1052/** Hide the strip (a project is selected, or back to the bare mobile list). */ 1053function hideModuleTabs() { 1054 document.getElementById('accounting-module-tabs')?.classList.add('hidden'); 1055 document.getElementById('btn-accounting-dashboard')?.classList.remove('list-row-selected'); 1056} 1057 1058/** 1059 * Show the Accounting Dashboard in the detail column. Deselects any project + tears down 1060 * its live sub-tab. takeover=false = the neutral bare-/accounting state: desktop shows it 1061 * in the detail column, phones keep the list (no column swap, no back header). 1062 */ 1063async function showAccountingDashboard({ takeover = true } = {}) { 1064 // Mobile: swap to the detail column ONLY on an explicit open. Runs BEFORE the 1065 // idempotent return: a phone can be in the neutral state (inDashboard, pane hidden 1066 // behind the list) and then open the dashboard explicitly â the swap must still happen. 1067 if (takeover && isMobileOrTablet()) { 1068 document.getElementById('accounting-list-column')?.classList.add('hidden'); 1069 const detailColumn = document.getElementById('accounting-detail-column'); 1070 if (detailColumn) { detailColumn.classList.remove('hidden', 'lg:flex'); detailColumn.classList.add('flex', 'w-full'); } 1071 } 1072 1073 // `inDashboard` is re-read AFTER the render, not trusted from before it: selectProject / 1074 // showAccountingSettings / showAccountingAllInvoices all run hideAccountingDashboard() + 1075 // hideModuleTabs() from live click handlers, and any of them can land inside this await. 1076 // Re-showing the strip afterwards would put a dashboard-active tab strip over a project 1077 // detail. Lifecycle-preflight POST_AWAIT_PUBLICATION_REVIEW, CA-202. 1078 if (inDashboard) { 1079 await renderAccountingDashboard(visibleProjects(), { failed: projectsLoadFailed, loading: projectsLoading, description: projectsLoadDescription }); 1080 if (!inDashboard) return; 1081 showModuleTabs('dashboard'); 1082 return; 1083 } 1084 1085 hideAccountingSettings(); 1086 hideAccountingAllInvoices(); 1087 teardownActiveSubModule(); // don't leave a project's sub-module alive behind the dashboard 1088 endSelectionScope(); 1089 currentProjectId = null; 1090 document.getElementById('accounting-detail-empty')?.classList.add('hidden'); 1091 document.getElementById('accounting-detail-content')?.classList.add('hidden'); 1092 document.getElementById('accounting-mobile-back-header')?.classList.add('hidden'); 1093 document.querySelectorAll('.accounting-project-card').forEach(card => card.classList.remove('list-row-selected')); 1094 if (listPanel.isCollapsed()) listPanel.updateMiniSelection(); 1095 1096 document.getElementById('accounting-dashboard-view')?.classList.remove('hidden'); 1097 inDashboard = true; 1098 showModuleTabs('dashboard'); 1099 // `projectsLoadFailed` is the honesty gate on BOTH entry arms. The dashboard is the neutral 1100 // detail state for bare /accounting, so the very first thing a user sees after a failed 1101 // project-list read is this pane â composing it over the empty `projects` that failure left 1102 // behind would print $0 across four money tiles as though the books were empty (MCF-0597). 1103 await renderAccountingDashboard(visibleProjects(), { failed: projectsLoadFailed, loading: projectsLoading, description: projectsLoadDescription }); 1104} 1105 1106/** Hide the Accounting Dashboard pane. */ 1107function hideAccountingDashboard() { 1108 if (!inDashboard) return; 1109 document.getElementById('accounting-dashboard-view')?.classList.add('hidden'); 1110 inDashboard = false;
1111} 1112 1113/** 1114 * Show the Accounting Settings view in the detail column (company-wide accounting 1115 * defaults â mirrors showCaSettings/showHrSettings). Deselects any project. Below 1024px it 1116 * takes the detail column; the view's own `lg:hidden` back button is the exit (MCF-0635/0637). 1117 * Idempotent: if already showing, just switches the inner tab. 1118 * @param {string|null} tab â settings sub-tab to activate. 1119 */ 1120async function showAccountingSettings(tab = null) { 1121 if (inSettings) { 1122 showModuleTabs('settings'); // keep the strip's active tab honest on deep links 1123 await showAccountingSettingsTab(tab); 1124 return; 1125 } 1126 1127 hideAccountingDashboard(); 1128 hideAccountingAllInvoices(); 1129 teardownActiveSubModule(); 1130 endSelectionScope(); 1131 currentProjectId = null; 1132 document.getElementById('accounting-detail-empty')?.classList.add('hidden'); 1133 document.getElementById('accounting-detail-content')?.classList.add('hidden'); 1134 document.getElementById('accounting-mobile-back-header')?.classList.add('hidden'); 1135 document.querySelectorAll('.accounting-project-card').forEach(card => card.classList.remove('list-row-selected')); 1136 if (listPanel.isCollapsed()) listPanel.updateMiniSelection(); 1137 1138 document.getElementById('accounting-settings-view')?.classList.remove('hidden'); 1139 inSettings = true; 1140 showModuleTabs('settings'); 1141 1142 // The shell ships the detail column `hidden lg:flex`, so below 1024px un-hiding only the inner 1143 // view paints into a display:none box and the phone keeps showing the list (MCF-0635). 1144 if (isMobileOrTablet()) { 1145 document.getElementById('accounting-list-column')?.classList.add('hidden'); 1146 const detailColumn = document.getElementById('accounting-detail-column'); 1147 if (detailColumn) { detailColumn.classList.remove('hidden', 'lg:flex'); detailColumn.classList.add('flex', 'w-full'); } 1148 } 1149 1150 // urlSync: a user-driven tab switch updates the URL (skipped while the router drives us). 1151 await initAccountingSettings(tab, (t) => { 1152 if (!_navigating) page(`/accounting/settings/${t}`); 1153 }); 1154} 1155 1156/** Hide the Accounting Settings view and tear down its active tab module. */ 1157function hideAccountingSettings() { 1158 if (!inSettings) return; 1159 destroyAccountingSettings(); 1160 document.getElementById('accounting-settings-view')?.classList.add('hidden'); 1161 inSettings = false; 1162} 1163 1164/** 1165 * Show the All Invoices view â the CROSS-PROJECT invoice list (R7). A module view, mutually 1166 * exclusive with the dashboard / settings / a selected project. Mirrors showAccountingSettings: 1167 * below 1024px it takes the detail column, its own `lg:hidden` back button is the exit 1168 * (MCF-0635), idempotent. 1169 */ 1170async function showAccountingAllInvoices() { 1171 if (inAllInvoices) { showModuleTabs('all-invoices'); return; } 1172 1173 hideAccountingDashboard(); 1174 hideAccountingSettings(); 1175 teardownActiveSubModule(); 1176 endSelectionScope(); 1177 currentProjectId = null; 1178 document.getElementById('accounting-detail-empty')?.classList.add('hidden'); 1179 document.getElementById('accounting-detail-content')?.classList.add('hidden'); 1180 document.getElementById('accounting-mobile-back-header')?.classList.add('hidden'); 1181 document.querySelectorAll('.accounting-project-card').forEach(card => card.classList.remove('list-row-selected')); 1182 if (listPanel.isCollapsed()) listPanel.updateMiniSelection(); 1183 1184 document.getElementById('accounting-all-invoices-view')?.classList.remove('hidden'); 1185 inAllInvoices = true; 1186 showModuleTabs('all-invoices'); 1187 1188 // Same swap as showAccountingSettings â without it a sub-1024px cold load paints into the 1189 // shell's `hidden lg:flex` detail column (MCF-0635). 1190 if (isMobileOrTablet()) { 1191 document.getElementById('accounting-list-column')?.classList.add('hidden'); 1192 const detailColumn = document.getElementById('accounting-detail-column'); 1193 if (detailColumn) { detailColumn.classList.remove('hidden', 'lg:flex'); detailColumn.classList.add('flex', 'w-full'); } 1194 } 1195 1196 // Row click-through: land on the invoice's own project detail, deep-linked to the record â 1197 // the module already owns that route (/accounting/:id/invoices/:invoiceId), so All Invoices 1198 // is a finder, not a second detail surface. Reuse, don't re-implement. 1199 await renderAccountingAllInvoices({ 1200 signal: abortController?.signal, 1201 onOpen: (projectId, invoiceId) => page(`/accounting/${projectId}/invoices/${invoiceId}`), 1202 }); 1203} 1204 1205/** Hide the All Invoices view and release its DataView + listeners. */ 1206function hideAccountingAllInvoices() { 1207 if (!inAllInvoices) return; 1208 destroyAccountingAllInvoices(); 1209 document.getElementById('accounting-all-invoices-view')?.classList.add('hidden'); 1210 inAllInvoices = false;
1211} 1212 1213// ======================================== 1214// URL HELPERS 1215// ======================================== 1216 1217function buildUrl() { 1218 let url = '/accounting'; 1219 if (currentProjectId) { 1220 url += `/${currentProjectId}`; 1221 if (currentSubModule && currentSubModule !== 'overview') { 1222 // Deep-link to an open record within a sub-module (mirrors 1223 // contract-admin's per-sub-module record-id append). A hook may carry 1224 // a detailSegment that replaces the list's segment while a record is 1225 // open (cost-analysis list â cost-codes/:costCodeId detail). 1226 const hook = deepLinkHookFor(currentSubModule); 1227 const recId = hook?.getCurrentId(); 1228 if (hook && recId) { 1229 url += `/${hook.detailSegment || currentSubModule}/${recId}`; 1230 // Optional inner-tab segment (/â¦/:recordId/:tab). Default tab 1231 // returns null â no segment, keeping /â¦/:recordId clean. 1232 const tab = hook.getCurrentTab?.(); 1233 if (tab) url += `/${tab}`; 1234 } else { 1235 url += `/${currentSubModule}`; 1236 } 1237 } 1238 } 1239 // Preserve the active time period across tab switches when the active tab 1240 // is one of the period-scoped sub-modules. Period selector owns the state; 1241 // we just round-trip it through the URL so refresh + back/forward work. 1242 const periodId = periodContext?.getActivePeriodId() ?? null; 1243 if (periodId && PERIOD_SCOPED_SUB_MODULES.has(currentSubModule)) { 1244 url += `?period=${periodId}`; 1245 } 1246 return url; 1247} 1248 1249function isKnownSubModule(id) { 1250 return visibleSubModules().some(m => m.id === id); 1251}
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.