1import { suppliersApi } from '../api/suppliers.js'; 2import { accountingPurchaseOrdersApi } from '../api/accounting-purchase-orders.js'; 3import { openPurchaseOrderEditor } from './po-editor.js'; 4import { renderTo, loadTemplates, createTemplateLoader } from '../utils/templates.js'; 5import { showModal, confirm } from '../utils/modal.js'; 6import { setLoading } from '../utils/dom.js'; 7import { flagFieldError } from '../utils/form-validation.js'; 8import { auth } from '../auth.js'; 9import { sendAttemptStorageKey, fingerprintSendPayload, readSendAttempt, writeSendAttempt } from '../utils/send-attempt.js'; 10 11// Page link values that map to the "cont" sub-module tab 12export const SUPPLIER_PAGE_LINKS = ['cont']; 13 14let currentProjectId = null; 15let suppliers = []; 16let filteredSuppliers = []; 17let signal = null; 18// Module-owned dialog controller. `signal` is the HOST page's signal (only page destroy 19// aborts it), so it cannot close a dialog on a same-page sub-module switch. destroy() aborts 20// this controller on every teardown route; init re-arms it and chains the host's abort INTO it 21// so page-level teardown still closes them. CA-103 (MCF-0244 + MCF-0306); shape from CA-097. 22let dialogAbort = null; 23const ensureTemplates = createTemplateLoader('components/supplier-templates'); 24let onNavigateCallback = null; 25 26// Detail state 27let currentSupplierId = null; 28let currentDetailData = null; 29 30// Portaled PO row-actions menu (kebab) â one at a time. 31let activePoMenu = null; 32let activePoMenuCleanup = null; 33let suppliersGeneration = 0; 34let addSupplierModalOwner = null; 35
36function invalidateAddSupplierModal() { 37 const owner = addSupplierModalOwner; 38 addSupplierModalOwner = null; 39 if (!owner) return; 40 owner.unlinkParent?.(); 41 owner.controller.abort(); 42 owner.modal?.close(); 43} 44 45function createAddSupplierModalOwner(pageSignal) { 46 invalidateAddSupplierModal(); 47 const controller = new AbortController(); 48 const onParentAbort = () => controller.abort(); 49 pageSignal?.addEventListener('abort', onParentAbort, { once: true }); 50 const owner = { 51 controller, 52 modal: null, 53 unlinkParent: () => pageSignal?.removeEventListener('abort', onParentAbort), 54 }; 55 addSupplierModalOwner = owner; 56 return owner; 57} 58 59function releaseAddSupplierModalOwner(owner) { 60 if (addSupplierModalOwner !== owner) return; 61 addSupplierModalOwner = null; 62 owner.unlinkParent?.(); 63 owner.controller.abort(); 64 owner.modal = null; 65} 66 67function isCurrentSuppliersContext(context, { requireSupplier = false } = {}) { 68 return !context.pageSignal?.aborted 69 && signal === context.pageSignal 70 && suppliersGeneration === context.generation 71 && currentProjectId === context.projectId 72 && !!document.getElementById('project-suppliers-container')?.isConnected 73 && (!requireSupplier || currentSupplierId === context.supplierId); 74} 75 76/** 77 * Initialize Suppliers sub-module. 78 * Called when the Suppliers tab is selected in Projects. 79 * @param {number} projectId - The selected project ID 80 * @param {AbortSignal} [abortSignal] - AbortController signal for cleanup 81 * @param {Object} [options] - Optional config 82 * @param {string|number} [options.initialSupplierId] - Supplier ID to deep-link into detail view 83 * @param {Function} [options.onNavigate] - Callback when view changes (for URL updates) 84 */ 85export async function initProjectSuppliers(projectId, abortSignal, options = {}) { 86 invalidateAddSupplierModal(); 87 suppliersGeneration += 1; 88 signal = abortSignal || null; 89 // Re-arm the dialog controller â a host can re-init without destroy (CA-086's shape), and 90 // a controller aborted on the last teardown must never be handed to a new dialog. Chain 91 // host -> module ONLY: the listener is bound to this controller's own signal, so a module 92 // abort removes it and never touches the host controller. 93 dialogAbort?.abort(); 94 dialogAbort = new AbortController(); 95 if (abortSignal?.aborted) dialogAbort.abort(); 96 else abortSignal?.addEventListener('abort', () => dialogAbort?.abort(), { once: true, signal: dialogAbort.signal }); 97 onNavigateCallback = options.onNavigate || null; 98 99 await ensureTemplates(); 100 101 // If project changed, clear cached data 102 if (currentProjectId !== projectId) { 103 suppliers = []; 104 filteredSuppliers = []; 105 currentSupplierId = null; 106 currentDetailData = null; 107 } 108 currentProjectId = projectId; 109 110 await loadSuppliers(); 111 112 // Deep-link into detail view if supplier ID provided via URL 113 if (options.initialSupplierId) { 114 await loadSupplierDetail(Number(options.initialSupplierId)); 115 } 116} 117 118/** 119 * Clean up Suppliers state. 120 */ 121export function destroyProjectSuppliers() { 122 invalidateAddSupplierModal(); 123 suppliersGeneration += 1; 124 closePoRowMenu(); 125 currentProjectId = null; 126 suppliers = []; 127 filteredSuppliers = []; 128 signal = null; 129 dialogAbort?.abort(); 130 dialogAbort = null; 131 onNavigateCallback = null; 132 currentSupplierId = null; 133 currentDetailData = null; 134} 135 136/** 137 * Get the currently viewed supplier ID (for URL building). 138 */ 139export function getCurrentSupplierId() { 140 return currentSupplierId || null; 141} 142 143// ======================================== 144// DATA LOADING 145// ======================================== 146 147async function loadSuppliers({ throwOnError = false, context = null } = {}) { 148 const loadContext = context || { 149 projectId: currentProjectId, 150 supplierId: currentSupplierId, 151 pageSignal: signal, 152 generation: suppliersGeneration, 153 }; 154 const container = document.getElementById('project-suppliers-container'); 155 if (!container || !currentProjectId || !isCurrentSuppliersContext(loadContext)) return false;
156 157 // Only show list loading if we don't have cached data 158 if (suppliers.length === 0) { 159 await renderTo(container, '#tpl-supplier-loading', {}); 160 if (!isCurrentSuppliersContext(loadContext)) return false; 161 } 162 163 try { 164 const response = await suppliersApi.getProjectList(loadContext.projectId); 165 if (!isCurrentSuppliersContext(loadContext)) return false; 166 suppliers = response.suppliers || []; 167 filteredSuppliers = [...suppliers]; 168 169 // Only render list if not currently in detail view 170 if (!currentSupplierId) { 171 await renderSupplierList({ context: loadContext }); 172 if (!isCurrentSuppliersContext(loadContext)) return false; 173 attachListEventHandlers(); 174 } 175 return true; 176 } catch (error) { 177 if (error.name === 'AbortError' || !isCurrentSuppliersContext(loadContext)) return false; 178 if (throwOnError) throw error; 179 window.showToast?.('Failed to load Suppliers', 'error', { description: error.message }); 180 return false; 181 } 182} 183 184// ======================================== 185// LIST RENDERING 186// ======================================== 187
188async function renderSupplierList({ context = null } = {}) { 189 const container = document.getElementById('project-suppliers-container'); 190 if (!container || (context && !isCurrentSuppliersContext(context))) return; 191 192 await renderTo(container, '#tpl-supplier-list', { 193 totalCount: suppliers.length 194 }); 195 if (context && !isCurrentSuppliersContext(context)) return; 196 197 const itemsContainer = document.getElementById('supplier-items'); 198 if (!itemsContainer) return; 199 200 if (filteredSuppliers.length === 0) { 201 await renderTo(itemsContainer, '#tpl-supplier-empty-row', {}); 202 } else { 203 await renderTo(itemsContainer, '#tpl-supplier-row', filteredSuppliers); 204 } 205 if (context && !isCurrentSuppliersContext(context)) return; 206} 207 208function attachListEventHandlers() { 209 const container = document.getElementById('project-suppliers-container'); 210 if (!container) return; 211 212 const opts = signal ? { signal } : {}; 213 214 // Add Supplier button 215 document.getElementById('btn-add-supplier')?.addEventListener('click', () => { 216 openAddSupplierModal(); 217 }, opts); 218 219 // Search 220 const searchInput = document.getElementById('supplier-search'); 221 searchInput?.addEventListener('input', (e) => { 222 applyFilters(e.target.value); 223 }, opts); 224 225 // Row clicks â open detail 226 const itemsContainer = document.getElementById('supplier-items'); 227 itemsContainer?.addEventListener('click', (e) => { 228 const card = e.target.closest('.supplier-card'); 229 if (card) { 230 const supplierId = parseInt(card.dataset.supplierId); 231 if (supplierId) loadSupplierDetail(supplierId); 232 } 233 }, opts); 234} 235 236async function openAddSupplierModal() { 237 const projectId = currentProjectId; 238 const pageSignal = signal; 239 const generation = suppliersGeneration; 240 const pageContext = { projectId, supplierId: currentSupplierId, pageSignal, generation }; 241 if (!projectId || !isCurrentSuppliersContext(pageContext)) return; 242 const owner = createAddSupplierModalOwner(pageSignal); 243 const isCurrent = () => addSupplierModalOwner === owner 244 && !owner.controller.signal.aborted 245 && isCurrentSuppliersContext(pageContext); 246 247 let available = []; 248 try { 249 const response = await suppliersApi.getAvailableForProject(projectId, { signal: owner.controller.signal }); 250 if (!isCurrent()) return; 251 available = Array.isArray(response) ? response : (response.suppliers || response || []); 252 } catch (error) { 253 if (error.name === 'AbortError' || !isCurrent()) return; 254 invalidateAddSupplierModal(); 255 window.showToast?.('Failed to load available suppliers', 'error', { description: error.message }); 256 return; 257 } 258 259 const modal = await showModal('#tpl-add-supplier-to-project-modal', { suppliers: available }, { 260 signal: owner.controller.signal, 261 onClose: () => releaseAddSupplierModalOwner(owner), 262 }); 263 if (!isCurrent()) { 264 modal.close(); 265 return; 266 } 267 owner.modal = modal; 268 269 const supplierSelect = modal.element.querySelector('[name="supplierId"]'); 270 const newSupplierCard = modal.element.querySelector('#new-supplier-card'); 271 const cancelNewBtn = modal.element.querySelector('#btn-cancel-new-supplier'); 272 const addBtn = modal.element.querySelector('#btn-add-supplier-to-project'); 273 274 supplierSelect?.addEventListener('change', () => { 275 if (supplierSelect.value === '__new__') { 276 newSupplierCard?.classList.remove('hidden'); 277 } else { 278 newSupplierCard?.classList.add('hidden'); 279 } 280 }); 281 282 cancelNewBtn?.addEventListener('click', () => { 283 newSupplierCard?.classList.add('hidden'); 284 if (supplierSelect) { 285 supplierSelect.value = ''; 286 supplierSelect.dispatchEvent(new Event('change', { bubbles: true })); 287 } 288 }); 289 290 addBtn?.addEventListener('click', async () => { 291 setLoading(addBtn, true, 'Adding...'); 292 293 try { 294 let supplierId; 295 296 if (supplierSelect?.value === '__new__') { 297 const nameInput = modal.element.querySelector('[name="newSupplierName"]'); 298 const name = nameInput?.value?.trim(); 299 if (!name) { 300 flagFieldError(modal.element, nameInput, 'Company name is required');
301 setLoading(addBtn, false); 302 return; 303 } 304 const phone1 = modal.element.querySelector('[name="newSupplierPhone1"]')?.value?.trim() || ''; 305 const city = modal.element.querySelector('[name="newSupplierCity"]')?.value?.trim() || ''; 306 const provState = modal.element.querySelector('[name="newSupplierProvState"]')?.value?.trim() || ''; 307 308 if (!isCurrent()) return; 309 const created = await suppliersApi.create({ name, phone1, city, provState }); 310 if (!isCurrent()) return; 311 supplierId = created.supplierId || created.id; 312 } else { 313 supplierId = supplierSelect ? parseInt(supplierSelect.value) : NaN; 314 if (!supplierId) { 315 flagFieldError(modal.element, supplierSelect, 'Please select a supplier', { clearOn: 'change' }); 316 setLoading(addBtn, false); 317 return; 318 } 319 } 320 321 if (!isCurrent()) return; 322 await suppliersApi.addToProject(supplierId, projectId); 323 if (!isCurrent()) return; 324 window.showToast?.('Supplier added to project', 'success'); 325 modal.close(); 326 await loadSuppliers({ context: pageContext }); 327 } catch (error) { 328 if (error.name === 'AbortError' || !isCurrent()) return; 329 window.showToast?.('Failed to add supplier', 'error', { description: error.message }); 330 setLoading(addBtn, false); 331 } 332 }); 333} 334 335// ======================================== 336// FILTERING 337// ======================================== 338 339function applyFilters(searchTerm = '') { 340 const term = searchTerm.toLowerCase(); 341 342 filteredSuppliers = suppliers.filter(s => { 343 if (term) { 344 const name = (s.name || '').toLowerCase(); 345 return name.includes(term); 346 } 347 return true; 348 }); 349 350 renderFilteredRows(); 351} 352 353async function renderFilteredRows() { 354 const itemsContainer = document.getElementById('supplier-items'); 355 const countEl = document.getElementById('supplier-count'); 356 if (!itemsContainer) return; 357 358 if (filteredSuppliers.length === 0) { 359 await renderTo(itemsContainer, '#tpl-supplier-empty-row', {}); 360 } else { 361 await renderTo(itemsContainer, '#tpl-supplier-row', filteredSuppliers); 362 } 363 364 if (countEl) countEl.textContent = filteredSuppliers.length; 365} 366 367// ======================================== 368// DETAIL VIEW 369// ======================================== 370 371async function loadSupplierDetail(supplierId) { 372 const container = document.getElementById('project-suppliers-container'); 373 if (!container || !currentProjectId) return; 374 375 currentSupplierId = supplierId; 376 377 // Notify parent for URL update 378 if (onNavigateCallback) onNavigateCallback(); 379 380 // Show loading skeleton 381 await renderTo(container, '#tpl-supplier-detail-loading', {}); 382 383 try { 384 const response = await suppliersApi.getProjectDetail(supplierId, currentProjectId); 385 currentDetailData = response; 386 387 // Render detail view 388 await renderTo(container, '#tpl-supplier-detail', response); 389 attachDetailEventHandlers(); 390 } catch (error) { 391 if (error.name === 'AbortError') return; 392 window.showToast?.('Failed to load supplier detail', 'error', { description: error.message }); 393 // Fall back to list 394 backToList(); 395 } 396} 397 398function attachDetailEventHandlers() { 399 const container = document.getElementById('project-suppliers-container'); 400 if (!container) return; 401 402 const opts = signal ? { signal } : {}; 403 404 // Back button 405 document.getElementById('btn-supplier-back')?.addEventListener('click', () => { 406 backToList(); 407 }, opts); 408 409 // Delete button 410 document.getElementById('btn-supplier-delete')?.addEventListener('click', () => { 411 handleDelete(); 412 }, opts); 413 414 // Add comment 415 document.getElementById('btn-add-supplier-comment')?.addEventListener('click', () => { 416 handleAddComment(); 417 }, opts); 418 419 // Sidebar tab switching (comments/messages) 420 container.querySelectorAll('.supplier-sidebar-tab').forEach(tab => { 421 tab.addEventListener('click', () => { 422 switchSidebarTab(tab.dataset.sidebarTab); 423 }, opts); 424 }); 425 426 // Task item row clicks â open task item detail modal 427 container.querySelectorAll('.task-item-row').forEach(row => { 428 row.addEventListener('click', () => { 429 const taskId = row.dataset.taskId; 430 if (taskId) openTaskItemDetail(Number(taskId)); 431 }, opts); 432 }); 433 434 // Submittal row clicks â navigate to submittals tab 435 container.querySelectorAll('.submittal-row').forEach(row => { 436 row.addEventListener('click', () => { 437 // Navigate to the submittals sub-module tab 438 // The submittal detail linking is future work; for now navigate to submittals tab 439 const subTabContainer = document.getElementById('sub-module-tabs'); 440 const subBtn = subTabContainer?.querySelector('[data-tab="sub"]') || subTabContainer?.querySelector('[data-tab="projsub"]'); 441 if (subBtn) subBtn.click(); 442 }, opts); 443 }); 444} 445 446async function openTaskItemDetail(taskId, { throwOnError = false, context = null } = {}) { 447 const taskContext = context || { 448 projectId: currentProjectId, 449 supplierId: currentSupplierId, 450 pageSignal: signal, 451 generation: suppliersGeneration, 452 }; 453 const isCurrent = () => isCurrentSuppliersContext(taskContext, { requireSupplier: true }); 454 await ensureTemplates(); 455 if (!isCurrent()) return false;
456 try { 457 const detail = await suppliersApi.getTaskItemDetail(taskId, { signal: taskContext.pageSignal }); 458 if (!isCurrent()) return false; 459 460 const EXPIRATION_TASK_TYPES = new Set(['COI', 'WSIB', 'WSIB2', 'MOL', 'TASR', 'SECL']); 461 const showExpiration = EXPIRATION_TASK_TYPES.has(detail.header?.taskTypeCode); 462 // The primary "PO" button belongs ONLY on the "Must Be Completed By" row of 463 // the Purchase Order / Agreement task (taskTypeCode LOIPA) â legacy parity 464 // (tpl_task_date_row.html: CONTITEM + 'Must Be Completed By' + LOIPA). This 465 // modal template is shared by every supplier task type; additional POs are 466 // created from the Purchase Orders section's "New PO" button, not per row. 467 const isPoTask = detail.header?.taskTypeCode === 'LOIPA'; 468 detail.dates?.forEach(d => { 469 d.showExpirationBtn = showExpiration && ( 470 d.dateTypeCode === 'DATEREC' || d.dateTypeText === 'Date Received From Sub-contractor' || 471 d.dateTypeCode === 'RFS' || d.dateTypeText === 'Received from Supplier' 472 ); 473 d.showPoBtn = isPoTask && isMustBeCompletedBy(d); 474 }); 475 476 // The PO section belongs only to the Purchase Order / Agreement (LOIPA) task. 477 // The backend returns the supplier's POs regardless of task type, so gate it. 478 detail.showPoSection = isPoTask; 479 // Pre-format the PO list (backend returns raw po.* rows) for the section. 480 detail.purchaseOrders?.forEach(po => { 481 po.refDisplay = po.referenceNumber || `#${po.purchaseOrderId}`; 482 po.dateIssuedDisplay = po.dateIssued ? String(po.dateIssued).slice(0, 10) : 'â'; 483 po.amountDisplay = (Number(po.subtotal) || 0).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); 484 }); 485 486 const modal = await showModal('#tpl-supplier-task-item-modal', detail, { size: 'lg', signal: dialogAbort?.signal }); 487 if (!isCurrent()) { 488 modal.close(); 489 return false; 490 } 491 const { element, close: closeDetail } = modal; 492 493 element.querySelectorAll('.task-date-action').forEach(btn => { 494 btn.addEventListener('click', async () => { 495 const dateId = Number(btn.dataset.dateId); 496 const action = btn.dataset.action; 497 const needsDate = btn.dataset.needsDate === 'true'; 498 const label = btn.dataset.label || (action === 'D' ? 'Change Due Date' : action === 'C' ? 'Mark Complete' : ''); 499 500 if (needsDate) { 501 const { element: promptEl, close: closePrompt } = await showModal( 502 '#tpl-supplier-task-date-prompt-modal', 503 { label }, 504 { size: 'sm', signal: dialogAbort?.signal } 505 ); 506 promptEl.querySelector('#btn-date-prompt-confirm')?.addEventListener('click', async () => { 507 const input = promptEl.querySelector('#date-prompt-input'); 508 const dateValue = readDateISO(input); 509 if (!dateValue) { 510 window.showToast?.('Please select a date', 'error'); 511 return; 512 } 513 closePrompt(); 514 closeDetail(); 515 await applyTaskDateAction(taskId, dateId, action, dateValue); 516 }); 517 } else { 518 closeDetail(); 519 await applyTaskDateAction(taskId, dateId, action, null); 520 } 521 }); 522 }); 523 element.querySelectorAll('.task-expiration-btn').forEach(btn => { 524 btn.addEventListener('click', async () => { 525 const dateId = Number(btn.dataset.dateId); 526 const existing = btn.dataset.expirationDate || ''; 527 const { element: promptEl, close: closePrompt } = await showModal( 528 '#tpl-supplier-task-date-prompt-modal', 529 { label: 'Add Expiration Date' }, 530 { size: 'sm', signal: dialogAbort?.signal } 531 ); 532 if (existing) { 533 const input = promptEl.querySelector('#date-prompt-input'); 534 if (input) input.value = existing; 535 } 536 promptEl.querySelector('#btn-date-prompt-confirm')?.addEventListener('click', async () => { 537 const input = promptEl.querySelector('#date-prompt-input'); 538 const dateValue = readDateISO(input); 539 closePrompt(); 540 closeDetail(); 541 await applyExpirationDate(taskId, dateId, dateValue); 542 }); 543 }); 544 }); 545 546 // Purchase Order â primary "PO" button on the MBCB row: get-or-create + open the primary PO 547 element.querySelectorAll('.task-po-open').forEach(btn => { 548 btn.addEventListener('click', () => 549 handleOpenPurchaseOrder(taskId, Number(btn.dataset.dateId), false, closeDetail)); 550 }); 551 // Purchase Orders section â click a PO row to open that PO in the editor
552 element.querySelectorAll('.task-po-row').forEach(row => { 553 row.addEventListener('click', async () => { 554 const poId = Number(row.dataset.poId); 555 if (!poId) return; 556 closeDetail(); 557 await openPurchaseOrderEditor({ 558 projectId: currentProjectId, 559 purchaseOrderId: poId, 560 signal, 561 onSaved: () => { openTaskItemDetail(taskId); }, 562 }); 563 }); 564 }); 565 // Purchase Orders section â per-row kebab: Edit and Resend / Delete (legacy parity) 566 element.querySelectorAll('.task-po-menu').forEach(btn => { 567 btn.addEventListener('click', (e) => { 568 e.stopPropagation(); // don't also trigger the row's open handler 569 openPoRowMenu(btn, { 570 poId: Number(btn.dataset.poId), 571 isAdditional: btn.dataset.isAdditional === '1', 572 ref: btn.dataset.ref || '', 573 }, taskId, closeDetail); 574 }); 575 }); 576 // "New PO" â create an additional PO anchored to the "Must Be Completed By" date 577 element.querySelector('#btn-task-po-new')?.addEventListener('click', () => { 578 const anchor = detail.dates?.find(isMustBeCompletedBy) || detail.dates?.[0]; 579 if (!anchor) { window.showToast?.('No workflow date to anchor the PO', 'error'); return; } 580 handleOpenPurchaseOrder(taskId, anchor.supplierTaskDateId, true, closeDetail); 581 }); 582 583 // Document upload 584 element.querySelector('#task-doc-file-input')?.addEventListener('change', async (e) => { 585 const file = e.target.files[0]; 586 if (!file) return; 587 const formData = new FormData(); 588 formData.append('file', file); 589 try { 590 await suppliersApi.uploadTaskDocument(taskId, formData); 591 window.showToast?.('Document uploaded', 'success'); 592 } catch (err) { 593 window.showToast?.('Failed to upload document', 'error', { description: err.message }); 594 } 595 closeDetail(); 596 openTaskItemDetail(taskId); 597 }); 598 599 // Document download 600 element.querySelectorAll('.task-doc-download').forEach(btn => { 601 btn.addEventListener('click', async () => { 602 try { 603 const { blob, fileName } = await suppliersApi.downloadTaskDocument(Number(btn.dataset.docId)); 604 const url = URL.createObjectURL(blob); 605 const a = document.createElement('a'); 606 a.href = url; 607 a.download = fileName || 'document'; 608 a.click(); 609 URL.revokeObjectURL(url); 610 } catch (err) { 611 window.showToast?.('Failed to download document', 'error', { description: err.message }); 612 } 613 }); 614 }); 615 616 // Document delete 617 element.querySelectorAll('.task-doc-delete').forEach(btn => { 618 btn.addEventListener('click', async () => { 619 const docId = Number(btn.dataset.docId); 620 const pageSignal = signal; 621 const projectId = currentProjectId; 622 const supplierId = currentSupplierId; 623 const generation = suppliersGeneration; 624 const context = { projectId, supplierId, pageSignal, generation }; 625 const committed = await confirm({ 626 title: 'Delete document?', 627 message: 'This document will be permanently removed.', 628 icon: 'trash-2', 629 confirmText: 'Delete', 630 confirmClass: 'btn-st-error', 631 signal: dialogAbort?.signal, 632 async onConfirm() { 633 await suppliersApi.deleteTaskDocument(docId); 634 } 635 }); 636 if (!committed || !isCurrentSuppliersContext(context, { requireSupplier: true })) return; 637 638 try { 639 window.showToast?.('Document deleted', 'success'); 640 if (!isCurrentSuppliersContext(context, { requireSupplier: true })) return; 641 closeDetail(); 642 await openTaskItemDetail(taskId, { throwOnError: true, context }); 643 if (!isCurrentSuppliersContext(context, { requireSupplier: true })) return; 644 } catch (error) { 645 if (error?.name === 'AbortError' || !isCurrentSuppliersContext(context, { requireSupplier: true })) return; 646 console.error('[ProjectSuppliers] Document deleted but task detail did not refresh:', error); 647 window.showToast?.('Document deleted, but the task detail did not refresh', 'error', { 648 description: 'Reload Suppliers to see the committed change.', 649 }); 650 } 651 }); 652 }); 653 654 // Task-level comment 655 element.querySelector('#btn-task-comment-post')?.addEventListener('click', async () => { 656 const input = element.querySelector('#task-comment-input'); 657 const comment = input?.value?.trim(); 658 if (!comment) return; 659 const btn = element.querySelector('#btn-task-comment-post');
660 setLoading(btn, true, 'Posting...'); 661 try { 662 await suppliersApi.addSupplierTaskItemComment(taskId, comment); 663 window.showToast?.('Comment added', 'success'); 664 } catch (err) { 665 window.showToast?.('Failed to add comment', 'error', { description: err.message }); 666 setLoading(btn, false); 667 return; 668 } 669 closeDetail(); 670 openTaskItemDetail(taskId); 671 }); 672 673 // Send email 674 element.querySelector('#btn-task-send-email')?.addEventListener('click', async () => { 675 if (!isCurrent()) return; 676 try { 677 const options = await suppliersApi.getTaskEmailOptions(taskId, { signal: taskContext.pageSignal }); 678 if (!isCurrent()) return; 679 closeDetail(); 680 await openEmailModal(taskId, options, taskContext); 681 } catch (err) { 682 if (err?.name === 'AbortError' || !isCurrent()) return; 683 window.showToast?.('Failed to load email options', 'error', { description: err.message }); 684 } 685 }); 686 687 return true; 688 } catch (error) { 689 if (error.name === 'AbortError' || !isCurrent()) return false; 690 if (throwOnError) throw error; 691 window.showToast?.('Failed to load task detail', 'error', { description: error.message }); 692 return false; 693 } 694} 695 696/** The "Must Be Completed By" workflow date â the PO anchor (legacy dates[0] / MBCB). */ 697function isMustBeCompletedBy(d) { 698 return d?.dateTypeText === 'Must Be Completed By' || d?.dateTypeCode === 'MBCB'; 699} 700 701/** 702 * Open the Purchase Order editor for a supplier task workflow date. 703 * `isAdditional=false` â primary PO (get-or-create for the date); `true` â a new 704 * additional PO. Reuses the shared editor (po-editor.js) that the Accounting â PO 705 * page also opens. On save, reopens the task modal so the change surfaces. 706 * @param {number} taskId ProjectDocCtrl_SupplierTaskID (for reopening the modal) 707 * @param {number} dateId ProjectDocCtrl_SupplierTask_DateID (the PO anchor) 708 * @param {boolean} isAdditional 709 * @param {Function} closeDetail closes the currently-open task modal 710 */ 711async function handleOpenPurchaseOrder(taskId, dateId, isAdditional, closeDetail) { 712 if (!currentProjectId || !currentSupplierId) { 713 window.showToast?.('Cannot open purchase order â missing supplier context', 'error'); 714 return; 715 } 716 try { 717 // Backend get-or-create returns the full detail bundle â hand it straight 718 // to the editor so it doesn't refetch. 719 const bundle = await accountingPurchaseOrdersApi.create(currentProjectId, { 720 supplierId: currentSupplierId, 721 projectTaskDateId: dateId, 722 isAdditional, 723 }); 724 if (!bundle?.header?.purchaseOrderId) { 725 window.showToast?.('Failed to open purchase order', 'error'); 726 return; 727 } 728 closeDetail(); 729 await openPurchaseOrderEditor({ 730 projectId: currentProjectId, 731 purchaseOrderId: bundle.header.purchaseOrderId, 732 bundle, 733 signal, 734 onSaved: () => { openTaskItemDetail(taskId); }, 735 }); 736 } catch (err) { 737 if (err.name === 'AbortError') return; 738 window.showToast?.('Failed to open purchase order', 'error', { description: err.message }); 739 } 740} 741 742/** 743 * Open the per-row PO actions menu (kebab). Portaled to <body> at z-popover so it 744 * clears the task modal, positioned below-right of the trigger. Mirrors the 745 * project-documents context-menu pattern. 746 * @param {HTMLElement} anchorEl the kebab button 747 * @param {{poId:number, isAdditional:boolean, ref:string}} po 748 * @param {number} taskId for reopening the task modal after an action 749 * @param {Function} closeDetail closes the currently-open task modal 750 */ 751function openPoRowMenu(anchorEl, po, taskId, closeDetail) { 752 closePoRowMenu(); 753 754 const html = $.templates('#tpl-supplier-po-row-menu').render({ isAdditional: po.isAdditional }); 755 const wrapper = document.createElement('div'); 756 wrapper.innerHTML = html; 757 const menu = wrapper.firstElementChild; 758 if (!menu) return; 759 760 document.body.appendChild(menu); 761 activePoMenu = menu; 762 window.lucide?.createIcons({ root: menu }); 763
764 // Position: below the anchor, right-aligned; flip above if no room below. 765 const a = anchorEl.getBoundingClientRect(); 766 const m = menu.getBoundingClientRect(); 767 let top = a.bottom + 4; 768 let left = a.right - m.width; 769 if (top + m.height > window.innerHeight - 8) top = a.top - m.height - 4; 770 if (left < 8) left = 8; 771 menu.style.top = `${top}px`; 772 menu.style.left = `${left}px`; 773 774 menu.querySelectorAll('.po-ctx-action').forEach(item => { 775 item.addEventListener('click', async () => { 776 const action = item.dataset.action; 777 closePoRowMenu(); 778 if (action === 'edit-resend') { 779 closeDetail(); 780 await openPurchaseOrderEditor({ 781 projectId: currentProjectId, 782 purchaseOrderId: po.poId, 783 signal, 784 onSaved: () => { openTaskItemDetail(taskId); }, 785 }); 786 } else if (action === 'delete') { 787 await handleDeletePurchaseOrder(po, taskId, closeDetail); 788 } 789 }); 790 }); 791 792 // Close on outside click or Escape. 793 const onOutside = (e) => { 794 if (!menu.contains(e.target) && !anchorEl.contains(e.target)) closePoRowMenu(); 795 }; 796 const onEsc = (e) => { if (e.key === 'Escape') closePoRowMenu(); }; 797 requestAnimationFrame(() => { 798 document.addEventListener('click', onOutside); 799 document.addEventListener('keydown', onEsc); 800 }); 801 activePoMenuCleanup = () => { 802 document.removeEventListener('click', onOutside); 803 document.removeEventListener('keydown', onEsc); 804 }; 805} 806 807function closePoRowMenu() { 808 if (activePoMenuCleanup) { activePoMenuCleanup(); activePoMenuCleanup = null; } 809 if (activePoMenu) { activePoMenu.remove(); activePoMenu = null; } 810} 811 812/** 813 * Delete an (additional) purchase order after confirmation, then reopen the task 814 * modal so the list refreshes. Legacy parity: only additional POs are deletable. 815 */ 816async function handleDeletePurchaseOrder(po, taskId, closeDetail) { 817 const projectId = currentProjectId; 818 const supplierId = currentSupplierId; 819 const pageSignal = signal; 820 const generation = suppliersGeneration; 821 const context = { projectId, supplierId, pageSignal, generation }; 822 const committed = await confirm({ 823 title: 'Delete PO', 824 message: `Are you sure you want to delete Purchase Order: '${po.ref}'?`, 825 confirmText: 'Delete', 826 confirmClass: 'btn-st-error', 827 signal: dialogAbort?.signal, 828 async onConfirm() { 829 await accountingPurchaseOrdersApi.delete_(projectId, po.poId); 830 }, 831 }); 832 if (!committed || !isCurrentSuppliersContext(context, { requireSupplier: true })) return; 833 834 try { 835 window.showToast?.(`Purchase Order ${po.ref} deleted`, 'success'); 836 if (!isCurrentSuppliersContext(context, { requireSupplier: true })) return; 837 closeDetail(); 838 await openTaskItemDetail(taskId, { throwOnError: true, context }); 839 if (!isCurrentSuppliersContext(context, { requireSupplier: true })) return; 840 } catch (error) { 841 if (error?.name === 'AbortError' || !isCurrentSuppliersContext(context, { requireSupplier: true })) return; 842 console.error('[ProjectSuppliers] Purchase order deleted but task detail did not refresh:', error); 843 window.showToast?.('Purchase order deleted, but the task detail did not refresh', 'error', { 844 description: 'Reload Suppliers to see the committed change.', 845 }); 846 } 847} 848 849async function openEmailModal(taskId, options, taskContext) { 850 const dialogSignal = dialogAbort?.signal; 851 const openedDialogAbort = dialogAbort; 852 if (!isCurrentSuppliersContext(taskContext, { requireSupplier: true }) || dialogSignal?.aborted) return; 853 const { element, close } = await showModal('#tpl-supplier-task-email-modal', options, { size: 'md', signal: dialogSignal }); 854 const isCurrent = () => dialogAbort === openedDialogAbort 855 && !openedDialogAbort?.signal.aborted 856 && element.isConnected 857 && isCurrentSuppliersContext(taskContext, { requireSupplier: true }); 858 if (!isCurrent()) { close(); return; } 859 860 const sendBtn = element.querySelector('#btn-task-email-send'); 861 const freshBtn = element.querySelector('#btn-task-email-fresh'); 862 const attemptNotice = element.querySelector('#supplier-email-attempt-notice'); 863 let sending = false;
864 const storageKey = () => sendAttemptStorageKey('supplier', taskContext.projectId, taskId, 865 auth.getCompany(), auth.getUser()); 866 const refreshAttemptUi = () => { 867 const saved = readSendAttempt(storageKey(), 'supplier email', localStorage); 868 attemptNotice?.classList.toggle('hidden', !saved); 869 return saved; 870 }; 871 try { refreshAttemptUi(); } 872 catch (error) { window.showToast?.('Supplier email attempt unavailable', 'error', { description: error.message }); } 873 874 const submitEmail = async (freshAttempt = false) => { 875 if (!isCurrent() || sending) return; 876 const toSelect = element.querySelector('[name="toContactIds"]'); 877 const ccSelect = element.querySelector('[name="ccContactIds"]'); 878 const docsSelect = element.querySelector('[name="attachDocIds"]'); 879 const subject = element.querySelector('#email-subject-input')?.value?.trim() || ''; 880 const message = element.querySelector('#email-message-input')?.value?.trim() || ''; 881 882 const toContactIds = toSelect 883 ? [...toSelect.selectedOptions].map(o => Number(o.value)) 884 : []; 885 const ccContactIds = ccSelect 886 ? [...ccSelect.selectedOptions].map(o => Number(o.value)) 887 : []; 888 const attachDocIds = docsSelect 889 ? [...docsSelect.selectedOptions].map(o => Number(o.value)) 890 : []; 891 892 if (toContactIds.length === 0) { 893 window.showToast?.('Please select at least one recipient', 'error'); 894 return; 895 } 896 897 sending = true; 898 const activeBtn = freshAttempt ? freshBtn : sendBtn; 899 if (freshBtn) freshBtn.disabled = true; 900 if (sendBtn) sendBtn.disabled = true; 901 setLoading(activeBtn, true, 'Queuing...'); 902 let submitted = false; 903 try { 904 const payload = { toContactIds, ccContactIds, attachDocIds, subject, emailMessage: message }; 905 const fingerprint = await fingerprintSendPayload(payload, crypto); 906 if (!isCurrent()) return; 907 const key = storageKey(); 908 const previous = readSendAttempt(key, 'supplier email', localStorage); 909 if (previous && !freshAttempt && previous.payloadFingerprint !== fingerprint) 910 throw new Error('These details differ from the saved attempt. Retry unchanged details or choose New attempt after checking Messages.'); 911 if (freshAttempt) { 912 if (!previous) return; 913 const approved = await confirm({ 914 title: 'Start a new supplier email attempt?', 915 message: 'Check Messages first. The earlier attempt may have queued an email. A new attempt can send a second copy.', 916 confirmText: 'Start new attempt', 917 signal: dialogSignal, 918 }); 919 if (!approved || !isCurrent()) return; 920 } 921 const attempt = freshAttempt || !previous 922 ? { operationKey: crypto.randomUUID().toLowerCase(), payloadFingerprint: fingerprint } 923 : previous; 924 writeSendAttempt(key, attempt, localStorage); 925 refreshAttemptUi(); 926 submitted = true; 927 await suppliersApi.sendTaskEmail(taskId, payload, attempt.operationKey); 928 if (!isCurrent()) return; 929 window.showToast?.('Supplier email queued for delivery', 'success'); 930 close(); 931 try { 932 await openTaskItemDetail(taskId, { context: taskContext, throwOnError: true }); 933 } catch (refreshError) { 934 if (!isCurrentSuppliersContext(taskContext, { requireSupplier: true })) return; 935 console.error('[ProjectSuppliers] Email queued but task detail did not refresh:', refreshError); 936 window.showToast?.('Email queued, but task detail did not refresh', 'error', { 937 description: 'Reload Suppliers to see the updated history.', 938 }); 939 } 940 } catch (err) { 941 if (!isCurrent()) return; 942 window.showToast?.(submitted ? 'Supplier email outcome needs checking' : 'Supplier email not queued', 'error', { 943 description: submitted ? `${err.message} Check Messages before starting a new attempt.` : err.message, 944 }); 945 } finally { 946 sending = false;
947 if (isCurrent()) { 948 setLoading(activeBtn, false); 949 if (freshBtn) freshBtn.disabled = false; 950 if (sendBtn) sendBtn.disabled = false; 951 } 952 } 953 }; 954 sendBtn?.addEventListener('click', () => submitEmail(), dialogSignal ? { signal: dialogSignal } : undefined); 955 freshBtn?.addEventListener('click', () => submitEmail(true), dialogSignal ? { signal: dialogSignal } : undefined); 956} 957 958function readDateISO(input) { 959 const txt = (input?.value || '').trim(); 960 if (!txt) return null; 961 const d = new Date(txt); 962 return isNaN(d.getTime()) ? null : d.toISOString().slice(0, 10); 963} 964 965async function applyTaskDateAction(taskId, dateId, action, dateValue) { 966 try { 967 await suppliersApi.updateTaskItemDate(taskId, dateId, action, dateValue); 968 window.showToast?.('Date updated', 'success'); 969 } catch (error) { 970 window.showToast?.('Failed to update date', 'error', { description: error.message }); 971 } 972 openTaskItemDetail(taskId); 973} 974 975async function applyExpirationDate(taskId, dateId, dateValue) { 976 try { 977 await suppliersApi.saveExpirationDate(taskId, dateId, dateValue); 978 window.showToast?.('Expiration date saved', 'success'); 979 } catch (error) { 980 window.showToast?.('Failed to save expiration date', 'error', { description: error.message }); 981 } 982 openTaskItemDetail(taskId); 983} 984 985function switchSidebarTab(tabName) { 986 const activeClasses = ['bg-st-primary', 'text-white', 'font-semibold']; 987 const inactiveClasses = ['text-theme-secondary', 'font-medium', 'hover:text-theme-primary', 'hover:bg-theme-primary', 'hover:border', 'hover:border-theme']; 988 989 // Update tab active states 990 document.querySelectorAll('.supplier-sidebar-tab').forEach(tab => { 991 const isActive = tab.dataset.sidebarTab === tabName; 992 if (isActive) { 993 tab.classList.remove(...inactiveClasses); 994 tab.classList.add(...activeClasses); 995 } else { 996 tab.classList.remove(...activeClasses); 997 tab.classList.add(...inactiveClasses); 998 } 999 }); 1000 1001 // Show/hide panels 1002 document.querySelectorAll('.supplier-sidebar-panel').forEach(panel => { 1003 panel.classList.add('hidden'); 1004 }); 1005 1006 const panel = document.getElementById(`sidebar-panel-${tabName}`); 1007 if (panel) panel.classList.remove('hidden'); 1008} 1009 1010async function handleAddComment() { 1011 const input = document.getElementById('supplier-comment-input'); 1012 const comment = input?.value?.trim(); 1013 if (!comment || !currentSupplierId || !currentProjectId) return; 1014 1015 const btn = document.getElementById('btn-add-supplier-comment'); 1016 setLoading(btn, true, 'Posting...'); 1017 1018 try { 1019 1020 await suppliersApi.addProjectComment(currentSupplierId, currentProjectId, comment); 1021 1022 // Clear input 1023 if (input) input.value = ''; 1024 1025 // Add optimistic comment to top of list 1026 const commentsList = document.getElementById('supplier-comments-list'); 1027 if (commentsList) { 1028 // Remove "no comments" message if present 1029 const emptyMsg = commentsList.querySelector('p'); 1030 if (emptyMsg && commentsList.children.length === 1) { 1031 emptyMsg.remove(); 1032 } 1033 1034 const now = new Date(); 1035 const dateStr = now.toLocaleDateString('en-US', { 1036 month: 'short', day: '2-digit', year: 'numeric', 1037 hour: 'numeric', minute: '2-digit', hour12: true 1038 }); 1039 1040 const commentHtml = $.templates('#tpl-supplier-comment-item').render({ 1041 createdBy: 'You', 1042 dateCreatedString: dateStr, 1043 comment: comment 1044 }); 1045 1046 commentsList.insertAdjacentHTML('afterbegin', commentHtml); 1047 } 1048 1049 window.showToast?.('Comment added', 'success'); 1050 setLoading(btn, false); 1051 } catch (error) { 1052 if (error.name === 'AbortError') return; 1053 window.showToast?.('Failed to add comment', 'error', { description: error.message }); 1054 setLoading(btn, false); 1055 } 1056} 1057 1058async function handleDelete() { 1059 if (!currentSupplierId || !currentProjectId) return; 1060 1061 const supplierName = currentDetailData?.supplier?.name || 'this
1061supplier'; 1062 const supplierId = currentSupplierId; 1063 const projectId = currentProjectId; 1064 const pageSignal = signal; 1065 const generation = suppliersGeneration; 1066 const context = { projectId, supplierId, pageSignal, generation }; 1067 1068 const committed = await confirm({ 1069 title: 'Remove supplier from project?', 1070 message: `This will remove ${supplierName} and all their task assignments from this project.`, 1071 icon: 'trash-2', 1072 confirmText: 'Remove', 1073 confirmClass: 'btn-st-error', 1074 signal: dialogAbort?.signal, 1075 async onConfirm() { 1076 await suppliersApi.deleteProjectSupplier(supplierId, projectId); 1077 } 1078 }); 1079 if (!committed || !isCurrentSuppliersContext(context, { requireSupplier: true })) return; 1080 1081 try { 1082 window.showToast?.('Supplier removed', 'success', { description: `${supplierName} removed from project` }); 1083 if (!isCurrentSuppliersContext(context, { requireSupplier: true })) return; 1084 1085 // Reset detail state and go back to list 1086 currentSupplierId = null; 1087 currentDetailData = null; 1088 1089 // Reload the list (supplier should be gone) 1090 await loadSuppliers({ throwOnError: true, context }); 1091 if (!isCurrentSuppliersContext(context)) return; 1092 } catch (error) { 1093 if (error?.name === 'AbortError' || !isCurrentSuppliersContext(context)) return; 1094 console.error('[ProjectSuppliers] Supplier removed but the list did not refresh:', error); 1095 window.showToast?.('Supplier removed, but the list did not refresh', 'error', { 1096 description: 'Reload Suppliers to see the committed change.', 1097 }); 1098 } 1099} 1100 1101async function backToList() { 1102 currentSupplierId = null; 1103 currentDetailData = null; 1104 1105 // Notify parent for URL update 1106 if (onNavigateCallback) onNavigateCallback(); 1107 1108 // Re-render list from cached data (instant) 1109 await renderSupplierList(); 1110 attachListEventHandlers(); 1111}
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.