PageSourceSearch

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

js smrtbld.ai collected 2026-10-05 15:10:46 UTC 23,576 bytes, 565 lines download raw bytes

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

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.