PageSourceSearch

https://smrtbld.ai/js/pages/project-suppliers.js

js smrtbld.ai collected 2026-10-05 15:10:48 UTC 47,185 bytes, 1,111 lines download raw bytes

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.