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