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.