PageSourceSearch

https://smrtbld.ai/js/pages/accounting.js

js smrtbld.ai collected 2026-10-05 15:08:30 UTC 70,656 bytes, 1,251 lines download raw bytes

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.