1import { siteInstructionsApi } from '../api/site-instructions.js'; 2import { offlineApi } from '../offline/offline-api.js'; 3import { todayISO } from '../utils/time-utils.js'; 4import { renderTo, loadTemplates, createTemplateLoader } from '../utils/templates.js'; 5import { createDesignerTaskDetail } from './designer-task-detail.js'; 6import { showModal } from '../utils/modal.js'; 7import { afterRender, runWithBusyButton, setLoading } from '../utils/dom.js'; 8import { downloadBlob } from '../utils/download.js'; 9import { attachDropdownMenu } from '../utils/dropdown-menu.js'; 10import { updateFilterPills as _updateFilterPills } from '../utils/filter-pills.js'; 11import { SearchableDropdown } from '../components/SearchableDropdown.js'; 12import { DatePicker } from '../components/DatePicker.js'; 13import { DataView } from '../components/DataView.js'; 14import { validateRequired } from '../utils/form-validation.js'; 15import { auth } from '../auth.js'; 16import './si-task-modals.js'; // side-effect: registers SI_ISS / SI_STC / SI_ACK / SI_CMP with the task-modal factory 17import { openTaskModal } from '../utils/task-modal-factory.js'; 18 19// Page link values that map to the "si" sub-module tab 20export const SI_PAGE_LINKS = ['si']; 21 22let currentProjectId = null; 23let items = []; 24let filteredItems = []; 25let signal = null; 26// Module-owned dialog controller. `signal` is the HOST page's signal (only page destroy 27// aborts it), so it cannot close a dialog on a same-page sub-module switch. destroy() aborts 28// this controller on every teardown route; init re-arms it and chains the host's abort INTO it 29// so page-level teardown still closes them. CA-103 (MCF-0244 + MCF-0306); shape from CA-097. 30let dialogAbort = null; 31const ensureTemplates = createTemplateLoader('components/si-templates'); 32let activeStatusFilter = 'all'; 33let onNavigateCallback = null; 34 35// CCM data from API 36let listData = null; 37 38// Shared detail view instance (created once, reused) 39let detailView = null; 40let currentContainerId = 'project-si-container'; 41 42// DataView (table/cards toggle) â recreated each time the list shell renders 43let dataView = null; 44 45// Export dropdown handle â set by attachEventHandlers after each list render, 46// used by the page-level export click handler below. 47let exportDropdown = null; 48 49// Own AbortController for the export dropdown's listeners (see attachEventHandlers 50// below) â NOT the page `signal`. attachEventHandlers() re-runs on every list 51// re-render (loadItems, every "back to list" from detail), and attachDropdownMenu 52// binds its outside-click/Escape listeners on `document` â the most persistent 53// target there is. The page signal only aborts on full contract-admin teardown, 54// not on list re-renders, so reusing it here would stack a fresh pair of 55// document-level listeners on every render, forever. Same fix as project-rfi.js 56// / project-chg.js exportDropdownAbort. 57let exportDropdownAbort = null; 58 59/** 60 * Initialize SI sub-module. 61 * Called when the SI tab is selected in Projects. 62 * @param {number} projectId - The selected project ID 63 * @param {AbortSignal} [abortSignal] - AbortController signal for cleanup 64 * @param {Object} [options] - Optional config 65 * @param {string} [options.initialTaskId] - Task ID to deep-link into detail view 66 * @param {Function} [options.onNavigate] - Callback when view changes (for URL updates) 67 */ 68export async function initProjectSi(projectId, abortSignal, options = {}) { 69 signal = abortSignal || null; 70 // Re-arm the dialog controller â a host can re-init without destroy (CA-086's shape), and 71 // a controller aborted on the last teardown must never be handed to a new dialog. Chain 72 // host -> module ONLY: the listener is bound to this controller's own signal, so a module 73 // abort removes it and never touches the host controller. 74 dialogAbort?.abort(); 75 dialogAbort = new AbortController(); 76 if (abortSignal?.aborted) dialogAbort.abort(); 77 else abortSignal?.addEventListener('abort', () => dialogAbort?.abort(), { once: true, signal: dialogAbort.signal }); 78 onNavigateCallback = options.onNavigate || null; 79 const containerId = options.containerId || 'project-si-container'; 80 81 await ensureTemplates(); 82
83 // Recreate detail view if container changed 84 if (currentContainerId !== containerId && detailView) { 85 detailView.destroy(); 86 detailView = null; 87 } 88 currentContainerId = containerId; 89 90 // Create detail view instance if not exists 91 if (!detailView) { 92 detailView = createDesignerTaskDetail({ 93 api: siteInstructionsApi, 94 containerId: currentContainerId, 95 entityLabel: 'Field Memo / S.I.', 96 entityIcon: 'file-text', 97 onSpecialAction: handleSpecialAction, 98 itemType: 'SI', 99 // CA-017 opt-in: send-to-contractor milestone codes (modern + legacy alias) â 100 // renders the truthful Not sent / Queued / Failed / Sent strip + retry. 101 deliveryStateCodes: ['SI_STC', 'S2CONT'], 102 }); 103 } 104 105 // If project changed, clear cached data and reset detail view 106 if (currentProjectId !== projectId) { 107 items = []; 108 filteredItems = []; 109 listData = null; 110 activeStatusFilter = 'all'; 111 detailView.destroy(); 112 } 113 currentProjectId = projectId; 114 115 attachPageLevelHandlers(); 116 117 await loadItems(); 118 119 // Deep-link into detail view if task ID provided via URL 120 if (options.initialTaskId) { 121 await detailView.loadDetail(currentProjectId, options.initialTaskId, signal, { 122 onNavigate: onNavigateCallback, 123 onBackToList: async () => { await renderList(); attachEventHandlers(); }, 124 onReloadList: () => loadItems(), 125 }); 126 } 127} 128 129/** 130 * Clean up SI state. 131 */ 132export function destroyProjectSi() { 133 detailView?.destroy(); 134 detailView = null; 135 dataView?.destroy(); 136 dataView = null; 137 currentProjectId = null; 138 items = []; 139 filteredItems = []; 140 listData = null; 141 signal = null; 142 dialogAbort?.abort(); 143 dialogAbort = null; 144 activeStatusFilter = 'all'; 145 onNavigateCallback = null; 146 currentContainerId = 'project-si-container'; 147 exportDropdownAbort?.abort(); 148 exportDropdownAbort = null; 149 exportDropdown = null; 150} 151 152/** 153 * Get the currently viewed SI task ID (for URL building). 154 */ 155export function getCurrentSiTaskId() { 156 return detailView?.getCurrentTaskId() || null; 157} 158 159// ======================================== 160// DATA LOADING 161// ======================================== 162 163async function loadItems() { 164 const container = document.getElementById(currentContainerId); 165 if (!container || !currentProjectId) return; 166 167 await renderTo(container, '#tpl-si-loading', {}); 168 169 try { 170 const response = await siteInstructionsApi.getList(currentProjectId); 171 listData = response; 172 items = await offlineApi.prioritizeOfflineAvailable(response.rfis || [], { 173 feature: 'site-instructions', 174 idGetter: item => item.siId ?? item.rfiId ?? item.taskId, 175 }); 176 filteredItems = [...items]; 177 178 await renderList(); 179 attachEventHandlers(); 180 } catch (error) { 181 if (error.name === 'AbortError') return; 182 window.showToast?.('Failed to load Field Memos / S.I.', 'error', { description: error.message }); 183 } 184} 185 186// ======================================== 187// RENDERING 188// ======================================== 189 190async function renderList() { 191 const container = document.getElementById(currentContainerId); 192 if (!container) return; 193 194 await renderTo(container, '#tpl-si-list', { 195 totalCount: items.length, 196 overdueCount: listData?.overdueCount || 0, 197 dueTodayCount: listData?.dueTodayCount || 0, 198 upcomingCount: listData?.upcomingCount || 0, 199 closedCount: listData?.closedCount || 0 200 }); 201 202 // Wire DataView for the table/cards toggle (must happen AFTER the shell 203 // renders so the table-view / cards-view / toggle containers exist). 204 dataView?.destroy(); 205 dataView = DataView.init({ 206 storageKey: 'si', 207 tableContainerId: 'si-table-view', 208 cardsContainerId: 'si-cards-view', 209 toggleContainerId: 'si-view-toggle', 210 defaultView: 'table', 211 onViewChange: () => renderCurrentView(), 212 }); 213 214 await renderCurrentView(); 215 216 // Restore active filter pill state 217 updateFilterPills(); 218} 219 220/** 221 * Render the active view (table or cards) from filteredItems. 222 */ 223async function renderCurrentView() { 224 const tableBody = document.getElementById('si-table-body'); 225 const cardsContainer = document.getElementById('si-items'); 226 227 if (filteredItems.length === 0) { 228 if (tableBody) await renderTo(tableBody, '#tpl-si-table-empty', {}); 229 if (cardsContainer) await renderTo(cardsContainer, '#tpl-si-empty', {}); 230 return; 231 } 232 233 const view = dataView?.getView() ?? 'table'; 234 if (view === 'table' && tableBody) { 235 await renderTo(tableBody, '#tpl-si-table-row', filteredItems); 236 } else if (cardsContainer) { 237 await renderTo(cardsContainer, '#tpl-si-row', filteredItems); 238 } 239} 240 241function updateFilterPills() { 242 _updateFilterPills('.si-filter-pill', activeStatusFilter); 243} 244 245/** Map current filter pill to the isOpen API param. */ 246function getSiFilterIsOpen() { 247 if (activeStatusFilter === 'all') return null; 248 if (activeStatusFilter === 'C') return false;
249 return true; 250} 251 252function attachEventHandlers() { 253 const container = document.getElementById(currentContainerId); 254 if (!container) return; 255 256 const opts = signal ? { signal } : {}; 257 258 // Search 259 const searchInput = document.getElementById('si-search'); 260 searchInput?.addEventListener('input', (e) => { 261 applyFilters(e.target.value); 262 }, opts); 263 264 // Status filter pills 265 const filterContainer = document.getElementById('si-status-filters'); 266 filterContainer?.addEventListener('click', (e) => { 267 const pill = e.target.closest('.si-filter-pill'); 268 if (!pill) return; 269 activeStatusFilter = pill.dataset.filter; 270 const searchInput = document.getElementById('si-search'); 271 applyFilters(searchInput?.value || ''); 272 updateFilterPills(); 273 }, opts); 274 275 // ââ Export dropdown toggle (button is inside the re-rendered list shell) ââ 276 // Abort the PREVIOUS render's dropdown listeners before attaching a fresh pair 277 // (see exportDropdownAbort declaration above) â otherwise document click/keydown 278 // listeners stack forever across list<->detail round trips. 279 exportDropdownAbort?.abort(); 280 exportDropdownAbort = new AbortController(); 281 exportDropdown = attachDropdownMenu('btn-si-export', 'si-export-menu', { signal: exportDropdownAbort.signal }); 282} 283 284// Bound once per page init (not per list render) â these delegate on the 285// persistent outer container element and must not stack across re-renders. 286// Same hazard + same cure as project-rfi.js/project-chg.js attachPageLevelHandlers: 287// the page AbortSignal only aborts on full page teardown, not on sub-module/project 288// switch, so re-binding on every render STACKS listeners (the measured "1 click = 289// 4-5 identical create modals" class â MCF-0144). Handlers read module-level state 290// (currentProjectId, detailView, signal, â¦) at fire time, so bind-once is safe. 291function attachPageLevelHandlers() { 292 const container = document.getElementById(currentContainerId); 293 if (!container) return; 294 295 if (container.dataset.siPageHandlersBound === 'true') return; 296 container.dataset.siPageHandlersBound = 'true'; 297 298 const opts = signal ? { signal } : {}; 299 300 // New SI button (delegated â works from header button AND empty state CTA) 301 container.addEventListener('click', (e) => { 302 if (e.target.closest('[data-action="new-si"]')) { 303 showAddModal(); 304 } 305 }, opts); 306 307 // ââ Export button clicks ââ 308 container.addEventListener('click', async (e) => { 309 const btn = e.target.closest('.si-export-btn'); 310 if (!btn) return; 311 exportDropdown?.close(); 312 313 const format = btn.dataset.format; 314 const scope = btn.dataset.scope; 315 const isOpen = scope === 'filtered' ? getSiFilterIsOpen() : null; 316 317 await runWithBusyButton(btn, 'Generating...', async () => { 318 const { blob, fileName } = format === 'pdf' 319 ? await siteInstructionsApi.downloadLogPdf(currentProjectId, isOpen) 320 : await siteInstructionsApi.downloadLogExcel(currentProjectId, isOpen); 321 322 downloadBlob(blob, fileName); 323 324 window.showToast?.(`${format.toUpperCase()} downloaded`, 'success'); 325 }, { errorTitle: 'Export failed', errorLogPrefix: '[ProjectSi] Export failed:', resetOnSuccess: true }); 326 }, opts); 327 328 // Row click â navigate to detail view. Delegated from `container` so it 329 // fires for both card rows (inside #si-items) and table rows (inside 330 // #si-table-body) â both carry `data-si-id` on the row element. 331 container.addEventListener('click', (e) => { 332 const row = e.target.closest('[data-si-id]'); 333 if (!row) return; 334 if (row.dataset.offlineUnavailable === 'true') { 335 window.showToast?.('Not saved for offline', 'info', { 336 description: 'Open this Field Memo while online to save it for offline viewing.', 337 }); 338 return; 339 } 340 detailView.loadDetail(currentProjectId, row.dataset.siId, signal, { 341 onNavigate: onNavigateCallback, 342 onBackToList: async () => { await renderList(); attachEventHandlers(); }, 343 onReloadList: () => loadItems(), 344 }); 345 }, opts); 346} 347 348// ======================================== 349// FILTERING 350// ======================================== 351 352function applyFilters(searchTerm = '') { 353 const term = searchTerm.toLowerCase(); 354 355 filteredItems = items.filter(r => { 356 // Status filter 357 if (activeStatusFilter !== 'all' && r.status !== activeStatusFilter) { 358 return false;
359 } 360 361 // Text search 362 if (term) { 363 const title = (r.title || '').toLowerCase(); 364 const ref = (r.referenceNumber || '').toLowerCase(); 365 const assigned = (r.assigned || '').toLowerCase(); 366 return title.includes(term) || ref.includes(term) || assigned.includes(term); 367 } 368 369 return true; 370 }); 371 372 renderFilteredRows(); 373} 374 375async function renderFilteredRows() { 376 await renderCurrentView(); 377 const countEl = document.getElementById('si-count'); 378 if (countEl) countEl.textContent = filteredItems.length; 379} 380 381// ======================================== 382// SPECIAL ACTION MODALS (SI Date Types) 383// ======================================== 384 385async function handleSpecialAction(action, taskDateId, dateTypeId, taskId) { 386 if (!currentProjectId || !taskDateId) return; 387 388 try { 389 const opts = await siteInstructionsApi.getAddOptions(currentProjectId); 390 const members = opts.members || []; 391 392 const commonOpts = { 393 projectId: currentProjectId, 394 taskId, 395 taskDateId, 396 dateTypeId, 397 api: siteInstructionsApi, 398 members, 399 onComplete: async () => { 400 if (detailView && taskId) { 401 await detailView.loadDetail(currentProjectId, taskId, signal, { 402 onNavigate: onNavigateCallback, 403 onBackToList: async () => { await renderList(); attachEventHandlers(); }, 404 onReloadList: () => loadItems(), 405 }); 406 } 407 }, 408 }; 409 410 const codeByAction = { 411 'si-issued': 'SI_ISS', 412 'si-sent-contractor': 'SI_STC', 413 'si-acknowledged': 'SI_ACK', 414 'si-completed': 'SI_CMP', 415 }; 416 const code = codeByAction[action]; 417 if (code) await openTaskModal(code, commonOpts); 418 } catch (error) { 419 if (error.name === 'AbortError') return; 420 window.showToast?.('Failed to open form', 'error', { description: error.message }); 421 } 422} 423 424// ======================================== 425// CREATE MODAL 426// ======================================== 427 428async function showAddModal() { 429 if (!currentProjectId) return; 430 431 // Captured BEFORE the first await: the live binding is nulled by destroy*, so a dialog opened 432 // after a sub-module switch would otherwise get `signal: undefined` and outlive its owner. 433 const dialogSignal = dialogAbort?.signal; 434 try { 435 const response = await siteInstructionsApi.getAddOptions(currentProjectId, signal ? { signal } : undefined); 436 const members = response.members || []; 437 const nextRefNumber = response.nextRefNumber || response.NextRefNumber || 1; 438 439 const user = auth.getUser(); 440 const currentUserId = String(user?.UserID || user?.userID || user?.userId || ''); 441 442 const modal = await showModal('#tpl-si-add-modal', { members, nextRefNumber }, { signal: dialogSignal }); 443 444 // Default Date Received to today 445 const dateInput = modal.element.querySelector('#si-date-received'); 446 if (dateInput) { 447 const today = todayISO(); 448 const dp = DatePicker.getInstance(dateInput); 449 if (dp) dp.setValue(today); else dateInput.value = today; 450 } 451 452 // Pre-select current user in Assigned To 453 const assignedSelect = modal.element.querySelector('#si-assigned-to'); 454 if (assignedSelect && currentUserId) { 455 const sd = SearchableDropdown.getInstance?.(assignedSelect); 456 if (sd) sd.setValue([currentUserId]); 457 } 458 459 // ââ File Upload ââ 460 const pendingFiles = []; 461 const fileInput = modal.element.querySelector('#si-file-input'); 462 const dropZone = modal.element.querySelector('#si-file-drop-zone'); 463 const fileList = modal.element.querySelector('#si-file-list'); 464 465 function addFiles(files) { 466 for (const file of files) { 467 if (pendingFiles.some(f => f.name === file.name && f.size === file.size)) continue; 468 pendingFiles.push(file); 469 } 470 renderFileList(); 471 } 472 function renderFileList() { 473 if (!fileList) return; 474 if (pendingFiles.length === 0) { fileList.innerHTML = ''; return; } 475 fileList.innerHTML = pendingFiles.map((f, i) => { 476 const sizeKb = (f.size / 1024).toFixed(1); 477 const isImage = f.type.startsWith('image/'); 478 return `<div class="flex items-center gap-3 p-2 rounded-lg bg-surface-2/10 border border-theme"> 479 <i data-lucide="${isImage ? 'image' : 'file'}" class="w-4 h-4 text-theme-secondary shrink-0"></i>
480 <span class="text-sm text-theme-primary truncate flex-1">${f.name}</span> 481 <span class="text-xs text-theme-secondary shrink-0">${sizeKb} KB</span> 482 <button type="button" class="text-theme-secondary hover:text-sb-error p-1 shrink-0" data-remove-file="${i}" aria-label="Remove"> 483 <i data-lucide="x" class="w-3.5 h-3.5"></i> 484 </button> 485 </div>`; 486 }).join(''); 487 afterRender(fileList); 488 } 489 dropZone?.addEventListener('click', () => fileInput?.click()); 490 fileInput?.addEventListener('change', () => { if (fileInput.files?.length) addFiles(fileInput.files); fileInput.value = ''; }); 491 dropZone?.addEventListener('dragover', (e) => { e.preventDefault(); dropZone.classList.add('border-st-primary', 'bg-surface-2/10'); }); 492 dropZone?.addEventListener('dragleave', () => { dropZone.classList.remove('border-st-primary', 'bg-surface-2/10'); }); 493 dropZone?.addEventListener('drop', (e) => { e.preventDefault(); dropZone.classList.remove('border-st-primary', 'bg-surface-2/10'); if (e.dataTransfer?.files?.length) addFiles(e.dataTransfer.files); }); 494 fileList?.addEventListener('click', (e) => { const btn = e.target.closest('[data-remove-file]'); if (btn) { pendingFiles.splice(parseInt(btn.dataset.removeFile), 1); renderFileList(); } }); 495 496 const createBtn = modal.element.querySelector('#btn-create-si'); 497 createBtn?.addEventListener('click', async () => { 498 const el = modal.element; 499 500 const title = el.querySelector('#si-title')?.value?.trim(); 501 const description = el.querySelector('#si-description')?.value?.trim() || null; 502 const details = el.querySelector('#si-details')?.value?.trim() || null; 503 const dateReceived = el.querySelector('#si-date-received')?.value || null; 504 505 const assignedEl = el.querySelector('#si-assigned-to'); 506 const assignedUserIds = Array.from(assignedEl?.selectedOptions || []) 507 .map(o => parseInt(o.value)).filter(v => !isNaN(v)); 508 509 const rules = [ 510 { el: el.querySelector('#si-title'), label: 'Title', maxLength: 500 }, 511 { el: el.querySelector('#si-description'), label: 'Description' }, 512 ]; 513 514 if (!validateRequired(el, rules)) return; 515 516 setLoading(createBtn, true, 'Recording...'); 517 518 try { 519 const result = await siteInstructionsApi.create(currentProjectId, { 520 title, 521 description, 522 comments: details, 523 dateDue: dateReceived, 524 assignedUserIds: assignedUserIds.length > 0 ? assignedUserIds : null, 525 }); 526 527 // Upload pending files 528 const siId = result?.rfiId || result?.RfiId || result?.id; 529 if (siId && pendingFiles.length > 0) { 530 for (const file of pendingFiles) { 531 try { 532 const formData = new FormData(); 533 formData.append('file', file); 534 formData.append('title', file.name); 535 formData.append('description', ''); 536 await siteInstructionsApi.uploadDocument(currentProjectId, siId, formData); 537 } catch (uploadErr) { 538 console.error('Failed to upload file:', file.name, uploadErr); 539 } 540 } 541 } 542 543 window.showToast?.('Field Memo / S.I. recorded', 'success', { description: title }); 544 modal.close(); 545 await loadItems(); 546 547 // Navigate to the newly created Field Memo detail view 548 if (siId && detailView) { 549 detailView.loadDetail(currentProjectId, siId, signal, { 550 onNavigate: onNavigateCallback, 551 onBackToList: async () => { await renderList(); attachEventHandlers(); }, 552 onReloadList: () => loadItems(), 553 }); 554 } 555 } catch (error) { 556 if (error.name === 'AbortError') return; 557 window.showToast?.('Failed to record Field Memo / S.I.', 'error', { description: error.message });
558 setLoading(createBtn, false); 559 } 560 }); 561 } catch (error) { 562 if (error.name === 'AbortError') return; 563 window.showToast?.('Failed to open add form', 'error', { description: error.message }); 564 } 565}
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.