PageSourceSearch

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

js smrtbld.ai collected 2026-10-05 15:07:24 UTC 49,185 bytes, 1,236 lines download raw bytes

1import { getInitials } from '../utils/jsrender-helpers.js';
2import { projectsApi } from '../api/projects.js';
3import { offlineApi } from '../offline/offline-api.js';
4import { rfpApi } from '../api/rfp.js';
5import { getSubModuleIcon, renderSubModuleTabs } from '../utils/sub-module-icons.js';
6import { renderTo, loadTemplates } from '../utils/templates.js';
7import { confirm } from '../utils/modal.js';
8import { auth } from '../auth.js';
9import { renderDemoDashboard, destroyDemoDashboard } from '../utils/demo-dashboard.js';
10import { loadReadinessRings, clearReadinessRings } from '../utils/dashboard-charts.js';
11import { initRfpDetails, destroyRfpDetails, getCurrentRfpTab } from './rfp-details.js';
12import { initOverviewDetails, destroyOverviewDetails, getCurrentOverviewTab } from './overview-details.js';
13import { initProjectTeam, destroyProjectTeam, TEAM_PAGE_LINKS } from './project-team.js';
14import { initProjectDocuments, destroyProjectDocuments, getCurrentDocumentFolderId, syncProjectDocumentsRoute, DOCUMENTS_PAGE_LINKS } from './project-documents.js';
15import { initFeeEstimation, destroyFeeEstimation, FEE_PAGE_LINKS } from './fee-estimation.js';
16import { initProjectMeetings, destroyProjectMeetings, MEETINGS_PAGE_LINKS, getCurrentMeetingId } from './project-meetings.js';
17import { initProposalConsultants, destroyProposalConsultants, CONSULTANT_PAGE_LINKS, getCurrentConsultantTaskId } from './proposal-consultants.js';
18import { initProposalTasks, destroyProposalTasks, TASK_PAGE_LINKS } from './proposal-tasks.js';
19import { initProposalAgreement, destroyProposalAgreement, AGREEMENT_PAGE_LINKS } from './proposal-agreement.js';
20import { initProjectActivityLog, destroyProjectActivityLog, ACTIVITY_LOG_PAGE_LINKS } from './project-activity-log.js';
21import { destroyTabNav } from '../utils/tab-nav-mode.js';
22import { DropdownNavigator } from '../components/DropdownNavigator.js';
23import { isMobile, isMobileOrTablet } from '../utils/mobile.js';
24import { DivisionFilter } from '../components/DivisionFilter.js';
25import { companySettings } from '../services/company-settings.js';
26import { initProjectDivisionsEditor } from '../utils/project-divisions-editor.js';
27
28let abortController = null;
29let divisionFilter = null;
30let currentProposalId = null;
31let currentProject = null;
32let proposals = [];
33let filteredProposals = [];
34let filterPopoverEl = null;
35let selectionGeneration = 0;
36let switchGeneration = 0;
37
38// Sub-module / collapse state
39let activeSubModule = 'overview';
40let listCollapsed = false;
41let _navigating = false;
42
43// Dynamic sub-modules from API (cached across page navigations)
44const SECTION_CODE = 'PROP';
45const OVERVIEW_TAB = { id: 'overview', label: 'Overview', icon: 'layout-dashboard' };
46const ACTIVITY_LOG_TAB = { id: 'activity-log', label: 'Activity Log', icon: 'activity' };
47let subModules = [];
48let subModulesLoaded = false;
49let proposalTemplatesLoaded = false;
50
51async function loadSubModules() {
52    if (subModulesLoaded) return;
53    try {
54        const response = await projectsApi.getPageAccess(SECTION_CODE, { signal: abortController?.signal });
55        const apiTabs = (response.pages || []).map(p => ({
56            id: p.pageLink,
57            label: p.pageTitle,
58            icon: getSubModuleIcon(p.pageLink)
59        }));
60        subModules = [OVERVIEW_TAB, ...apiTabs, ACTIVITY_LOG_TAB];
61        subModulesLoaded = true;
62    } catch (error) {
63        if (error.name === 'AbortError') return;
64        subModules = [OVERVIEW_TAB, ACTIVITY_LOG_TAB];
65    }
66}
67
68/**
69 * Session-boundary reset (CA-051 / MCF-0080). Called from teardownAuthenticatedServices
70 * (router.js) so the next in-tab session derives its tab set from its own getPageAccess.
71 * Never called on ordinary navigation — the per-session cache above stays intact.
72 */
73export function resetSubModulesCache() {
74    subModules = [];
75    subModulesLoaded = false;
76}
77
78// Filter state — defaults: active only, all membership
79let filterState = {
80    status: ['active'],
81    membership: ['mine', 'all']
82};
83
84/**
85 * Initialize Proposals Page
86 * @param {number|null} selectedProposalId - Optional proposal ID to auto-select (from URL)
87 * @param {string|null} subModule - Optional sub-module to activate (e.g. 'rfp', 'schedule')
88 * @param {string|null} subTab - Optional sub-tab within the sub-module (e.g. 'client-info')
89 */
90export async function initProposalsPage(selectedProposalId = null, subModule = null, subTab = null) {
91    abortController = new AbortController();
92    const pageSignal = abortController.signal;
93    initProjectDivisionsEditor({ rootId: 'detail-header', getProject: () =>
93 currentProject, onChanged: refreshProposalDivisions, signal: abortController.signal });
94
95    if (!proposalTemplatesLoaded) {
96        await loadTemplates('components/proposal-sub-templates');
97        proposalTemplatesLoaded = true;
98    }
99
100    // Reset state
101    currentProposalId = null;
102    currentProject = null;
103    proposals = [];
104    filteredProposals = [];
105    filterState = { status: ['active'], membership: ['mine', 'all'] };
106    filterPopoverEl = null;
107    activeSubModule = 'overview';
108    listCollapsed = false;
109
110    // Store deep-link params for use after project loads
111    const pendingSubModule = subModule;
112    const pendingSubTab = subTab;
113
114    // Initialize Event Handlers
115    initListHandlers();
116    initMobileBackHandler();
117    initDropdownNavigator();
118    initToggleHandler();
119    initSubModuleTabHandlers();
120
121    // Division filter
122    divisionFilter = await DivisionFilter.create({ onChange: () => loadProposals() });
123    if (divisionFilter) {
124        const searchRow = document.getElementById('proposal-search')?.closest('.shrink-0');
125        searchRow?.after(divisionFilter.element);
126    }
127
128    // Load dynamic sub-modules, then proposal list
129    await loadSubModules();
130    await loadProposals();
131    if (pageSignal.aborted || abortController?.signal !== pageSignal) return;
132
133    // Auto-select proposal from URL param (suppress pushState — URL already correct from route)
134    _navigating = true;
135    if (selectedProposalId) {
136        const target = pendingSubModule || 'overview';
137        if (!await selectProposal(selectedProposalId, { targetSubModule: target })) {
138            _navigating = false;
139            return;
140        }
141
142        // Navigate to requested sub-module after project loads
143        if (pendingSubModule && pendingSubModule !== activeSubModule) {
144            await settleProposalAction(switchSubModule(pendingSubModule, pendingSubTab));
145        } else if (pendingSubModule === 'overview' && pendingSubTab) {
146            // Overview sub-tab deep link: /proposals/123/overview/risk-assessment
147            await settleProposalAction(switchSubModule('overview', pendingSubTab));
148        }
149    }
150    _navigating = false;
151}
152
153/**
154 * Clean up Proposals Page
155 */
156export function destroyProposalsPage() {
157    selectionGeneration++;
158    clearProposalSelectionTransition();
159    destroyTabNav(document.getElementById('sub-module-tabs'));
160    destroyOverviewDetails();
161    destroyRfpDetails();
162    destroyProjectTeam();
163    destroyProjectDocuments();
164    destroyFeeEstimation();
165    destroyProjectMeetings();
166    destroyProposalConsultants();
167    destroyProposalTasks();
168    destroyProposalAgreement();
169    destroyProjectActivityLog();
170    destroyDemoDashboard('proposal');
171    clearReadinessRings('rfp');
172    DropdownNavigator.destroy(DROPDOWN_NAV_ID);
173    divisionFilter?.destroy();
174    divisionFilter = null;
175    abortController?.abort();
176    abortController = null;
177    currentProposalId = null;
178    proposals = [];
179    filteredProposals = [];
180    filterPopoverEl = null;
181    filterState = { status: ['active'], membership: ['mine', 'all'] };
182    activeSubModule = 'overview';
183    listCollapsed = false;
184    _navigating = false;
185}
186
187/**
188 * Lightweight in-page navigation (called by route guard on same-page back/forward).
189 * Reuses existing DOM, event handlers, and cached data — no full re-render.
190 * Sets _navigating flag to prevent URL pushes (URL is already correct from popstate).
191 */
192export async function navigateProposalsPage(id = null, subModule = null, subTab = null) {
193    _navigating = true;
194    try {
195        if (id && id !== currentProposalId) {
196            const target = subModule || 'overview';
197            if (!await selectProposal(id, { targetSubModule: target })) return;
198            if (target !== activeSubModule) await settleProposalAction(switchSubModule(target));
199            if (DOCUMENTS_PAGE_LINKS.includes(target)) await syncProjectDocumentsRoute(subTab);
200        } else if (id && id === currentProposalId) {
201            const target = subModule || 'overview';
202            if (target !== activeSubModule) await settleProposalAction(switchSubModule(target, subTab));
203            else if (DOCUMENTS_PAGE_LINKS.includes(target)) await syncProjectDocumentsRoute(subTab);
204        } else if (!id && currentProposalId) {
205            showMobileList();
206        }
207    } finally {
208        _navigating = false;
209    }
210}
211
212// ========================================
213// LIST LOGIC
214// ========================================
215
216async function loadProposals() {
217    const listContainer = document.getElementById('proposals-list');
218    if (!listContainer) return;
219
220    // Show skeleton loading state
221    listContainer.innerHTML = $.templates('#tpl-skeleton-list-compact').render({});
222
223    try {
224        const response = await projectsApi.getProposals(divisionFilter?.getValue());
225        proposals = await offlineApi.filterProjectsToOffline(response.projects || []);
226
227        // Enrich list data with computed fields
228        proposals.forEach(enrichProposalData);
229
230        applyFilters();
231        renderProposalList();
232
233    } catch (error) {
234        if (error.name === 'AbortError') return;
235
236        window.showToast?.('Failed to load proposals', 'error', { description: error.message });
237    }
238}
239
240async function renderProposalList() {
241    const listContainer = document.getElementById('proposals-list');
242    const countEl = document.getElementById('proposal-count');
243    if (!listContainer) return;
244
245    if (filteredProposals.length === 0) {
246        await renderTo('#proposals-list', '#tpl-proposals-empty', {});
247    } else {
248        await renderTo('#proposals-list', '#tpl-proposals-row', filteredProposals, {
249            getInitials
250        });
251
252        // Handle selection state
253        if (currentProposalId) {
254            const row = listContainer.querySelector(`[data-id="${currentProposalId}"]`);
255            if (row) row.classList.add('border-st-primary', 'bg-st-primary/5');
256        }
257    }
258
259    if (countEl) countEl.textContent = `${filteredProposals.length} proposals`;
260
261    // Keep mini list in sync if collapsed
262    if (listCollapsed) {
263        renderMiniProposalList();
264    }
265
266    // Fetch readiness for each project in parallel and update rings
267    loadReadinessRings(listContainer, filteredProposals, (id) => rfpApi.getReadiness(id, { signal: abortController?.signal }), 'rfp');
268
269    // Sync mobile dropdown navigator
270    syncDropdownNavigator();
271}
272
273function initListHandlers() {
274    const listContainer = document.getElementById('proposals-list');
275    const searchInput = document.getElementById('proposal-search');
276    const filterBtn = document.getElementById('btn-filter');
277    const addBtn = document.getElementById('btn-create-proposal');
278
279    listContainer?.addEventListener('click', (e) => {
280        const row = e.target.closest('.proposal-row');
281        if (row) {
282            const id = parseInt(row.dataset.id);
283            settleProposalAction(selectProposal(id));
284        }
285        // Handle empty state create button
286        if (e.target.closest('[data-action="create-proposal"]')) {
287            handleCreateProposal();
288        }
289    }, { signal: abortController.signal });
290
291    searchInput?.addEventListener('input', (e) => {
292        applyFilters(e.target.value);
293        renderProposalList();
294    }, { signal: abortController.signal });
295
296    filterBtn?.addEventListener('click', () => toggleFilterPopover(), { signal: abortController.signal });
297
298    // Click-outside to close filter popover
299    document.addEventListener('click', (e) => {
300        if (filterPopoverEl && !filterPopoverEl.contains(e.target) && !e.target.closest('#btn-filter')) {
301            closeFilterPopover();
302        }
303    }, { signal: abortController.signal });
304
305    addBtn?.addEventListener('click', () => handleCreateProposal(), { signal: abortController.signal });
306}
307
308function applyFilters(searchTerm = '') {
309    const term = (searchTerm || '').toLowerCase();
310    filteredProposals = proposals.filter(p => {
311        // Text search
312        const matchesSearch = !term ||
313            (p.projectTitle && p.projectTitle.toLowerCase().includes(term)) ||
314            (p.projectNumber && p.projectNumber.toLowerCase().includes(term)) ||
315            (p.divisionName && p.divisionName.toLowerCase().includes(term)) ||
316            (p.divisionNames && p.divisionNames.toLowerCase().includes(term)) ||
317            (p.projectCity && p.projectCity.toLowerCase().includes(term));
318
319        // Status filter
320        const isActive = !p.isArchived;
321        const matchesStatus =
322            (isActive && filterState.status.includes('active')) ||
323            (!isActive && filterState.status.includes('archived'));
324
325        // Membership filter
326        const matchesMembership =
327            filterState.membership.includes('all') ||
328            (filterState.membership.includes('mine') && p.isMemberOfProject);
329
330        return matchesSearch && matchesStatus && matchesMembership;
331    });
332}
333
334// ========================================
335// FILTER POPOVER
336// ========================================
337
338function toggleFilterPopover() {
339    if (filterPopoverEl) {
340        closeFilterPopover();
341        return;
342    }
343
344    const filterBtn = document.getElementById('btn-filter');
345    if (!filterBtn) return;
346
347    // Create popover container
348    filterPopoverEl = document.createElement('div');
349    filterPopoverEl.className = 'absolute right-0 top-full mt-2 w-56 card-st p-4 shadow-sb-lg z-50';
350
351    // Render template with current filter state as helpers
352    const tpl = $.templates('#tpl-proposal-filter-popover');
353    filterPopoverEl.innerHTML = tpl.render({}, {
354        statusActive: filterState.status.includes('active'),
355        statusArchived: filterState.status.includes('archived'),
356        memberMine: filterState.membership.includes('mine'),
357        memberAll: filterState.membership.includes('all')
358    });
359
360    // Position relative to filter button
361    filterBtn.parentElement.appendChild(filterPopoverEl);
362
363    // Checkbox change handlers
364    filterPopoverEl.querySelectorAll('input[type="checkbox"]').forEach(cb => {
365        cb.addEventListener('change', () => {
366            const filterGroup = cb.dataset.filter;
367            const value = cb.value;
368
369            if (filterGroup === 'status') {
370                if (cb.checked) {
371                    if (!filterState.status.includes(value)) filterState.status.push(value);
372                } else {
373                    filterState.status = filterState.status.filter(v => v !== value);
374                }
375            } else if (filterGroup === 'membership') {
376                if (cb.checked) {
377                    if (!filterState.membership.includes(value)) filterState.membership.push(value);
378                } else {
379                    filterState.membership = filterState.membership.filter(v => v !== value);
380                }
381            }
382
383            const searchInput = document.getElementById('proposal-search');
384            applyFilters(searchInput?.value || '');
385            renderProposalList();
386            updateFilterIndicator();
387        });
388    });
389}
390
391function closeFilterPopover() {
392    if (filterPopoverEl) {
393        filterPopoverEl.remove();
394        filterPopoverEl = null;
395    }
396}
397
398function isFilterNonDefault() {
399    const defaultStatus = ['active'];
400    const defaultMembership = ['mine', 'all'];
401
402    const statusMatch = filterState.status.length === defaultStatus.length &&
403        defaultStatus.every(s => filterState.status.includes(s));
404    const membershipMatch = filterState.membership.length === defaultMembership.length &&
405        defaultMembership.every(m => filterState.membership.includes(m));
406
407    return !statusMatch || !membershipMatch;
408}
409
410function updateFilterIndicator() {
411    const indicator = document.getElementById('filter-indicator');
412    if (indicator) {
413        indicator.classList.toggle('hidden', !isFilterNonDefault());
414    }
415}
416
417// ========================================
418// URL MANAGEMENT
419// ========================================
420
421function buildUrl() {
422    const base = '/proposals';
423    if (!currentProposalId) return base;
424    let url = `${base}/${currentProposalId}`;
425
426    // Overview sub-tabs: /proposals/123/overview/risk-assessment
427    if (activeSubModule === 'overview') {
428        const overviewTab = getCurrentOverviewTab();
429        if (overviewTab && overviewTab !== 'overview') {
430            url += `/overview/${overviewTab}`;
431        }
432        return url;
433    }
434
435    if (activeSubModule && activeSubModule !== 'overview') {
436        url += `/${activeSubModule}`;
437        if (activeSubModule === 'rfp') {
438            const rfpTab = getCurrentRfpTab();
439            if (rfpTab && rfpTab !== 'overview') {
440                url += `/${rfpTab}`;
441            }
442        }
443        if (CONSULTANT_PAGE_LINKS.includes(activeSubModule)) {
444            const taskId = getCurrentConsultantTaskId();
445            if (taskId) {
446                url += `/${taskId}`;
447            }
448        }
449        if (MEETINGS_PAGE_LINKS.includes(activeSubModule)) {
450            const meetingId = getCurrentMeetingId();
451            if (meetingId) {
452                url += `/${meetingId}`;
453            }
454        }
455        if (DOCUMENTS_PAGE_LINKS.includes(activeSubModule)) {
456            // Documents deep-links the open FOLDER (0 / root → no segment).
457            const folderId = getCurrentDocumentFolderId();
458            if (folderId) url += `/${folderId}`;
459        }
460    }
461    return url;
462}
463
464function updateUrl(push = false) {
465    const url = buildUrl();
466    if (push && !_navigating) {
467        page.show(url);
468    } else {
469        history.replaceState(null, '', url);
470    }
471}
472
473function handleOverviewTabChange() {
474    updateUrl();
475    const tab = getCurrentOverviewTab();
476    if (tab === 'overview') {
477        if (listCollapsed) expandList();
478    } else {
479        if (!listCollapsed) collapseList();
480    }
481}
482
483// ========================================
484// SUB-MODULE TABS
485// ========================================
486
487function initSubModuleTabHandlers() {
488    const tabContainer = document.getElementById('sub-module-tabs');
489    if (!tabContainer) return;
490
491    tabContainer.addEventListener('click', (e) => {
492        const btn = e.target.closest('[data-tab]');
493        if (!btn || btn.classList.contains('tab-nav-mode-toggle') || btn.classList.contains('tab-overflow-more') || btn.classList.contains('tab-overflow-item')) return;
494        settleProposalAction(switchSubModule(btn.dataset.tab));
495    }, { signal: abortController.signal });
496}
497
498async function switchSubModule(moduleId, rfpSubTab = null) {
499    let documentsInitPromise = null;
500    // Same tab clicked — destroy current detail view so it re-inits back to list
501    if (moduleId === activeSubModule && !rfpSubTab) {
502        if (CONSULTANT_PAGE_LINKS.includes(moduleId)) destroyProposalConsultants();
503        else if (MEETINGS_PAGE_LINKS.includes(moduleId)) destroyProjectMeetings();
504        else if (TASK_PAGE_LINKS.includes(moduleId)) destroyProposalTasks();
505        else return; // overview, rfp, team, docs, fee, agreement — no detail view to reset
506    }
507
508    const generation = ++switchGeneration;
509    const recordGeneration = selectionGeneration;
510    const selectedId = currentProposalId;
511    const pageSignal = abortController?.signal || null;
512    const ownsSwitch = () => generation === switchGeneration && recordGeneration === selectionGeneration && selectedId === currentProposalId
513        && abortController?.signal === pageSignal && !pageSignal?.aborted;
514
515    // Clean up previous sub-module state
516    if (activeSubModule === 'overview' && moduleId !== 'overview') {
517        destroyOverviewDetails();
518    }
519    if (activeSubModule === 'rfp' && moduleId !== 'rfp') {
520        destroyRfpDetails();
521    }
522    if (TEAM_PAGE_LINKS.includes(activeSubModule) && !TEAM_PAGE_LINKS.includes(moduleId)) {
523        destroyProjectTeam();
524    }
525    if (DOCUMENTS_PAGE_LINKS.includes(activeSubModule) && !DOCUMENTS_PAGE_LINKS.includes(moduleId)) {
526        destroyProjectDocuments();
527    }
528    if (FEE_PAGE_LINKS.includes(activeSubModule) && !FEE_PAGE_LINKS.includes(moduleId)) {
529        destroyFeeEstimation();
530    }
531    if (MEETINGS_PAGE_LINKS.includes(activeSubModule) && !MEETINGS_PAGE_LINKS.includes(moduleId)) {
532        destroyProjectMeetings();
533    }
534    if (CONSULTANT_PAGE_LINKS.includes(activeSubModule) && !CONSULTANT_PAGE_LINKS.includes(moduleId)) {
535        destroyProposalConsultants();
536    }
537    if (TASK_PAGE_LINKS.includes(activeSubModule) && !TASK_PAGE_LINKS.includes(moduleId)) {
538        destroyProposalTasks();
539    }
540    if (AGREEMENT_PAGE_LINKS.includes(activeSubModule) && !AGREEMENT_PAGE_LINKS.includes(moduleId)) {
541        destroyProposalAgreement();
542    }
543    if (ACTIVITY_LOG_PAGE_LINKS.includes(activeSubModule) && !ACTIVITY_LOG_PAGE_LINKS.includes(moduleId)) {
544        await destroyProjectActivityLog();
545        if (!ownsSwitch()) return false;
546    }
547
548    activeSubModule = moduleId;
549
550    // Toggle header compact/expanded based on active tab
551    const headerEl = document.getElementById('detail-header');
552    if (headerEl) {
553        headerEl.classList.toggle('header-compact', moduleId !== 'overview');
554    }
555
556    // Update tab active state
557    const tabContainer = document.getElementById('sub-module-tabs');
558    tabContainer?.querySelectorAll('.proposals-tab-btn').forEach(btn => {
559        const on = btn.dataset.tab === moduleId;
560        btn.classList.toggle('active', on);
561        btn.setAttribute('aria-selected', String(on));
562    });
563
564    // Show/hide sub-tab navs based on active main tab
565    document.querySelectorAll('#sub-tab-container > nav[data-parent-tab]').forEach(nav => {
566        nav.classList.toggle('hidden', nav.dataset.parentTab !== moduleId);
567    });
568
569    // Swap tab panels
570    const overviewPanel = document.getElementById('tab-overview');
571    const rfpPanel = document.getElementById('tab-rfp');
572    const teamPanel = document.getElementById('tab-projteam');
573    const documentsPanel = document.getElementById('tab-projdocuments');
574    const feePanel = document.getElementById('tab-fee');
575    const meetingsPanel = document.getElementById('tab-meetings');
576    const consultantPanel = document.getElementById('tab-consultant');
577    const taskListPanel = document.getElementById('tab-caltsk');
578    const agreementPanel = document.getElementById('tab-proposal');
579    const activityLogPanel = document.getElementById('tab-activity-log');
580    const comingSoonPanel = document.getElementById('tab-coming-soon');
581
582    // Hide all panels
583    overviewPanel?.classList.add('hidden');
584    rfpPanel?.classList.add('hidden');
585    teamPanel?.classList.add('hidden');
586    documentsPanel?.classList.add('hidden');
587    feePanel?.classList.add('hidden');
588    meetingsPanel?.classList.add('hidden');
589    consultantPanel?.classList.add('hidden');
590    taskListPanel?.classList.add('hidden');
591    agreementPanel?.classList.add('hidden');
592    activityLogPanel?.classList.add('hidden');
593    comingSoonPanel?.classList.add('hidden');
594
595    if (moduleId === 'overview') {
596        overviewPanel?.classList.remove('hidden');
597        if (currentProposalId) {
598            initOverviewDetails(currentProposalId, abortController?.signal, {
599                initialTab: rfpSubTab,
600                onTabChange: handleOverviewTabChange
601            });
602        }
603
604        // Collapse/expand based on overview sub-tab
605        if (rfpSubTab && rfpSubTab !== 'overview') {
606            if (!listCollapsed) collapseList();
607        } else {
608            if (listCollapsed) expandList();
609        }
610    } else if (moduleId === 'rfp') {
611        rfpPanel?.classList.remove('hidden');
612        if (currentProposalId) {
613            initRfpDetails(currentProposalId, abortController?.signal, {
614                initialTab: rfpSubTab,
615                onTabChange: () => updateUrl()
616            });
617        }
618
619        // Auto-collapse list when entering a sub-module
620        if (!listCollapsed) collapseList();
621    } else if (TEAM_PAGE_LINKS.includes(moduleId)) {
622        teamPanel?.classList.remove('hidden');
623        if (currentProposalId) {
624            initProjectTeam(currentProposalId, abortController?.signal);
625        }
626
627        if (!listCollapsed) collapseList();
628    } else if (DOCUMENTS_PAGE_LINKS.includes(moduleId)) {
629        documentsPanel?.classList.remove('hidden');
630        if (currentProposalId) {
631            documentsInitPromise = initProjectDocuments(currentProposalId, abortController?.signal, {
632                folderId: rfpSubTab ? parseInt(rfpSubTab, 10) || 0 : null,
633                onNavigate: ({ push = false } = {}) => updateUrl(push),
634            });
635        }
636
637        if (!listCollapsed) collapseList();
638    } else if (FEE_PAGE_LINKS.includes(moduleId)) {
639        feePanel?.classList.remove('hidden');
640        if (currentProposalId) {
641            initFeeEstimation(currentProposalId, abortController?.signal);
642        }
643
644        if (!listCollapsed) collapseList();
645    } else if (MEETINGS_PAGE_LINKS.includes(moduleId)) {
646        meetingsPanel?.classList.remove('hidden');
647        if (currentProposalId) {
648            initProjectMeetings(currentProposalId, abortController?.signal, {
649                initialMeetingId: rfpSubTab || null,
650                onNavigate: () => updateUrl()
651            });
652        }
653
654        if (!listCollapsed) collapseList();
655    } else if (CONSULTANT_PAGE_LINKS.includes(moduleId)) {
656        consultantPanel?.classList.remove('hidden');
657        if (currentProposalId) {
658            initProposalConsultants(currentProposalId, abortController?.signal, {
659                initialTaskId: rfpSubTab || null,
660                onNavigate: () => updateUrl()
661            });
662        }
663
664        if (!listCollapsed) collapseList();
665    } else if (TASK_PAGE_LINKS.includes(moduleId)) {
666        taskListPanel?.classList.remove('hidden');
667        if (currentProposalId) {
668            initProposalTasks(currentProposalId, abortController?.signal);
669        }
670
671        if (!listCollapsed) collapseList();
672    } else if (AGREEMENT_PAGE_LINKS.includes(moduleId)) {
673        agreementPanel?.classList.remove('hidden');
674        if (currentProposalId) {
675            initProposalAgreement(currentProposalId, abortController?.signal);
676        }
677
678        if (!listCollapsed) collapseList();
679    } else if (ACTIVITY_LOG_PAGE_LINKS.includes(moduleId)) {
680        activityLogPanel?.classList.remove('hidden');
681        if (!listCollapsed) collapseList();
682        if (currentProposalId) {
683            await initProjectActivityLog(currentProposalId, abortController?.signal, {
684                containerId: 'proposals-activity-log-container',
685            });
686            if (!ownsSwitch()) return false;
687        }
688    } else {
689        comingSoonPanel?.classList.remove('hidden');
690
691        // Update coming-soon content with module info
692        const mod = subModules.find(m => m.id === moduleId);
693        if (mod) {
694            const iconEl = document.getElementById('coming-soon-icon');
695            const labelEl = document.getElementById('coming-soon-label');
696            if (iconEl) {
697                iconEl.setAttribute('data-lucide', mod.icon);
698                if (window.lucide) window.lucide.createIcons({ root: iconEl.parentElement });
699            }
700            if (labelEl) {
701                labelEl.textContent = `${mod.label} is being ported to smrtBuild. Stay tuned!`;
702            }
703        }
704
705        // Auto-collapse list when entering a sub-module
706        if (!listCollapsed) collapseList();
707    }
708
709    updateUrl(true);
710    return documentsInitPromise;
711}
712
713// ========================================
714// COLLAPSE / EXPAND
715// ========================================
716
717function initToggleHandler() {
718    const toggleBtn = document.getElementById('btn-toggle-list');
719    if (!toggleBtn) return;
720
721    toggleBtn.addEventListener('click', () => {
722        if (listCollapsed) {
723            expandList();
724        } else {
725            collapseList();
726        }
727    }, { signal: abortController.signal });
728
729    // Collapsed sidebar buttons
730    const listColumn = document.getElementById('proposal-list-column');
731    if (listColumn) {
732        listColumn.querySelector('.collapsed-expand-btn')?.addEventListener('click', () => expandList(), { signal: abortController.signal });
733        listColumn.querySelector('.collapsed-add-btn')?.addEventListener('click', () => {
734            document.getElementById('btn-create-proposal')?.click();
735        }, { signal: abortController.signal });
736    }
737}
738
739function collapseList() {
740    if (isMobileOrTablet()) return;
741
742    const listColumn = document.getElementById('proposal-list-column');
743    const toggleBtn = document.getElementById('btn-toggle-list');
744    if (!listColumn) return;
745
746    listCollapsed = true;
747    listColumn.classList.add('collapsed');
748
749    if (toggleBtn) {
750        toggleBtn.classList.remove('expanded');
751        toggleBtn.title = 'Expand proposal list';
752    }
753
754    renderMiniProposalList();
755}
756
757function expandList() {
758    const listColumn = document.getElementById('proposal-list-column');
759    const toggleBtn = document.getElementById('btn-toggle-list');
760    if (!listColumn) return;
761
762    listCollapsed = false;
763    listColumn.classList.remove('collapsed');
764
765    if (toggleBtn) {
766        toggleBtn.classList.add('expanded');
767        toggleBtn.title = 'Collapse proposal list';
768    }
769}
770
771async function renderMiniProposalList() {
772    const miniContainer = document.getElementById('proposals-list-mini');
773    if (!miniContainer) return;
774
775    if (filteredProposals.length === 0) {
776        miniContainer.innerHTML = '';
777        return;
778    }
779
780    await renderTo(miniContainer, '#tpl-proposals-row-mini', filteredProposals);
781
782    // Highlight selected proposal
783    if (currentProposalId) {
784        miniContainer.querySelectorAll('.proposal-row-mini').forEach(btn => {
785            const isSelected = parseInt(btn.dataset.id) === currentProposalId;
786            btn.classList.toggle('bg-st-primary/10', isSelected);
787            btn.classList.toggle('ring-2', isSelected);
788            btn.classList.toggle('ring-st-primary', isSelected);
789            btn.querySelectorAll('span').forEach(s => s.classList.toggle('!text-st-primary', isSelected));
790        });
791    }
792
793    // Click handler for mini list items (delegated)
794    miniContainer.onclick = (e) => {
795        const row = e.target.closest('.proposal-row-mini');
796        if (row) {
797            const id = parseInt(row.dataset.id);
798            settleProposalAction(selectProposal(id));
799        }
800    };
801}
802
803// ========================================
804// DETAIL LOGIC
805// ========================================
806
807async function settleProposalAction(promise) {
808    try {
809        return await promise;
810    } catch (error) {
811        if (error?.name !== 'AbortError') console.error('[Proposals] Action failed:', error);
812        return false;
813    }
814}
815
816function clearProposalSelectionTransition() {
817    const statusContainer = document.getElementById('detail-transition-status');
818    statusContainer?.replaceChildren();
819    statusContainer?.classList.add('hidden');
820}
821
822async function beginProposalSelectionTransition(ownsSelection) {
823    const visiblePanelIds = Array.from(document.querySelectorAll('#detail-content .tab-panel:not(.hidden)'))
824        .map(panel => panel.id).filter(Boolean);
825    currentProject = null;
826    showDetailPane();
827    const detailContent = document.getElementById('detail-content');
828    document.querySelectorAll('#detail-content .tab-panel').forEach(panel => panel.classList.add('hidden'));
829    document.querySelectorAll('#sub-tab-container > nav[data-parent-tab]').forEach(nav => nav.classList.add('hidden'));
830    document.getElementById('sub-module-tabs')?.classList.add('hidden');
831    const headerContainer = document.getElementById('detail-header');
832    headerContainer?.replaceChildren();
833    headerContainer?.classList.remove('header-compact');
834    clearProposalSelectionTransition();
835    const statusContainer = document.getElementById('detail-transition-status');
836    const statusStaging = document.createElement('div');
837    await renderTo(statusStaging, '#tpl-state-loading', { message: 'Loading selected project…' });
838    if (!ownsSelection() || !statusContainer?.isConnected) return null;
839    statusContainer.replaceChildren(...statusStaging.childNodes);
840    statusContainer.classList.remove('hidden');
841    return { visiblePanelIds, statusContainer };
842}
843
844async function selectProposal(id, { targetSubModule = activeSubModule } = {}) {
845    if (currentProposalId === id) return true;
846    const generation = ++selectionGeneration;
847    const pageSignal = abortController?.signal || null;
848    const ownsSelection = () => generation === selectionGeneration
849        && currentProposalId === id
850        && abortController?.signal === pageSignal
851        && !pageSignal?.aborted;
852
853    // Update visual selection in full list
854    const listContainer = document.getElementById('proposals-list');
855    listContainer?.querySelectorAll('.proposal-row').forEach(r => {
856        r.classList.remove('border-st-primary', 'bg-st-primary/5');
857    });
858
859    const selectedRow = listContainer?.querySelector(`[data-id="${id}"]`);
860    if (selectedRow) selectedRow.classList.add('border-st-primary', 'bg-st-primary/5');
861
862    currentProposalId = id;
863    const transitionPromise = beginProposalSelectionTransition(ownsSelection);
864
865    // Update mini list selection
866    updateMiniListSelection();
867
868    // Sync mobile dropdown navigator
869    DropdownNavigator.setSelected(DROPDOWN_NAV_ID, id);
870
871    // Push history entry so browser Back closes the detail pane
872    updateUrl(true);
873
874    try {
875        const transition = await transitionPromise;
876        if (!transition || !ownsSelection()) return false;
877        const activityWasActive = ACTIVITY_LOG_PAGE_LINKS.includes(activeSubModule);
878        if (activityWasActive && ACTIVITY_LOG_PAGE_LINKS.includes(targetSubModule)) {
879            await initProjectActivityLog(id, pageSignal, { containerId: 'proposals-activity-log-container' });
880        } else if (activityWasActive) {
881            await destroyProjectActivityLog();
882        }
883        if (!ownsSelection()) return false;
884
885        const listProposal = proposals.find(p => p.projectId === id);
886        if (listProposal && !await renderProposalDetail(listProposal, { generation, id, pageSignal, targetSubModule, transition })) return;
887        if (!ownsSelection()) return false;
888        showDetailPane();
889
890        // Fetch full detail from API (task counts, milestones, etc.)
891        const fullProposal = await projectsApi.getProject(id, { signal: pageSignal });
892        if (!ownsSelection()) return false;
893
894        // Merge list data into detail — list query has designer fields that
895        // the detail query may not have yet (avoids second render losing dates)
896        if (listProposal) {
897            for (const key of Object.keys(listProposal)) {
898                if (fullProposal[key] === undefined || fullProposal[key] === null) {
899                    fullProposal[key] = listProposal[key];
900                }
901            }
902        }
903
904        enrichProposalData(fullProposal);
905
906        return await renderProposalDetail(fullProposal, { generation, id, pageSignal, targetSubModule, transition });
907    } catch (error) {
908        if (error.name === 'AbortError' || !ownsSelection()) return false;
909        window.showToast?.('Failed to load project', 'error', { description: error.message });
910        hideDetailPane();
911        updateUrl();
912        return false;
913    }
914}
915
916function updateMiniListSelection() {
917    const miniContainer = document.getElementById('proposals-list-mini');
918    if (!miniContainer) return;
919
920    miniContainer.querySelectorAll('.proposal-row-mini').forEach(btn => {
921        const isSelected = parseInt(btn.dataset.id) === currentProposalId;
922        btn.classList.toggle('bg-st-primary/10', isSelected);
923        btn.classList.toggle('ring-2', isSelected);
924        btn.classList.toggle('ring-st-primary', isSelected);
925    });
926}
927
928/**
929 * Enrich proposal data with computed fields
930 */
931function enrichProposalData(proposal) {
932    if (!proposal) return;
933
934    // Normalize field names — list query uses shorter names than detail query
935    if (proposal.anticipatedConStart && !proposal.anticipatedConstructionStart) {
936        proposal.anticipatedConstructionStart = proposal.anticipatedConStart;
937    }
938    if (proposal.conCompletion && !proposal.constructionCompletion) {
939        proposal.constructionCompletion = proposal.conCompletion;
940    }
941
942    if (proposal.projectEmail) {
943        const company = auth.getCompany();
944        proposal.projectEmailWithDomain = company?.subdomain
945            ? `${proposal.projectEmail}@${company.subdomain}`
946            : proposal.projectEmail;
947    }
948
949    // Build milestone cards for the rich detail header
950    // milestones from BECC_PM_Projects_Dates may have rows with null projectDate,
951    // so only use milestones that actually have dates set
952    const datedMilestones = (proposal.milestones || []).filter(m => m.projectDate);
953    if (datedMilestones.length > 0) {
954        proposal.milestoneCards = datedMilestones.slice(0, 4).map(m => ({
955            label: m.typeText,
956            date: m.projectDate
957        }));
958    } else {
959        // Fallback: flat designer date fields
960        proposal.milestoneCards = [
961            { label: 'Submission Due', date: proposal.submissionDueDate },
962            { label: 'Expected Award', date: proposal.anticipatedAwardDate },
963            { label: 'Construction Start', date: proposal.anticipatedConstructionStart },
964            { label: 'Completion', date: proposal.constructionCompletion }
965        ].filter(m => m.date);
966    }
967}
968
969/** Re-fetch divisions after a header manage-modal save; re-render header subtitle. */
970async function refreshProposalDivisions() {
971    if (!currentProject?.projectId) return;
972    const fresh = await projectsApi.getProject(currentProject.projectId);
973    Object.assign(currentProject, {
974        divisions: fresh.divisions, assignedDivisionIds: fresh.assignedDivisionIds,
975        divisionNames: fresh.divisionNames, divisionName: fresh.divisionName,
976        usesDivisions: companySettings.usesDivisions(),
977    });
978    const headerContainer = document.getElementById('detail-header');
979    if (headerContainer) {
980        await renderTo(headerContainer, '#tpl-proposals-header', currentProject, { canEdit: !!currentProject.canEdit });
981        document.getElementById('btn-edit-proposal')?.addEventListener('click', () => handleEditProposal(currentProject), { signal: abortController.signal });
982        document.getElementById('btn-archive-proposal')?.addEventListener('click', () => handleArchiveProposal(currentProject.projectId), { signal: abortController.signal });
983        if (activeSubModule !== 'overview') headerContainer.classList.add('header-compact');
984    }
985}
986
987async function renderProposalDetail(proposal, { generation, id, pageSignal, targetSubModule, transition }) {
988    const ownsSelection = () => generation === selectionGeneration
989        && currentProposalId === id
990        && abortController?.signal === pageSignal
991        && !pageSignal?.aborted;
992    if (!ownsSelection()) return false;
993    proposal.usesDivisions = companySettings.usesDivisions();
994    const emptyState = document.getElementById('detail-empty');
995    const content = document.getElementById('detail-content');
996
997    const headerContainer = document.getElementById('detail-header');
998    const detailContent = document.getElementById('detail-content');
999    const tabContainer = document.getElementById('sub-module-tabs');
1000    const headerStaging = document.createElement('div');
1001    await renderTo(headerStaging, '#tpl-proposals-header', proposal, { canEdit: !!proposal.canEdit });
1002    if (!ownsSelection()) return false;
1003    const visibleTabs = await offlineApi.filterSubModulesToOffline(subModules, proposal?.projectId);
1004    if (!ownsSelection() || !headerContainer?.isConnected || !detailContent?.isConnected || !tabContainer?.isConnected) return false;
1005    currentProject = proposal;
1006    if (emptyState) emptyState.classList.add('hidden');
1007    if (content) content.classList.remove('hidden');
1008    content?.removeAttribute('aria-busy');
1009    clearProposalSelectionTransition();
1010    headerContainer.replaceChildren(...headerStaging.childNodes);
1011    headerContainer.classList.toggle('header-compact', activeSubModule !== 'overview');
1012    tabContainer.classList.remove('hidden');
1013    renderSubModuleTabs(visibleTabs, activeSubModule, 'proposals');
1014    if (targetSubModule === activeSubModule) {
1015        transition?.visiblePanelIds?.forEach(panelId => document.getElementById(panelId)?.classList.remove('hidden'));
1016    }
1017    document.getElementById('btn-edit-proposal')?.addEventListener('click', () => handleEditProposal(proposal), { signal: abortController.signal });
1018    document.getElementById('btn-archive-proposal')?.addEventListener('click', () => handleArchiveProposal(proposal.projectId), { signal: abortController.signal });
1019
1020    // Show sub-tab nav for the active main tab
1021    document.querySelectorAll('#sub-tab-container > nav[data-parent-tab]').forEach(nav => {
1022        nav.classList.toggle('hidden', nav.dataset.parentTab !== activeSubModule);
1023    });
1024
1025    // Init overview-details sub-tab module (handles rendering overview info + dashboard + other sub-tabs)
1026    if (activeSubModule === 'overview' && proposal.projectId) {
1027        initOverviewDetails(proposal.projectId, abortController?.signal, {
1028            proposalData: proposal,
1029            onTabChange: handleOverviewTabChange
1030        });
1031    }
1032
1033    // If RFP tab is active, re-init with current project
1034    if (activeSubModule === 'rfp' && proposal.projectId) {
1035        initRfpDetails(proposal.projectId, abortController?.signal, {
1036            onTabChange: () => updateUrl()
1037        });
1038    }
1039
1040    // If Project Team tab is active, re-init with current project
1041    if (TEAM_PAGE_LINKS.includes(activeSubModule) && proposal.projectId) {
1042        initProjectTeam(proposal.projectId, abortController?.signal);
1043    }
1044
1045    // If Project Documents tab is active, re-init with current project
1046    if (DOCUMENTS_PAGE_LINKS.includes(activeSubModule) && proposal.projectId) {
1047        await initProjectDocuments(proposal.projectId, abortController?.signal, { onNavigate: ({ push = false } = {}) => updateUrl(push) });
1048        if (!ownsSelection()) return false;
1049    }
1050
1051    // If Fee Estimation tab is active, re-init with current project
1052    if (FEE_PAGE_LINKS.includes(activeSubModule) && proposal.projectId) {
1053        initFeeEstimation(proposal.projectId, abortController?.signal);
1054    }
1055
1056    // If Meetings tab is active, re-init with current project
1057    if (MEETINGS_PAGE_LINKS.includes(activeSubModule) && proposal.projectId) {
1058        initProjectMeetings(proposal.projectId, abortController?.signal, {
1059            onNavigate: () => updateUrl()
1060        });
1061    }
1062
1063    // If Consultant tab is active, re-init with current project
1064    if (CONSULTANT_PAGE_LINKS.includes(activeSubModule) && proposal.projectId) {
1065        initProposalConsultants(proposal.projectId, abortController?.signal, {
1066            onNavigate: () => updateUrl()
1067        });
1068    }
1069
1070    // If Task List tab is active, re-init with current project
1071    if (TASK_PAGE_LINKS.includes(activeSubModule) && proposal.projectId) {
1072        initProposalTasks(proposal.projectId, abortController?.signal);
1073    }
1074
1075    // If Proposal & Agreement tab is active, re-init with current project
1076    if (AGREEMENT_PAGE_LINKS.includes(activeSubModule) && proposal.projectId) {
1077        initProposalAgreement(proposal.projectId, abortController?.signal);
1078    }
1079
1080    // Update mini list selection if collapsed
1081    if (listCollapsed) {
1082        updateMiniListSelection();
1083    }
1084    return ownsSelection();
1085}
1086
1087// ========================================
1088// MOBILE SUPPORT
1089// ========================================
1090
1091function showDetailPane() {
1092    const emptyState = document.getElementById('detail-empty');
1093    const detailContent = document.getElementById('detail-content');
1094    const listColumn = document.getElementById('proposal-list-column');
1095    const detailColumn = document.getElementById('proposal-detail-column');
1096    const mobileBackHeader = document.getElementById('proposal-mobile-back-header');
1097
1098    if (emptyState) emptyState.classList.add('hidden');
1099    if (detailContent) detailContent.classList.remove('hidden');
1100
1101    if (isMobileOrTablet()) {
1102        if (listColumn) listColumn.classList.add('hidden');
1103        if (detailColumn) {
1104            detailColumn.classList.remove('hidden', 'lg:flex');
1105            detailColumn.classList.add('flex', 'w-full');
1106        }
1107        if (mobileBackHeader) mobileBackHeader.classList.remove('hidden');
1108    }
1109}
1110
1111function hideDetailPane() {
1112    selectionGeneration++;
1113    void destroyProjectActivityLog();
1114    clearProposalSelectionTransition();
1115    const emptyState = document.getElementById('detail-empty');
1116    const detailContent = document.getElementById('detail-content');
1117
1118    if (emptyState) emptyState.classList.remove('hidden');
1119    if (detailContent) detailContent.classList.add('hidden');
1120
1121    currentProposalId = null;
1122    currentProject = null;
1123}
1124
1125function showMobileList() {
1126    selectionGeneration++;
1127    void destroyProjectActivityLog();
1128    clearProposalSelectionTransition();
1129    const listColumn = document.getElementById('proposal-list-column');
1130    const detailColumn = document.getElementById('proposal-detail-column');
1131    const mobileBackHeader = document.getElementById('proposal-mobile-back-header');
1132    const emptyState = document.getElementById('detail-empty');
1133    const detailContent = document.getElementById('detail-content');
1134
1135    if (listColumn) listColumn.classList.remove('hidden');
1136
1137    if (detailColumn) {
1138        detailColumn.classList.remove('flex', 'w-full');
1139        detailColumn.classList.add('hidden', 'lg:flex');
1140    }
1141
1142    if (mobileBackHeader) mobileBackHeader.classList.add('hidden');
1143    if (emptyState) emptyState.classList.remove('hidden');
1144    if (detailContent) detailContent.classList.add('hidden');
1145
1146    document.querySelectorAll('#proposals-list .proposal-row').forEach(card => {
1147        card.classList.remove('border-st-primary', 'bg-st-primary/5');
1148    });
1149
1150    currentProposalId = null;
1151    currentProject = null;
1152    activeSubModule = 'overview';
1153    updateUrl(true);
1154}
1155
1156function initMobileBackHandler() {
1157    const backBtn = document.getElementById('btn-proposal-mobile-back');
1158    if (backBtn) {
1159        backBtn.addEventListener('click', showMobileList, { signal: abortController.signal });
1160    }
1161}
1162
1163// ========================================
1164// DROPDOWN NAVIGATOR (Mobile < 768px)
1165// ========================================
1166
1167const DROPDOWN_NAV_ID = 'proposal-dropdown-nav';
1168
1169function initDropdownNavigator() {
1170    const container = document.getElementById(DROPDOWN_NAV_ID);
1171    if (!container) return;
1172
1173    // Only show on mobile
1174    if (isMobile()) {
1175        container.classList.remove('hidden');
1176    }
1177
1178    DropdownNavigator.init({
1179        containerId: DROPDOWN_NAV_ID,
1180        items: buildDropdownItems(),
1181        selectedId: currentProposalId,
1182        onSelect: (id) => settleProposalAction(selectProposal(id)),
1183        onNew: () => page('/proposals/new'),
1184        newLabel: 'New Proposal',
1185        emptyLabel: 'No proposals found',
1186        moduleTitle: 'Proposals',
1187        moduleIcon: 'clipboard-list'
1188    });
1189}
1190
1191function syncDropdownNavigator() {
1192    DropdownNavigator.setItems(DROPDOWN_NAV_ID, buildDropdownItems());
1193    DropdownNavigator.setSelected(DROPDOWN_NAV_ID, currentProposalId);
1194}
1195
1196function buildDropdownItems() {
1197    return filteredProposals.map(p => ({
1198        id: p.projectId,
1199        badge: p.projectNumber || null,
1200        name: p.projectTitle || 'Untitled',
1201        subtitle: p.projectCity || p.divisionName || null
1202    }));
1203}
1204
1205// ========================================
1206// CRUD ACTIONS
1207// ========================================
1208
1209function handleCreateProposal() {
1210    page('/proposals/new');
1211}
1212
1213function handleEditProposal(proposal) {
1214    page(`/proposals/${proposal.projectId}/edit`);
1215}
1216
1217async function handleArchiveProposal(id) {
1218    await confirm({
1219        title: 'Archive this proposal?',
1220        message: 'The proposal will be moved to the archive.',
1221        icon: 'archive',
1222        confirmText: 'Archive',
1223        confirmClass: 'btn-st-error',
1224        async onConfirm() {
1225            await projectsApi.archiveProject(id);
1226            window.showToast?.('Proposal archived', 'success');
1227            await loadProposals();
1228            hideDetailPane();
1229        }
1230    });
1231}
1232
1233// ========================================
1234// Exports
1235// ========================================
1236export { selectProposal, hideDetailPane, showMobileList };

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.