PageSourceSearch

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

js smrtbld.ai collected 2026-10-05 15:10:43 UTC 106,488 bytes, 2,877 lines download raw bytes

1import { projectMeetingsApi } from '../api/project-meetings.js';
2import { projectTeamApi } from '../api/project-team.js';
3import { offlineApi } from '../offline/offline-api.js';
4import { renderTo, loadTemplates, createTemplateLoader } from '../utils/templates.js';
5import { showModal, confirm } from '../utils/modal.js';
6import { flagFieldError } from '../utils/form-validation.js';
7import { DatePicker } from '../components/DatePicker.js';
8import { setLoading, afterRender } from '../utils/dom.js';
9import { downloadBlob } from '../utils/download.js';
10import { getInitials, timeAgo } from '../utils/jsrender-helpers.js';
11import { parseLocalDate, formatDateISO } from '../utils/time-utils.js';
12import { FileViewer } from '../components/FileViewer.js';
13
14// Page link values that map to "meetings" in parent modules
15export const MEETINGS_PAGE_LINKS = ['meetings'];
16
17// --- Module State ---
18let currentProjectId = null;
19let signal = null;
20// Module-owned dialog lifetime (CA-106 / MCF-0244): armed in initProjectMeetings, chained from the
21// host's signal, aborted in destroyProjectMeetings — 22 host destroy routes (bids, contract-admin,
22// projects, …), none of which aborts the page signal. The participant-picker / email owners
23// (createMeetingModalOwner) are already module-owned and keep their own controller.
24let dialogAbort = null;
25const ensureTemplates = createTemplateLoader('components/project-meetings-templates');
26let meetings = [];
27let viewState = 'list'; // 'list' | 'detail'
28let currentMeetingId = null;
29let currentMeeting = null;
30let collapsedTopics = new Set();
31let saveDebounceTimers = {};
32const SAVE_DEBOUNCE_MS = 1500;
33
34// Modal state
35let editingMeetingId = null;
36let activeModal = null;
37let createStagedParticipants = []; // participants staged in the create-meeting modal
38let participantPickerOwner = null;
39let emailMeetingOwner = null;
40
41const ATTENDANCE_LABELS = { InPerson: 'In-Person', Virtual: 'Virtual', Notified: 'Notified' };
42
43// SortableJS instances (for cleanup)
44let sortableInstances = [];
45
46// Comment state: topicId → { loaded: bool, comments: [], expanded: bool }
47let commentCache = {};
48
49// File state: topicId → { loaded: bool, files: [], expanded: bool }
50let fileCache = {};
51
52// Active popover cleanup functions (for destroy)
53let activePopoverCleanups = [];
54
55// Navigation callback — parent module calls updateUrl() when this fires
56let onNavigateCallback = null;
57let meetingsGeneration = 0;
58
59function invalidateMeetingModalOwner(kind) {
60    const owner = kind === 'participants' ? participantPickerOwner : emailMeetingOwner;
61    if (kind === 'participants') participantPickerOwner = null;
62    else emailMeetingOwner = null;
63    if (!owner) return;
64    owner.unlinkParent?.();
65    owner.controller.abort();
66    owner.modal?.close();
67}
68
69function createMeetingModalOwner(kind, pageSignal) {
70    invalidateMeetingModalOwner(kind);
71    const controller = new AbortController();
72    const onParentAbort = () => controller.abort();
73    pageSignal?.addEventListener('abort', onParentAbort, { once: true });
74    const owner = {
75        controller,
76        modal: null,
77        unlinkParent: () => pageSignal?.removeEventListener('abort', onParentAbort),
78    };
79    if (kind === 'participants') participantPickerOwner = owner;
80    else emailMeetingOwner = owner;
81    return owner;
82}
83
84function releaseMeetingModalOwner(kind, owner) {
85    const isCurrent = kind === 'participants'
86        ? participantPickerOwner === owner
87        : emailMeetingOwner === owner;
88    if (!isCurrent) return;
89    if (kind === 'participants') participantPickerOwner = null;
90    else emailMeetingOwner = null;
91    owner.unlinkParent?.();
92    owner.controller.abort();
93    owner.modal = null;
94}
95
96function invalidateMeetingScopedModals() {
97    invalidateMeetingModalOwner('participants');
98    invalidateMeetingModalOwner('email');
99}
100
101function isCurrentMeetingsMountContext(context) {
102    return !context.pageSignal?.aborted
103        && signal === context.pageSignal
104        && meetingsGeneration === context.generation
105        && String(currentProjectId) === String(context.projectId)
106        && !!document.getElementById('project-meetings-container')?.isConnected;
107}
108
109function isCurrentMeetingContext(projectId, meetingId, pageSignal, generation = null) {
110    const context = {
111        projectId,
112        pageSignal,
113        generation: generation ?? meetingsGeneration,
114    };
115    return isCurrentMeetingsMountContext(context)
116        && String(currentProjectId) === String(projectId)
117        && String(currentMeetingId) === String(meetingId);
118}
119
120// ========================================
121// LIFECYCLE
122// ========================================
123
124/**
125 * Get the currently viewed meeting ID (for URL building by parent modules).
126 */
127export function getCurrentMeetingId() {
128    return viewState === 'detail' ? currentMeetingId : null;
129}
130
131/**
132 * Initialize Project Meetings sub-module.
133 * @param {number} projectId - The selected project ID
134 * @param {AbortSignal} [abortSignal] - AbortController signal for cleanup
135 * @param {Object} [options] - Optional config
136 * @param {number} [options.initialMeetingId] - Meeting ID to deep-link into detail view
137 * @param {Function} [options.onNavigate] - Callback when view changes (for URL updates)
138 */
139export async function initProjectMeetings(projectId, abortSignal, options = {}) {
140    invalidateMeetingScopedModals();
141    meetingsGeneration += 1;
142    signal = abortSignal || null;
143    dialogAbort?.abort();
144    dialogAbort = new AbortController();
145    if (abortSignal?.aborted) dialogAbort.abort();
146    else abortSignal?.addEventListener('abort', () => dialogAbort?.abort(), { once: true, signal: dialogAbort.signal });
147    onNavigateCallback = options.onNavigate || null;
148
149    await ensureTemplates();
150
151    if (currentProjectId !== projectId) {
152        meetings = [];
153        viewState = 'list';
154        currentMeetingId = null;
155        currentMeeting = null;
156        collapsedTopics.clear();
157        commentCache = {};
158        fileCache = {};
159        clearAllDebounceTimers();
160    }
161    currentProjectId = projectId;
162
163    await loadMeetings();
164
165    // Deep-link into detail view if meeting ID provided via URL
166    if (options.initialMeetingId && viewState === 'list') {
167        await loadMeetingDetail(options.initialMeetingId);
168    }
169}
170
171/**
172 * Clean up Project Meetings state.
173 */
174export function destroyProjectMeetings() {
175    invalidateMeetingScopedModals();
176    meetingsGeneration += 1;
177    FileViewer.destroy();
178    clearAllDebounceTimers();
179    destroySortables();
180    closeModal();
181    dismissActivePopovers();
182    currentProjectId = null;
183    meetings = [];
184    viewState = 'list';
185    currentMeetingId = null;
186    currentMeeting = null;
187    collapsedTopics.clear();
188    commentCache = {};
189    fileCache = {};
190    editingMeetingId = null;
191    createStagedParticipants = [];
192    activePopoverCleanups = [];
193    onNavigateCallback = null;
194    dialogAbort?.abort();
195    dialogAbort = null;
196    signal = null;
197}
198
199// ========================================
200// DATA LOADING
201// ========================================
202
203async function loadMeetings() {
204    const container = document.getElementById('project-meetings-container');
205    if (!container) return;
206
207    await renderTo(container, '#tpl-meetings-loading', {});
208
209    try {
210        const response = await projectMeetingsApi.getMeetings(currentProjectId);
211        meetings = await offlineApi.prioritizeOfflineAvailable(response?.meetings || [], {
212            feature: 'meetings',
213            idGetter: item => item.meetingId,
214            keyForId: id => `smrtbuild/projects/${currentProjectId}/meetings/${id}`,
215        });
216
217        viewState = 'list';
218        await renderListView();
219    } catch (error) {
220        if (error.name === 'AbortError') return;
221        window.showToast?.('Failed to load meetings', 'error', { description: error.message });
222        meetings = [];
223        await renderListView();
224    }
225}
226
227async function loadMeetingDetail(meetingId, { throwOnError = false, context = null } = {}) {
228    const loadContext = context || {
229        projectId: currentProjectId,
230        pageSignal: signal,
231        generation: meetingsGeneration,
232    };
233    const container = document.getElementById('project-meetings-container');
234    if (!container || !isCurrentMeetingsMountContext(loadContext)) return false;
235
236    await renderTo(container, '#tpl-meetings-loading', {});
237    if (!isCurrentMeetingsMountContext(loadContext)) return false;
238
239    try {
240        const response = await projectMeetingsApi.getMeetingDetail(loadContext.projectId, meetingId);
241        if (!isCurrentMeetingsMountContext(loadContext)) return false;
242        currentMeeting = response;
243        currentMeetingId = meetingId;
244        viewState = 'detail';
245        collapsedTopics.clear();
246        commentCache = {};
247        fileCache = {};
248
249        await renderDetailView({ context: loadContext, meetingId });
250        if (!isCurrentMeetingContext(loadContext.projectId, meetingId, loadContext.pageSignal, loadContext.generation)) return false;
251        onNavigateCallback?.();
252        return true;
253    } catch (error) {
254        if (error.name === 'AbortError' || !isCurrentMeetingsMountContext(loadContext)) return false;
255        if (throwOnError) throw error;
256        window.showToast?.('Failed to load meeting detail', 'error', { description: error.message });
257        // Fall back to list view
258        viewState = 'list';
259        await renderListView();
260        if (!isCurrentMeetingsMountContext(loadContext)) return false;
261        onNavigateCallback?.();
262        return false;
263    }
264}
265
266// ========================================
267// RENDERING — LIST VIEW
268// ========================================
269
270async function renderListView({ context = null } = {}) {
271    const container = document.getElementById('project-meetings-container');
272    if (!container || (context && !isCurrentMeetingsMountContext(context))) return;
273
274    if (meetings.length === 0) {
275        await renderTo(container, '#tpl-meetings-empty', {});
276        if (context && !isCurrentMeetingsMountContext(context)) return;
277        initEventHandlers();
278        return;
279    }
280
281    // Collapse follow-up chains into one row each (latest meeting is the head row).
282    const chains = groupMeetingsByChain(meetings);
283
284    await renderTo(container, '#tpl-meetings-list', { count: chains.length }, {
285        formatMeetingDate: formatMeetingDate
286    });
287    if (context && !isCurrentMeetingsMountContext(context)) return;
288
289    // Render meeting cards — one per chain
290    const cardsContainer = document.getElementById('meetings-cards-container');
291    if (cardsContainer) {
292        await renderTo(cardsContainer, '#tpl-meeting-card', chains, {
293            formatMeetingDate: formatMeetingDate
294        });
295        if (context && !isCurrentMeetingsMountContext(context)) return;
296    }
297
298    initEventHandlers();
299}
300
301/**
302 * Collapse the flat meeting list into one row per follow-up chain.
303 * Chain root = meetingParentId (follow-ups store the ORIGINAL meeting's id there — the
304 * chain is flat, every follow-up points straight at the root) or the meeting's own id for
305 * originals. The "head" row shown in the list is the LATEST meeting in the series (the
306 * current/active one); the rest become the expandable "Other meetings" list.
307 */
308function groupMeetingsByChain(list) {
309    const groups = new Map();
310    for (const m of list) {
311        const rootKey = (m.meetingParentId && m.meetingParentId > 0) ? m.meetingParentId : m.meetingId;
312        if (!groups.has(rootKey)) groups.set(rootKey, []);
313        groups.get(rootKey).push(m);
314    }
315
316    const heads = [];
317    for (const members of groups.values()) {
318        const sorted = members.slice().sort(compareMeetingRecencyDesc);
319        heads.push({
320            ...sorted[0],
321            _seriesCount: members.length,
322            _others: sorted.slice(1)
323        });
324    }
325
326    heads.sort(compareMeetingRecencyDesc);
327    return heads;
328}
329
330/** Newest-first comparator: startDate, then dateCreated, then meetingId. */
331function compareMeetingRecencyDesc(a, b) {
332    const diff = meetingTime(b) - meetingTime(a);
333    if (diff !== 0) return diff;
334    return (b.meetingId || 0) - (a.meetingId || 0);
335}
336
337function meetingTime(m) {
338    const raw = m.startDate || m.dateCreated;
339    const t = raw ? new Date(raw).getTime() : 0;
340    return Number.isNaN(t) ? 0 : t;
341}
342
343/**
344 * Expand/collapse the "Other meetings" list on a collapsed series card.
345 */
346function toggleSeries(toggleEl) {
347    const card = toggleEl.closest('.meeting-card');
348    const panel = card?.querySelector('.meeting-series-panel');
349    if (!panel) return;
350
351    const nowHidden = panel.classList.toggle('hidden');
352    toggleEl.setAttribute('aria-expanded', String(!nowHidden));
353
354    const chevron = toggleEl.querySelector('[data-series-chevron]');
355    if (chevron) chevron.classList.toggle('rotate-90', !nowHidden);
356}
357
358/**
359 * Open another meeting in the series from the "Other meetings" list.
360 */
361function openChainMeeting(rowEl) {
362    if (rowEl.dataset.offlineUnavailable === 'true') {
363        window.showToast?.('Not saved for offline', 'info', {
364            description: 'Open this Meeting while online to save it for offline viewing.',
365        });
366        return;
367    }
368    const meetingId = parseInt(rowEl.dataset.meetingId);
369    if (meetingId) transitionToMeetingDetail(meetingId);
370}
371
372function transitionToMeetingDetail(meetingId) {
373    invalidateMeetingScopedModals();
374    return loadMeetingDetail(meetingId);
375}
376
377// ========================================
378// RENDERING — DETAIL VIEW
379// ========================================
380
381async function renderDetailView({ context = null, meetingId = currentMeetingId } = {}) {
382    const container = document.getElementById('project-meetings-container');
383    if (!container || !currentMeeting || (context && !isCurrentMeetingContext(context.projectId, meetingId, context.pageSignal, context.generation))) return;
384
385    // Meeting number = 1-based position in the follow-up chain, matching how the backend numbers
386    // meetings (chain index + 1) for the "MTG # {n}" email subject. Derived from the chain already
387    // in the detail response so the header can show "Meeting {n}" (legacy parity), no extra field.
388    const chainIdx = (currentMeeting.chain || []).findIndex(c => c.isCurrent);
389    const meetingNumber = chainIdx >= 0 ? chainIdx + 1 : 1;
390
391    await renderTo(container, '#tpl-meeting-detail', { ...currentMeeting, meetingNumber }, {
392        formatMeetingDate: formatMeetingDate
393    });
394    if (context && !isCurrentMeetingContext(context.projectId, meetingId, context.pageSignal, context.generation)) return;
395
396    // Render topics, participants, chain breadcrumb
397    renderTopics();
398    renderParticipants();
399    renderChainBreadcrumb();
400    updateProgressBar();
401
402    initEventHandlers();
403    initSortables();
404}
405
406function renderTopics() {
407    const topicsContainer = document.getElementById('meeting-topics-container');
408    const emptyEl = document.getElementById('meeting-topics-empty');
409    if (!topicsContainer) return;
410
411    const topics = currentMeeting?.topics || [];
412
413    if (topics.length === 0) {
414        topicsContainer.innerHTML = '';
415        if (emptyEl) emptyEl.classList.remove('hidden');
416        return;
417    }
418
419    if (emptyEl) emptyEl.classList.add('hidden');
420
421    let html = '';
422    topics.forEach((topic, idx) => {
423        html += renderTopicHTML(topic, idx);
424    });
425    topicsContainer.innerHTML = html;
426
427    afterRender(topicsContainer);
428}
429
430/**
431 * Check if the current meeting is read-only (closed).
432 */
433function isMeetingReadOnly() {
434    return !!currentMeeting?.isClosed;
435}
436
437/**
438 * Precompute all data needed to render an action item via template.
439 */
440function prepareSubtopicData(sub, subIndex) {
441    const statusInfo = getActionItemStatus(sub);
442    const isComplete = !!sub.dateCompleted;
443    const commentCount = sub.commentCount || 0;
444    const fileCount = sub.fileCount || 0;
445
446    let dueBadge = null;
447    if (sub.dateDue) {
448        dueBadge = computeDueDateBadge(sub);
449    }
450
451    const assignments = (sub.assignments || []).map(a => ({
452        ...a,
453        initials: getInitials(a.name || '??'),
454        firstName: (a.name || '').split(' ')[0],
455        topicId: sub.meetingsTopicId
456    }));
457
458    // Completed-by footer (legacy parity): "Completed by {name} on {date}" on done items.
459    const completedText = isComplete && sub.completedByName
460        ? `Completed by ${sub.completedByName}${sub.dateCompleted ? ' on ' + formatMeetingDate(sub.dateCompleted) : ''}`
461        : null;
462
463    return {
464        ...sub,
465        subIndex,
466        statusInfo,
467        isComplete,
468        completedText,
469        isCommentExpanded: !!commentCache[sub.meetingsTopicId]?.expanded,
470        isFilesExpanded: !!fileCache[sub.meetingsTopicId]?.expanded,
471        commentCount,
472        fileCount,
473        hasComments: commentCount > 0,
474        hasFiles: fileCount > 0,
475        dueBadge,
476        assignments
477    };
478}
479
480/**
481 * Compute due date badge data (class and display text) for an action item.
482 */
483function computeDueDateBadge(item) {
484    // parseLocalDate: a date-only "YYYY-MM-DD" due date must be read as local midnight, not
485    // UTC midnight, or the day-count math (and badge) lands one day early west of UTC.
486    const due = parseLocalDate(item.dateDue);
487    const now = new Date();
488    now.setHours(0, 0, 0, 0);
489    due.setHours(0, 0, 0, 0);
490    const daysUntil = Math.round((due - now) / (1000 * 60 * 60 * 24));
491
492    if (item.dateCompleted) {
493        return { badgeClass: 'badge-st-secondary', displayText: formatShortDate(item.dateDue) };
494    }
495    if (daysUntil < 0) {
496        const daysOver = Math.abs(daysUntil);
497        return { badgeClass: 'badge-st-error', displayText: daysOver === 1 ? '1 day overdue' : `${daysOver}d overdue` };
498    }
499    if (daysUntil === 0) {
500        return { badgeClass: 'badge-st-error', displayText: 'Due today' };
501    }
502    if (daysUntil <= 6) {
503        return { badgeClass: 'badge-st-warning', displayText: daysUntil === 1 ? 'Due tomorrow' : `Due in ${daysUntil}d` };
504    }
505    return { badgeClass: 'badge-st-success', displayText: formatShortDate(item.dateDue) };
506}
507
508function renderTopicHTML(topic, idx) {
509    const readOnly = isMeetingReadOnly();
510    const isCollapsed = collapsedTopics.has(topic.meetingsTopicId);
511    const subtopics = (topic.subtopics || []).map((sub, si) => prepareSubtopicData(sub, si));
512    const completedCount = subtopics.filter(s => s.isComplete).length;
513    const totalCount = subtopics.length;
514    const summaryText = totalCount > 0 ? `${completedCount}/${totalCount}` : '';
515
516    return $.templates('#tpl-meeting-topic').render({
517        ...topic,
518        index: idx,
519        isCollapsed,
520        subtopics,
521        summaryText
522    }, { readOnly });
523}
524
525/**
526 * Get status info for an action item (Open / In Progress / Complete).
527 * "In Progress" = has a due date but not complete.
528 */
529function getActionItemStatus(item) {
530    if (item.dateCompleted) {
531        return {
532            label: 'Complete',
533            icon: 'check',
534            bgClass: 'bg-sb-success/20 hover:bg-sb-success/30',
535            iconClass: 'text-sb-success',
536            status: 'complete'
537        };
538    }
539    if (item.dateDue) {
540        return {
541            label: 'In Progress',
542            icon: 'loader',
543            bgClass: 'bg-st-primary/20 hover:bg-st-primary/30',
544            iconClass: 'text-st-primary',
545            status: 'in-progress'
546        };
547    }
548    return {
549        label: 'Open',
550        icon: 'circle',
551        bgClass: 'bg-surface-2/50 hover:bg-surface-2/80',
552        iconClass: 'text-theme-secondary',
553        status: 'open'
554    };
555}
556
557/**
558 * Re-render a single topic card (for structural changes like adding/removing subtopics).
559 */
560function rerenderTopic(topicIdx) {
561    const topics = currentMeeting?.topics || [];
562    const topic = topics[topicIdx];
563    if (!topic) return;
564
565    const existing = document.querySelector(`[data-topic-index="${topicIdx}"]`);
566    if (!existing) return;
567
568    const temp = document.createElement('div');
569    temp.innerHTML = renderTopicHTML(topic, topicIdx);
570    const newCard = temp.firstElementChild;
571
572    existing.replaceWith(newCard);
573    afterRender(newCard);
574
575    // Re-init SortableJS on the new subtopics container
576    initSubtopicSortable(newCard.querySelector('.meeting-subtopics-container'));
577}
578
579// ========================================
580// PROGRESS BAR
581// ========================================
582
583function updateProgressBar() {
584    const progressBar = document.getElementById('meeting-progress-bar');
585    const progressFill = document.getElementById('meeting-progress-fill');
586    const progressText = document.getElementById('meeting-progress-text');
587    if (!progressBar || !progressFill || !progressText) return;
588
589    const topics = currentMeeting?.topics || [];
590    let totalItems = 0;
591    let completedItems = 0;
592
593    for (const topic of topics) {
594        for (const sub of (topic.subtopics || [])) {
595            totalItems++;
596            if (sub.dateCompleted) completedItems++;
597        }
598    }
599
600    if (totalItems === 0) {
601        progressBar.classList.add('hidden');
602        return;
603    }
604
605    progressBar.classList.remove('hidden');
606    const pct = Math.round((completedItems / totalItems) * 100);
607    progressFill.style.width = `${pct}%`;
608    progressText.textContent = `${completedItems}/${totalItems} items complete (${pct}%)`;
609
610    // Color the bar based on completion
611    progressFill.className = pct === 100 ? 'progress-bar-st-success' : 'progress-bar-st';
612}
613
614// ========================================
615// SORTABLEJS
616// ========================================
617
618function initSortables() {
619    destroySortables();
620
621    // Don't init drag-drop when meeting is read-only
622    if (isMeetingReadOnly()) return;
623
624    // Sortable for top-level topics
625    const topicsContainer = document.getElementById('meeting-topics-container');
626    if (topicsContainer && typeof Sortable !== 'undefined') {
627        const topicSortable = Sortable.create(topicsContainer, {
628            animation: 150,
629            handle: '.meeting-drag-handle',
630            ghostClass: 'opacity-30',
631            dragClass: 'shadow-sb-lg',
632            onEnd: handleTopicDragEnd
633        });
634        sortableInstances.push(topicSortable);
635    }
636
637    // Sortable for each subtopics container
638    document.querySelectorAll('.meeting-subtopics-container').forEach(el => {
639        initSubtopicSortable(el);
640    });
641}
642
643function initSubtopicSortable(container) {
644    if (!container || typeof Sortable === 'undefined') return;
645
646    const sortable = Sortable.create(container, {
647        animation: 150,
648        handle: '.subtopic-drag-handle',
649        ghostClass: 'opacity-30',
650        group: 'subtopics',
651        onEnd: handleSubtopicDragEnd
652    });
653    sortableInstances.push(sortable);
654}
655
656function destroySortables() {
657    sortableInstances.forEach(s => s.destroy());
658    sortableInstances = [];
659}
660
661function handleTopicDragEnd(evt) {
662    if (evt.oldIndex === evt.newIndex) return;
663
664    const topics = currentMeeting?.topics || [];
665    const [moved] = topics.splice(evt.oldIndex, 1);
666    topics.splice(evt.newIndex, 0, moved);
667
668    // Update data-topic-index attributes
669    document.querySelectorAll('.meeting-topic-card').forEach((card, i) => {
670        card.dataset.topicIndex = i;
671    });
672
673    updateTopicSortOrders();
674    saveTopicOrder();
675    updateProgressBar();
676}
677
678function handleSubtopicDragEnd(evt) {
679    if (evt.oldIndex === evt.newIndex && evt.from === evt.to) return;
680
681    const topics = currentMeeting?.topics || [];
682
683    // Find source and target topic by parent container
684    const fromParentId = parseInt(evt.from.dataset.parentTopicId);
685    const toParentId = parseInt(evt.to.dataset.parentTopicId);
686
687    const fromTopic = topics.find(t => t.meetingsTopicId === fromParentId);
688    const toTopic = topics.find(t => t.meetingsTopicId === toParentId);
689    if (!fromTopic || !toTopic) return;
690
691    // Move the subtopic in our data
692    const [moved] = fromTopic.subtopics.splice(evt.oldIndex, 1);
693    moved.parentMeetingsTopicId = toParentId;
694    toTopic.subtopics.splice(evt.newIndex, 0, moved);
695
696    // Update data-subtopic-index attributes in both containers
697    evt.from.querySelectorAll('.meeting-subtopic-row').forEach((row, i) => {
698        row.dataset.subtopicIndex = i;
699    });
700    evt.to.querySelectorAll('.meeting-subtopic-row').forEach((row, i) => {
701        row.dataset.subtopicIndex = i;
702    });
703
704    // Save: update the moved subtopic's parent + all sort orders
705    saveSubtopicMove(moved, fromTopic, toTopic);
706}
707
708async function saveSubtopicMove(movedItem, fromTopic, toTopic) {
709    try {
710        // Update the moved item's parent reference
711        await projectMeetingsApi.saveTopic(currentProjectId, currentMeetingId, {
712            id: movedItem.meetingsTopicId,
713            parentMeetingsTopicId: movedItem.parentMeetingsTopicId,
714            topicTitle: movedItem.topicTitle,
715            sortOrder: 0 // Will be overwritten below
716        });
717
718        // Save sort orders for affected containers
719        const saveOrders = (topic) => {
720            (topic.subtopics || []).forEach((s, i) => { s.sortOrder = i + 1; });
721            return topic.subtopics.map(s => ({ meetingsTopicId: s.meetingsTopicId, sortOrder: s.sortOrder }));
722        };
723
724        const allItems = [...saveOrders(toTopic)];
725        if (fromTopic.meetingsTopicId !== toTopic.meetingsTopicId) {
726            allItems.push(...saveOrders(fromTopic));
727        }
728
729        if (allItems.length > 0) {
730            await projectMeetingsApi.reorderTopics(currentProjectId, currentMeetingId, { topics: allItems });
731        }
732    } catch (error) {
733        if (error.name === 'AbortError') return;
734        window.showToast?.('Failed to save reorder', 'error', { description: error.message });
735    }
736}
737
738// ========================================
739// EVENT HANDLING
740// ========================================
741
742function initEventHandlers() {
743    const container = document.getElementById('project-meetings-container');
744    if (!container) return;
745
746    const opts = signal ? { signal } : {};
747
748    container.addEventListener('click', handleClick, opts);
749    container.addEventListener('input', handleInput, opts);
750    container.addEventListener('keydown', handleKeydown, opts);
751    container.addEventListener('change', handleFileInputChange, opts);
752}
753
754function handleClick(e) {
755    const actionEl = e.target.closest('[data-action]');
756    if (!actionEl) {
757        // Check for meeting card click (no data-action, just .meeting-card)
758        // Skip if click was on an interactive child (input, button, link, drag handle)
759        if (e.target.closest('a[href], button, input, .meeting-drag-handle')) return;
760        // Clicks inside the collapsed series section (toggle + "Other meetings" panel) must
761        // not open the head meeting — those rows carry their own data-action.
762        if (e.target.closest('.meeting-series-section')) return;
763        const card = e.target.closest('.meeting-card');
764        if (card && viewState === 'list') {
765            if (card.dataset.offlineUnavailable === 'true') {
766                window.showToast?.('Not saved for offline', 'info', {
767                    description: 'Open this Meeting while online to save it for offline viewing.',
768                });
769                return;
770            }
771            const meetingId = parseInt(card.dataset.meetingId);
772            if (meetingId) transitionToMeetingDetail(meetingId);
773        }
774        return;
775    }
776
777    const action = actionEl.dataset.action;
778    const topicCard = e.target.closest('.meeting-topic-card');
779    const topicIdx = topicCard ? parseInt(topicCard.dataset.topicIndex) : -1;
780    const subtopicRow = e.target.closest('.meeting-subtopic-row');
781    const subtopicIdx = subtopicRow ? parseInt(subtopicRow.dataset.subtopicIndex) : -1;
782
783    switch (action) {
784        // List view actions
785        case 'create-meeting': showMeetingModal(); break;
786        case 'toggle-series': toggleSeries(actionEl); break;
787        case 'open-chain-meeting': openChainMeeting(actionEl); break;
788
789        // Detail view actions
790        case 'back-to-list': backToList(); break;
791        case 'edit-meeting': showMeetingModal(currentMeeting); break;
792        case 'delete-meeting': handleDeleteMeeting(); break;
793        case 'add-topic': addTopic(); break;
794        case 'expand-all': expandAllTopics(); break;
795        case 'collapse-all': collapseAllTopics(); break;
796
797        // Topic actions
798        case 'toggle-topic': toggleTopic(topicIdx, topicCard); break;
799        case 'delete-topic': handleDeleteTopic(topicIdx); break;
800        case 'add-subtopic': addSubtopic(topicIdx); break;
801        case 'delete-subtopic': handleDeleteSubtopic(topicIdx, subtopicIdx); break;
802
803        // Action item actions
804        case 'cycle-status': cycleActionItemStatus(topicIdx, subtopicIdx); break;
805        case 'set-due-date': showDueDatePicker(topicIdx, subtopicIdx, actionEl); break;
806
807        // Comment actions
808        case 'toggle-comments': toggleCommentThread(subtopicRow); break;
809        case 'send-comment': sendComment(actionEl); break;
810        case 'delete-comment': deleteComment(actionEl, topicIdx, subtopicIdx); break;
811
812        // Participant actions
813        case 'add-participant': showParticipantPicker(); break;
814        case 'remove-participant': removeParticipant(actionEl); break;
815
816        // Assignment actions
817        case 'assign-topic': showAssignmentPicker(topicIdx, subtopicIdx, actionEl); break;
818        case 'unassign-topic': unassignTopic(actionEl, topicIdx, subtopicIdx); break;
819
820        // Meeting lifecycle (Phase 5)
821        case 'close-meeting': handleCloseMeeting(); break;
822        case 'reopen-meeting': handleReopenMeeting(); break;
823        case 'follow-up-meeting': showFollowUpModal(); break;
824        case 'create-followup': handleCreateFollowUp(); break;
825        case 'chain-meeting': navigateChainMeeting(actionEl); break;
826
827        // File attachment actions (Phase 6)
828        case 'toggle-files': toggleFileSection(subtopicRow); break;
829        case 'delete-file': deleteFile(actionEl, topicIdx, subtopicIdx); break;
830        case 'view-file': viewFile(actionEl); break;
831
832        // Print & Email (Phase 7)
833        case 'print-meeting': handlePrintMeeting(); break;
834        case 'email-meeting': handleEmailMeeting(); break;
835    }
836}
837
838function handleInput(e) {
839    const input = e.target.closest('.meeting-input');
840    if (!input) return;
841
842    const field = input.dataset.field;
843    const topicId = parseInt(input.dataset.topicId);
844    const parentId = parseInt(input.dataset.parentId);
845
846    if (field === 'topicTitle' && topicId) {
847        // Find the topic/subtopic in our data and update
848        const topics = currentMeeting?.topics || [];
849
850        if (parentId === 0) {
851            // Root topic
852            const topic = topics.find(t => t.meetingsTopicId === topicId);
853            if (topic) {
854                topic.topicTitle = input.value;
855                debouncedSaveTopic(topicId);
856            }
857        } else {
858            // Subtopic
859            for (const topic of topics) {
860                const sub = (topic.subtopics || []).find(s => s.meetingsTopicId === topicId);
861                if (sub) {
862                    sub.topicTitle = input.value;
863                    debouncedSaveTopic(topicId);
864                    break;
865                }
866            }
867        }
868    }
869}
870
871function handleKeydown(e) {
872    const textarea = e.target.closest('.comment-input');
873    if (!textarea) return;
874
875    // Enter to send (without Shift), Shift+Enter for newline
876    if (e.key === 'Enter' && !e.shiftKey) {
877        e.preventDefault();
878        const topicId = parseInt(textarea.dataset.actionTopicId);
879        if (topicId) {
880            const sendBtn = textarea.closest('.comment-thread-container')
881                ?.querySelector(`[data-action="send-comment"][data-comment-for-topic="${topicId}"]`);
882            if (sendBtn) sendBtn.click();
883        }
884    }
885}
886
887// ========================================
888// COMMENT THREADS
889// ========================================
890
891/**
892 * Toggle the comment thread for an action item.
893 * Lazy-loads comments on first expand.
894 */
895async function toggleCommentThread(subtopicRow) {
896    if (!subtopicRow) return;
897    const topicId = parseInt(subtopicRow.dataset.topicId);
898    if (!topicId) return;
899
900    const threadContainer = subtopicRow.querySelector(`[data-comment-topic-id="${topicId}"]`);
901    if (!threadContainer) return;
902
903    const isHidden = threadContainer.classList.contains('hidden');
904
905    if (isHidden) {
906        // Expand
907        threadContainer.classList.remove('hidden');
908        if (!commentCache[topicId]) {
909            commentCache[topicId] = { loaded: false, comments: [], expanded: true };
910        }
911        commentCache[topicId].expanded = true;
912
913        // Lazy-load on first expand
914        if (!commentCache[topicId].loaded) {
915            await loadCommentsForTopic(topicId, threadContainer);
916        } else {
917            renderCommentThread(topicId, threadContainer);
918        }
919
920        // Focus the textarea
921        const textarea = threadContainer.querySelector('.comment-input');
922        if (textarea) textarea.focus();
923    } else {
924        // Collapse
925        threadContainer.classList.add('hidden');
926        if (commentCache[topicId]) {
927            commentCache[topicId].expanded = false;
928        }
929    }
930}
931
932/**
933 * Fetch comments from API and render them.
934 */
935async function loadCommentsForTopic(topicId, threadContainer) {
936    const listEl = threadContainer.querySelector('.comment-thread-list');
937    if (!listEl) return;
938
939    listEl.innerHTML = $.templates('#tpl-meeting-comment-loading').render({});
940
941    try {
942        const response = await projectMeetingsApi.getTopicComments(currentProjectId, currentMeetingId, topicId);
943        const comments = response?.comments || [];
944
945        if (!commentCache[topicId]) {
946            commentCache[topicId] = { loaded: false, comments: [], expanded: true };
947        }
948        commentCache[topicId].loaded = true;
949        commentCache[topicId].comments = comments;
950
951        renderCommentThread(topicId, threadContainer);
952    } catch (error) {
953        if (error.name === 'AbortError') return;
954        listEl.innerHTML = $.templates('#tpl-meeting-comment-error').render({});
955    }
956}
957
958/**
959 * Render cached comments into the thread list.
960 */
961function renderCommentThread(topicId, threadContainer) {
962    const listEl = threadContainer.querySelector('.comment-thread-list');
963    if (!listEl) return;
964
965    const comments = commentCache[topicId]?.comments || [];
966
967    if (comments.length === 0) {
968        listEl.innerHTML = $.templates('#tpl-meeting-comment-empty').render({});
969        return;
970    }
971
972    const data = comments.map(c => ({ ...c, timeAgoText: timeAgo(c.dateCreated) }));
973    listEl.innerHTML = $.templates('#tpl-meeting-single-comment').render(data);
974
975    afterRender(listEl);
976}
977
978/**
979 * Send a new comment for an action item.
980 */
981async function sendComment(actionEl) {
982    const topicId = parseInt(actionEl.dataset.commentForTopic);
983    if (!topicId) return;
984
985    const threadContainer = document.querySelector(`[data-comment-topic-id="${topicId}"]`);
986    if (!threadContainer) return;
987
988    const textarea = threadContainer.querySelector('.comment-input');
989    const commentText = textarea?.value?.trim();
990    if (!commentText) return;
991
992    // Clear the textarea immediately (optimistic)
993    textarea.value = '';
994    autoResizeTextarea(textarea);
995
996    // Find the action item data to update commentCount
997    const { topic: parentTopic, subtopic } = findSubtopicByTopicId(topicId);
998
999    try {
1000        const saved = await projectMeetingsApi.addComment(currentProjectId, currentMeetingId, topicId, {
1001            comment: commentText
1002        });
1003
1004        if (saved) {
1005            // Add to cache
1006            if (!commentCache[topicId]) {
1007                commentCache[topicId] = { loaded: true, comments: [], expanded: true };
1008            }
1009            commentCache[topicId].comments.push(saved);
1010
1011            // Update local comment count
1012            if (subtopic) subtopic.commentCount = (subtopic.commentCount || 0) + 1;
1013
1014            // Update the comment count label in the row
1015            updateCommentCountLabel(topicId, subtopic?.commentCount || 0);
1016
1017            renderCommentThread(topicId, threadContainer);
1018        }
1019    } catch (error) {
1020        if (error.name === 'AbortError') return;
1021        window.showToast?.('Failed to add comment', 'error', { description: error.message });
1022        // Restore the text
1023        textarea.value = commentText;
1024    }
1025}
1026
1027/**
1028 * Delete a comment.
1029 */
1030async function deleteComment(actionEl, topicIdx, subtopicIdx) {
1031    const commentId = parseInt(actionEl.dataset.deleteCommentId);
1032    if (!commentId) return;
1033
1034    const threadContainer = actionEl.closest('.comment-thread-container');
1035    const topicId = threadContainer ? parseInt(threadContainer.dataset.commentTopicId) : null;
1036    const projectId = currentProjectId;
1037    const meetingId = currentMeetingId;
1038    const pageSignal = signal;
1039    if (!projectId || !meetingId || !topicId) return;
1040
1041    const committed = await confirm({
1042        title: 'Delete this comment?',
1043        message: 'This cannot be undone.',
1044        icon: 'trash-2',
1045        confirmText: 'Delete',
1046        confirmClass: 'btn-st-error',
1047        signal: dialogAbort?.signal,
1048        async onConfirm() {
1049            await projectMeetingsApi.deleteComment(projectId, meetingId, topicId, commentId);
1050        }
1051    });
1052    if (!committed || !isCurrentMeetingContext(projectId, meetingId, pageSignal)) return;
1053
1054    try {
1055        // Remove from cache only after the accepted mutation has closed the dialog.
1056            if (topicId && commentCache[topicId]) {
1057                commentCache[topicId].comments = commentCache[topicId].comments.filter(
1058                    c => c.meetingsTopicCommentId !== commentId
1059                );
1060            }
1061
1062            // Update local comment count
1063            const { subtopic } = findSubtopicByTopicId(topicId);
1064            if (subtopic) {
1065                subtopic.commentCount = Math.max(0, (subtopic.commentCount || 0) - 1);
1066                updateCommentCountLabel(topicId, subtopic.commentCount);
1067            }
1068
1069            // Re-render thread
1070            if (threadContainer && topicId) {
1071                renderCommentThread(topicId, threadContainer);
1072            }
1073    } catch (error) {
1074        if (error?.name === 'AbortError' || pageSignal?.aborted) return;
1075        console.error('[ProjectMeetings] Comment deleted but refresh failed:', error);
1076        window.showToast?.('Comment deleted, but the thread did not refresh', 'error', {
1077            description: 'Reload the meeting to see the committed change.',
1078        });
1079    }
1080}
1081
1082/**
1083 * Update the comment count label in the action item row without full re-render.
1084 */
1085function updateCommentCountLabel(topicId, count) {
1086    const row = document.querySelector(`.meeting-subtopic-row[data-topic-id="${topicId}"]`);
1087    if (!row) return;
1088
1089    const label = row.querySelector('.comment-count-label');
1090    if (label) {
1091        label.textContent = count > 0 ? count : '';
1092    }
1093
1094    // Update the button styling
1095    const btn = row.querySelector('[data-action="toggle-comments"]');
1096    if (btn) {
1097        if (count > 0) {
1098            btn.classList.remove('opacity-0', 'group-hover:opacity-100');
1099            btn.classList.add('text-st-primary', 'hover:text-st-primary/80');
1100            btn.classList.remove('text-theme-secondary');
1101        }
1102    }
1103}
1104
1105/**
1106 * Find a subtopic (action item) by its meetingsTopicId across all topics.
1107 */
1108function findSubtopicByTopicId(topicId) {
1109    const topics = currentMeeting?.topics || [];
1110    for (const topic of topics) {
1111        for (const sub of (topic.subtopics || [])) {
1112            if (sub.meetingsTopicId === topicId) {
1113                return { topic, subtopic: sub };
1114            }
1115        }
1116    }
1117    return { topic: null, subtopic: null };
1118}
1119
1120/**
1121 * Auto-resize textarea to fit content.
1122 */
1123function autoResizeTextarea(textarea) {
1124    if (!textarea) return;
1125    textarea.style.height = 'auto';
1126    textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
1127}
1128
1129
1130// ========================================
1131// PARTICIPANTS
1132// ========================================
1133
1134/**
1135 * Render participant avatar chips in the participants section.
1136 */
1137function renderParticipants() {
1138    const container = document.getElementById('meeting-participants-container');
1139    const badge = document.getElementById('participant-count-badge');
1140    if (!container) return;
1141
1142    const participants = currentMeeting?.participants || [];
1143
1144    if (badge) {
1145        badge.textContent = participants.length > 0 ? `(${participants.length})` : '';
1146    }
1147
1148    if (participants.length === 0) {
1149        container.innerHTML = $.templates('#tpl-meeting-participants-empty').render({});
1150        return;
1151    }
1152
1153    const readOnly = isMeetingReadOnly();
1154    const groups = [
1155        { label: 'In-Person', icon: 'building', items: participants.filter(p => p.attendanceType === 'InPerson') },
1156        { label: 'Virtual', icon: 'monitor', items: participants.filter(p => p.attendanceType === 'Virtual') },
1157        { label: 'Notified', icon: 'bell', items: participants.filter(p => p.attendanceType === 'Notified') }
1158    ].filter(g => g.items.length > 0);
1159
1160    // Precompute initials
1161    groups.forEach(g => g.items.forEach(p => {
1162        p.initials = getInitials(p.name || '??');
1163    }));
1164
1165    container.innerHTML = $.templates('#tpl-meeting-participant-group').render(groups, { readOnly });
1166    afterRender(container);
1167}
1168
1169/**
1170 * Show participant picker modal.
1171 * Fetches project team and shows checkboxes, filtered by who's already in the meeting.
1172 */
1173/**
1174 * Build participant picker options from the project team's email-options endpoint.
1175 * CRITICAL: uses getEmailOptions (NOT getTeam). Participants are stored by the ACTUAL
1176 * becc_pm_users.UserID (members) / CompanyContactID (contacts). getTeam returns join-table
1177 * row ids (ProjectMemberID / ProjectContactID) which don't resolve to a user/contact, so
1178 * names come back "Unknown" and the already-added filter never matches.
1179 */
1180function buildParticipantOptions(emailOptions) {
1181    const mk = (o, type) => {
1182        const name = (o.name || '').trim();
1183        return {
1184            id: o.id,
1185            participantType: type,
1186            name,
1187            subtitle: type === 'C' ? (o.companyName || 'Contact') : (o.companyName || o.email || ''),
1188            searchText: `${name} ${o.email || ''} ${o.companyName || ''}`.toLowerCase(),
1189            initials: getInitials(name || '??')
1190        };
1191    };
1192    return [
1193        ...(emailOptions?.users || []).map(u => mk(u, 'M')),
1194        ...(emailOptions?.contacts || []).map(c => mk(c, 'C'))
1195    ];
1196}
1197
1198async function showParticipantPicker() {
1199    const projectId = currentProjectId;
1200    const meetingId = currentMeetingId;
1201    const pageSignal = signal;
1202    const generation = meetingsGeneration;
1203    if (!projectId || !meetingId || !isCurrentMeetingContext(projectId, meetingId, pageSignal, generation)) return;
1204    const owner = createMeetingModalOwner('participants', pageSignal);
1205    const context = { projectId, meetingId, pageSignal, generation, owner };
1206    const isCurrent = () => participantPickerOwner === owner
1207        && !owner.controller.signal.aborted
1208        && isCurrentMeetingContext(projectId, meetingId, pageSignal, generation);
1209
1210    try {
1211        // Project team with ACTUAL user/contact ids (see buildParticipantOptions).
1212        const emailOptions = await projectTeamApi.getEmailOptions(projectId, { signal: owner.controller.signal });
1213        if (!isCurrent()) return;
1214        const allOptions = buildParticipantOptions(emailOptions);
1215
1216        // Build existing participant set for filtering
1217        const existingSet = new Set();
1218        (currentMeeting?.participants || []).forEach(p => {
1219            existingSet.add(`${p.participantType}-${p.userId}`);
1220        });
1221
1222        // Filter out already-added participants
1223        const items = allOptions.filter(m => !existingSet.has(`${m.participantType}-${m.id}`));
1224
1225        if (items.length === 0) {
1226            invalidateMeetingModalOwner('participants');
1227            window.showToast?.('All team members are already participants', 'info');
1228            return;
1229        }
1230
1231        // Show modal using shared utility
1232        const modal = await showModal(
1233            '#tpl-meeting-participant-picker',
1234            { items: items.map(item => ({ ...item, initials: getInitials(item.name || '??') })) },
1235            {
1236                signal: owner.controller.signal,
1237                onClose: () => releaseMeetingModalOwner('participants', owner),
1238            }
1239        );
1240        if (!isCurrent()) {
1241            modal.close();
1242            return;
1243        }
1244        owner.modal = modal;
1245        const overlay = modal.element;
1246
1247        // Wire events
1248        wireParticipantPickerEvents(overlay, modal.close, items, context);
1249
1250    } catch (error) {
1251        if (error.name === 'AbortError' || !isCurrent()) return;
1252        invalidateMeetingModalOwner('participants');
1253        window.showToast?.('Failed to load team members', 'error', { description: error.message });
1254    }
1255}
1256
1257function wireParticipantPickerEvents(overlay, closeModal, items, context) {
1258    const { projectId, meetingId, pageSignal, generation, owner } = context;
1259    const isCurrent = () => participantPickerOwner === owner
1260        && !owner.controller.signal.aborted
1261        && isCurrentMeetingContext(projectId, meetingId, pageSignal, generation);
1262    // showModal() already handles backdrop click, dismiss buttons, and body scroll lock
1263
1264    // Search
1265    overlay.querySelector('.picker-search')?.addEventListener('input', (e) => {
1266        const term = e.target.value.toLowerCase();
1267        overlay.querySelectorAll('.picker-item').forEach(item => {
1268            const search = item.dataset.search || '';
1269            item.style.display = search.includes(term) ? '' : 'none';
1270        });
1271    });
1272
1273    // Select all
1274    const selectAllCb = overlay.querySelector('.picker-select-all');
1275    selectAllCb?.addEventListener('change', () => {
1276        const visible = overlay.querySelectorAll('.picker-item:not([style*="display: none"]) .picker-checkbox');
1277        visible.forEach(cb => { cb.checked = selectAllCb.checked; });
1278        updatePickerCount(overlay);
1279    });
1280
1281    // Individual checkbox
1282    overlay.querySelector('.picker-list')?.addEventListener('change', () => updatePickerCount(overlay));
1283
1284    // Segmented attendance-type buttons
1285    const opts = { signal: owner.controller.signal };
1286    overlay.querySelectorAll('.seg-btn-st').forEach(btn => {
1287        btn.addEventListener('click', () => {
1288            overlay.querySelectorAll('.seg-btn-st').forEach(b => b.classList.remove('active'));
1289            btn.classList.add('active');
1290            const hidden = overlay.querySelector('#attendance-type-value');
1291            if (hidden) hidden.value = btn.dataset.value;
1292        }, opts);
1293    });
1294
1295    // Submit
1296    overlay.querySelector('.picker-submit')?.addEventListener('click', async () => {
1297        const submitBtn = overlay.querySelector('.picker-submit');
1298        const attendanceType = overlay.querySelector('#attendance-type-value')?.value || 'InPerson';
1299        const selected = Array.from(overlay.querySelectorAll('.picker-checkbox:checked')).map(cb => {
1300            const item = cb.closest('.picker-item');
1301            return { id: parseInt(cb.value), participantType: item.dataset.type };
1302        });
1303
1304        if (selected.length === 0) {
1305            window.showToast?.('Select at least one participant', 'info');
1306            return;
1307        }
1308
1309        setLoading(submitBtn, true, 'Adding...');
1310
1311        try {
1312            for (const s of selected) {
1313                if (!isCurrent()) return;
1314                const saved = await projectMeetingsApi.addParticipant(projectId, meetingId, {
1315                    userId: s.id,
1316                    participantType: s.participantType,
1317                    attendanceType: attendanceType
1318                });
1319                if (!isCurrent()) return;
1320                if (saved) {
1321                    if (!currentMeeting.participants) currentMeeting.participants = [];
1322                    currentMeeting.participants.push(saved);
1323                }
1324            }
1325
1326            renderParticipants();
1327            closeModal();
1328            window.showToast?.(`Added ${selected.length} participant${selected.length > 1 ? 's' : ''}`, 'success');
1329        } catch (error) {
1330            if (error.name === 'AbortError' || !isCurrent()) return;
1331            window.showToast?.('Failed to add participants', 'error', { description: error.message });
1332            setLoading(submitBtn, false);
1333        }
1334    });
1335}
1336
1337function updatePickerCount(overlay) {
1338    const checked = overlay.querySelectorAll('.picker-checkbox:checked').length;
1339    const countEl = overlay.querySelector('.picker-selected-count');
1340    if (countEl) countEl.textContent = `${checked} selected`;
1341}
1342
1343/**
1344 * Remove a participant from the meeting.
1345 */
1346async function removeParticipant(actionEl) {
1347    const participantId = parseInt(actionEl.dataset.participantId);
1348    const attendanceType = actionEl.dataset.attendanceType || 'InPerson';
1349    if (!participantId) return;
1350
1351    try {
1352        await projectMeetingsApi.removeParticipant(currentProjectId, currentMeetingId, participantId, attendanceType);
1353
1354        // Remove from local data
1355        if (currentMeeting?.participants) {
1356            currentMeeting.participants = currentMeeting.participants.filter(
1357                p => !(p.id === participantId && p.attendanceType === attendanceType)
1358            );
1359        }
1360
1361        renderParticipants();
1362    } catch (error) {
1363        if (error.name === 'AbortError') return;
1364        window.showToast?.('Failed to remove participant', 'error', { description: error.message });
1365    }
1366}
1367
1368// ========================================
1369// TOPIC ASSIGNMENTS
1370// ========================================
1371
1372/**
1373 * Show assignment picker dropdown for an action item.
1374 * Sources from meeting participants.
1375 */
1376function showAssignmentPicker(topicIdx, subtopicIdx, actionEl) {
1377    const topics = currentMeeting?.topics || [];
1378    const sub = topics[topicIdx]?.subtopics?.[subtopicIdx];
1379    if (!sub) return;
1380
1381    // Get meeting participants as assignment candidates
1382    const participants = currentMeeting?.participants || [];
1383    if (participants.length === 0) {
1384        window.showToast?.('Add participants to the meeting first', 'info');
1385        return;
1386    }
1387
1388    // Filter out already assigned
1389    const existingSet = new Set((sub.assignments || []).map(a => `${a.assignType}-${a.userId}`));
1390    const candidates = participants.filter(p => !existingSet.has(`${p.participantType}-${p.userId}`));
1391
1392    if (candidates.length === 0) {
1393        window.showToast?.('All participants are already assigned', 'info');
1394        return;
1395    }
1396
1397    // Remove any existing popover
1398    dismissActivePopovers();
1399
1400    // Build mini-picker popover
1401    const rect = actionEl.getBoundingClientRect();
1402    const popover = document.createElement('div');
1403    popover.className = 'assign-popover fixed bg-theme-primary border border-theme rounded-lg shadow-sb-lg z-50 w-56 max-h-48 overflow-y-auto custom-scrollbar animate-in fade-in zoom-in duration-150';
1404    popover.style.top = `${rect.bottom + 4}px`;
1405    popover.style.left = `${Math.min(rect.left, window.innerWidth - 240)}px`;
1406
1407    const candidateData = candidates.map(p => ({
1408        ...p,
1409        initials: getInitials(p.name || '??')
1410    }));
1411    popover.innerHTML = `<div class="py-1">${$.templates('#tpl-meeting-assignment-picker-item').render(candidateData)}</div>`;
1412    document.body.appendChild(popover);
1413
1414    // Handle click on a candidate
1415    popover.addEventListener('click', async (e) => {
1416        const btn = e.target.closest('[data-assign-user-id]');
1417        if (!btn) return;
1418
1419        const userId = parseInt(btn.dataset.assignUserId);
1420        const assignType = btn.dataset.assignParticipantType || 'M';
1421        popover.remove();
1422
1423        try {
1424            const saved = await projectMeetingsApi.assignTopic(
1425                currentProjectId, currentMeetingId, sub.meetingsTopicId,
1426                { userId, assignType }
1427            );
1428
1429            if (saved) {
1430                if (!sub.assignments) sub.assignments = [];
1431                sub.assignments.push(saved);
1432                rerenderTopic(topicIdx);
1433            }
1434        } catch (error) {
1435            if (error.name === 'AbortError') return;
1436            window.showToast?.('Failed to assign', 'error', { description: error.message });
1437        }
1438    });
1439
1440    // Close on click-outside or Escape — tracked for cleanup on destroy
1441    registerPopoverDismiss(popover, actionEl);
1442}
1443
1444/**
1445 * Remove an assignment from a topic.
1446 */
1447async function unassignTopic(actionEl, topicIdx, subtopicIdx) {
1448    const assignmentId = parseInt(actionEl.dataset.assignmentId);
1449    const assignType = actionEl.dataset.assignType || 'M';
1450    const topicId = parseInt(actionEl.dataset.assignTopicId);
1451    if (!assignmentId || !topicId) return;
1452
1453    try {
1454        await projectMeetingsApi.unassignTopic(currentProjectId, currentMeetingId, topicId, assignmentId, assignType);
1455
1456        // Remove from local data
1457        const { subtopic } = findSubtopicByTopicId(topicId);
1458        if (subtopic?.assignments) {
1459            subtopic.assignments = subtopic.assignments.filter(a => a.id !== assignmentId);
1460        }
1461
1462        // Find the topic index for re-render
1463        const topics = currentMeeting?.topics || [];
1464        const tIdx = topics.findIndex(t => (t.subtopics || []).some(s => s.meetingsTopicId === topicId));
1465        if (tIdx >= 0) rerenderTopic(tIdx);
1466    } catch (error) {
1467        if (error.name === 'AbortError') return;
1468        window.showToast?.('Failed to remove assignment', 'error', { description: error.message });
1469    }
1470}
1471
1472// ========================================
1473// ACTION ITEM STATUS CYCLING
1474// ========================================
1475
1476/**
1477 * Cycle an action item through statuses:
1478 * Open (no due date, not complete) → In Progress (set due date to +7 days) → Complete → Open (reopen)
1479 */
1480async function cycleActionItemStatus(topicIdx, subtopicIdx) {
1481    const topics = currentMeeting?.topics || [];
1482    const topic = topics[topicIdx];
1483    const sub = topic?.subtopics?.[subtopicIdx];
1484    if (!sub) return;
1485
1486    const status = getActionItemStatus(sub);
1487
1488    try {
1489        if (status.status === 'open') {
1490            // Open → In Progress: set due date to 7 days from now
1491            const dueDate = new Date();
1492            dueDate.setDate(dueDate.getDate() + 7);
1493            const dueDateStr = dueDate.toISOString().split('T')[0];
1494
1495            // Optimistic update
1496            sub.dateDue = dueDateStr;
1497            rerenderTopic(topicIdx);
1498            updateProgressBar();
1499
1500            await projectMeetingsApi.setTopicDueDate(currentProjectId, currentMeetingId, sub.meetingsTopicId, {
1501                dueDate: dueDateStr
1502            });
1503        } else if (status.status === 'in-progress') {
1504            // In Progress → Complete
1505            sub.dateCompleted = new Date().toISOString();
1506            rerenderTopic(topicIdx);
1507            updateProgressBar();
1508
1509            await projectMeetingsApi.completeTopic(currentProjectId, currentMeetingId, sub.meetingsTopicId, {
1510                isComplete: true
1511            });
1512        } else {
1513            // Complete → Open: reopen (clear completion AND due date)
1514            sub.dateCompleted = null;
1515            sub.completedByName = null;
1516            sub.dateDue = null;
1517            rerenderTopic(topicIdx);
1518            updateProgressBar();
1519
1520            await projectMeetingsApi.completeTopic(currentProjectId, currentMeetingId, sub.meetingsTopicId, {
1521                isComplete: false
1522            });
1523            await projectMeetingsApi.setTopicDueDate(currentProjectId, currentMeetingId, sub.meetingsTopicId, {
1524                dueDate: null
1525            });
1526        }
1527    } catch (error) {
1528        if (error.name === 'AbortError') return;
1529        window.showToast?.('Failed to update status', 'error', { description: error.message });
1530        // Reload to get correct state
1531        await loadMeetingDetail(currentMeetingId);
1532    }
1533}
1534
1535// ========================================
1536// DUE DATE PICKER
1537// ========================================
1538
1539function showDueDatePicker(topicIdx, subtopicIdx, anchorEl) {
1540    const topics = currentMeeting?.topics || [];
1541    const topic = topics[topicIdx];
1542    const sub = topic?.subtopics?.[subtopicIdx];
1543    if (!sub) return;
1544
1545    // Remove any existing picker
1546    dismissActivePopovers();
1547
1548    // Create a small popover with a date input
1549    const popover = document.createElement('div');
1550    popover.id = 'due-date-popover';
1551    popover.className = 'absolute z-50 bg-theme-primary border border-theme rounded-lg shadow-sb-lg p-3 space-y-2';
1552
1553    // Local "YYYY-MM-DD" for the picker's initial value. Going through toISOString() would
1554    // re-introduce the UTC day-shift; parseLocalDate + formatDateISO keeps it on the local day.
1555    const localDue = parseLocalDate(sub.dateDue);
1556    const currentDate = localDue ? formatDateISO(localDue) : '';
1557
1558    popover.innerHTML = $.templates('#tpl-meeting-due-date-popover').render({ currentDate });
1559
1560    // Portal to document.body with fixed positioning. The subtopic row sits inside
1561    // .meeting-topic-body, which is overflow-hidden (collapse animation) — appending the
1562    // popover there clips it, and for the last/just-added action item it's clipped entirely
1563    // ("nothing appears"). Positioning off the icon's viewport rect keeps it visible.
1564    const rect = anchorEl.getBoundingClientRect();
1565    document.body.appendChild(popover);
1566    popover.style.position = 'fixed';
1567    popover.style.top = `${rect.bottom + 4}px`;
1568    popover.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - 220))}px`;
1569    popover.style.minWidth = '200px';
1570
1571    // Enhance the date input
1572    afterRender(popover);
1573    const dateInput = popover.querySelector('#due-date-input');
1574
1575    // Handle save/clear
1576    const handleAction = async (e) => {
1577        const actionBtn = e.target.closest('[data-due-action]');
1578        if (!actionBtn) return;
1579
1580        const dueAction = actionBtn.dataset.dueAction;
1581
1582        if (dueAction === 'save') {
1583            const newDate = dateInput.value || null;
1584            sub.dateDue = newDate;
1585            popover.remove();
1586            rerenderTopic(topicIdx);
1587            updateProgressBar();
1588
1589            try {
1590                await projectMeetingsApi.setTopicDueDate(currentProjectId, currentMeetingId, sub.meetingsTopicId, {
1591                    dueDate: newDate
1592                });
1593            } catch (error) {
1594                if (error.name === 'AbortError') return;
1595                window.showToast?.('Failed to set due date', 'error', { description: error.message });
1596            }
1597        } else if (dueAction === 'clear') {
1598            sub.dateDue = null;
1599            popover.remove();
1600            rerenderTopic(topicIdx);
1601            updateProgressBar();
1602
1603            try {
1604                await projectMeetingsApi.setTopicDueDate(currentProjectId, currentMeetingId, sub.meetingsTopicId, {
1605                    dueDate: null
1606                });
1607            } catch (error) {
1608                if (error.name === 'AbortError') return;
1609                window.showToast?.('Failed to clear due date', 'error', { description: error.message });
1610            }
1611        }
1612    };
1613
1614    popover.addEventListener('click', handleAction);
1615
1616    // Close on click-outside or Escape — tracked for cleanup on destroy
1617    registerPopoverDismiss(popover, anchorEl);
1618
1619    // Handle Enter key in date input
1620    dateInput?.addEventListener('keydown', (e) => {
1621        if (e.key === 'Enter') {
1622            popover.querySelector('[data-due-action="save"]')?.click();
1623        }
1624    });
1625}
1626
1627// ========================================
1628// MEETING LIFECYCLE (Phase 5)
1629// ========================================
1630
1631/**
1632 * Render the chain breadcrumb for follow-up meetings.
1633 * Shows "Meeting #1 → #2 → #3 (current)" navigation.
1634 */
1635function renderChainBreadcrumb() {
1636    const container = document.getElementById('meeting-chain-breadcrumb');
1637    if (!container) return;
1638
1639    const chain = currentMeeting?.chain || [];
1640
1641    // Only show breadcrumb if there are 2+ meetings in the chain
1642    if (chain.length < 2) {
1643        container.classList.add('hidden');
1644        return;
1645    }
1646
1647    container.classList.remove('hidden');
1648    container.innerHTML = $.templates('#tpl-meeting-chain-breadcrumb').render({ chain });
1649    afterRender(container);
1650}
1651
1652/**
1653 * Navigate to another meeting in the chain.
1654 */
1655function navigateChainMeeting(actionEl) {
1656    const meetingId = parseInt(actionEl.dataset.chainMeetingId);
1657    if (meetingId && meetingId !== currentMeetingId) {
1658        transitionToMeetingDetail(meetingId);
1659    }
1660}
1661
1662/**
1663 * Close the current meeting — captures end time + proposed next meeting,
1664 * optionally emails the minutes to participants, then locks editing.
1665 */
1666async function handleCloseMeeting() {
1667    if (!currentMeetingId || !currentMeeting) return;
1668
1669    const data = {
1670        meetingTitle: currentMeeting.meetingTitle || 'this meeting',
1671        endDateLocal: nowLocalDatetime(),
1672        nextDateLocal: ''
1673    };
1674
1675    activeModal = await showModal('#tpl-meeting-close-modal', data, {
1676        signal: dialogAbort?.signal,
1677        onClose: () => { activeModal = null; }
1678    });
1679
1680    // Two close actions (legacy parity): "Close Meeting" closes without emailing; "Email &
1681    // Close" also sends the minutes PDF to all participants. Same payload, sendEmails differs.
1682    const closeBtn = activeModal.element.querySelector('[data-action="confirm-close"]');
1683    if (closeBtn) closeBtn.addEventListener('click', () => submitCloseMeeting(false));
1684    const closeSendBtn = activeModal.element.querySelector('[data-action="confirm-close-send"]');
1685    if (closeSendBtn) closeSendBtn.addEventListener('click', () => submitCloseMeeting(true));
1686    // Escape-to-close is handled by showModal (closeOnEscape); onClose nulls activeModal.
1687}
1688
1689/**
1690 * Submit the close-meeting modal: end date, proposed next meeting, and whether to email minutes.
1691 * @param {boolean}
1691 sendEmails - true for "Email & Close", false for "Close Meeting".
1692 */
1693async function submitCloseMeeting(sendEmails = false) {
1694    if (!currentMeetingId) return;
1695
1696    const endDate = document.getElementById('close-end-input')?.value || null;
1697    const followUpDate = document.getElementById('close-next-input')?.value || null;
1698
1699    // Spinner on the clicked button; disable the other so the meeting can't be double-closed.
1700    const closeBtn = activeModal?.element?.querySelector('[data-action="confirm-close"]');
1701    const sendBtn = activeModal?.element?.querySelector('[data-action="confirm-close-send"]');
1702    const activeBtn = sendEmails ? sendBtn : closeBtn;
1703    const otherBtn = sendEmails ? closeBtn : sendBtn;
1704    if (otherBtn) otherBtn.disabled = true;
1705    setLoading(activeBtn, true, sendEmails ? 'Sending...' : 'Closing...');
1706
1707    try {
1708        await projectMeetingsApi.closeMeeting(currentProjectId, currentMeetingId, {
1709            endDate,
1710            followUpDate,
1711            sendEmails
1712        });
1713        window.showToast?.(sendEmails ? 'Meeting closed — minutes sent' : 'Meeting closed', 'success');
1714        closeModal();
1715        await loadMeetingDetail(currentMeetingId);
1716    } catch (error) {
1717        if (error?.name === 'AbortError') return;
1718        setLoading(activeBtn, false);
1719        if (otherBtn) otherBtn.disabled = false;
1720        window.showToast?.('Failed to close meeting', 'error', { description: error.message });
1721        console.error('Failed to close meeting', error);
1722    }
1723}
1724
1725/**
1726 * Reopen a closed meeting — unlocks editing.
1727 */
1728async function handleReopenMeeting() {
1729    if (!currentMeetingId || !currentMeeting) return;
1730    const projectId = currentProjectId;
1731    const meetingId = currentMeetingId;
1732    const pageSignal = signal;
1733    const generation = meetingsGeneration;
1734    const context = { projectId, pageSignal, generation };
1735
1736    const committed = await confirm({
1737        title: 'Reopen this meeting?',
1738        message: 'Editing will be unlocked.',
1739        icon: 'unlock',
1740        confirmText: 'Reopen',
1741        confirmClass: 'btn-st-primary',
1742        signal: dialogAbort?.signal,
1743        async onConfirm() {
1744            await projectMeetingsApi.reopenMeeting(projectId, meetingId);
1745        }
1746    });
1747    if (!committed || !isCurrentMeetingContext(projectId, meetingId, pageSignal, generation)) return;
1748
1749    window.showToast?.('Meeting reopened', 'success');
1750    try {
1751        await loadMeetingDetail(meetingId, { throwOnError: true, context });
1752        if (!isCurrentMeetingContext(projectId, meetingId, pageSignal, generation)) return;
1753    } catch (error) {
1754        if (error?.name === 'AbortError' || !isCurrentMeetingsMountContext(context)) return;
1755        console.error('[ProjectMeetings] Meeting reopened but refresh failed:', error);
1756        window.showToast?.('Meeting reopened, but the detail did not refresh', 'error', {
1757            description: 'Reload Meetings to see the committed status.',
1758        });
1759    }
1760}
1761
1762/**
1763 * Show the follow-up meeting modal.
1764 */
1765async function showFollowUpModal() {
1766    // Captured BEFORE the team read: destroyProjectMeetings() nulls dialogAbort, so a live read
1767    // after the await would hand showModal `undefined` (CA-103 [functionality:1]).
1768    const dialogSignal = dialogAbort?.signal;
1769    // Load the project team so attendees can be set on the follow-up (legacy parity).
1770    let teamItems = [];
1771    try {
1772        // Email-options carry the actual user/contact ids (see buildParticipantOptions).
1773        // Page signal on the read (charter law 20 — the module's read cancellation stays the page's).
1774        const emailOptions = await projectTeamApi.getEmailOptions(currentProjectId, { signal });
1775        teamItems = buildParticipantOptions(emailOptions);
1776    } catch (error) {
1777        if (error?.name === 'AbortError') return;   // page torn down mid-read — nothing to open
1778        console.warn('Failed to load team for follow-up modal', error);
1779    }
1780
1781    // Pre-fill attendees from the meeting being followed up — legacy pre-selects the prior
1782    // meeting's participants in the three attendance pickers; the user can then adjust.
1783    createStagedParticipants = (currentMeeting?.participants || []).map(p => ({
1784        id: p.userId,
1785        participantType: p.participantType,
1786        attendanceType: p.attendanceType,
1787        attLabel: ATTENDANCE_LABELS[p.attendanceType] || p.attendanceType,
1788        name: p.name || 'Unknown',
1789        initials: getInitials(p.name || '??')
1790    }));
1791
1792    const data = {
1793        startDateLocal: '',
1794        location: currentMeeting?.location || '',
1795        teamItems
1796    };
1797
1798    activeModal = await showModal('#tpl-meeting-followup-modal', data, {
1799        signal: dialogSignal,
1800        onClose: () => { activeModal = null; }
1801    });
1802
1803    // Wire create button
1804    const createBtn = activeModal.element.querySelector('[data-action="create-followup"]');
1805    if (createBtn) createBtn.addEventListener('click', handleCreateFollowUp);
1806
1807    // Reuse the create-modal participant staging (same classes/attributes in this template),
1808    // then paint the pre-filled attendee chips.
1809    wireCreateParticipantStaging(activeModal.element, teamItems);
1810    renderCreateStagedChips(activeModal.element);
1811
1812    // Enter to create (Escape-to-close is handled by showModal). Not while typing in team search.
1813    activeModal.element.addEventListener('keydown', (e) => {
1814        if (e.key === 'Enter' && !e.target.classList.contains('create-team-search')) handleCreateFollowUp();
1815    });
1816}
1817
1818/**
1819 * Create a follow-up meeting from the closed meeting.
1820 * Copies open action items to the new meeting.
1821 */
1822async function handleCreateFollowUp() {
1823    if (!currentMeetingId) return;
1824
1825    const startDate = document.getElementById('followup-start-input')?.value || null;
1826    const location = document.getElementById('followup-location-input')?.value?.trim() || null;
1827
1828    const createBtn = activeModal?.element?.querySelector('[data-action="create-followup"]');
1829    setLoading(createBtn, true, 'Creating...');
1830
1831    try {
1832        const newMeeting = await projectMeetingsApi.createFollowUp(
1833            currentProjectId, currentMeetingId,
1834            {
1835                startDate,
1836                location,
1837                participants: createStagedParticipants.map(p => ({
1838                    userId: p.id,
1839                    participantType: p.participantType,
1840                    attendanceType: p.attendanceType
1841                }))
1842            }
1843        );
1844
1845        closeModal();
1846        window.showToast?.('Follow-up meeting created', 'success', {
1847            description: 'Open action items have been carried forward'
1848        });
1849
1850        // Navigate to the new meeting
1851        if (newMeeting?.meetingId) {
1852            // Add to local meetings list
1853            meetings.push({
1854                meetingId: newMeeting.meetingId,
1855                meetingTitle: newMeeting.meetingTitle,
1856                startDate: newMeeting.startDate,
1857                isClosed: false,
1858                topicCount: newMeeting.topics?.length || 0,
1859                meetingParentId: newMeeting.meetingParentId
1860            });
1861
1862            await transitionToMeetingDetail(newMeeting.meetingId);
1863        }
1864    } catch (error) {
1865        if (error.name === 'AbortError') return;
1866        window.showToast?.('Failed to create follow-up', 'error', { description: error.message });
1867        setLoading(createBtn, false);
1868    }
1869}
1870
1871// ========================================
1872// PHASE 6: FILE ATTACHMENTS
1873// ========================================
1874
1875/**
1876 * Toggle the file section for an action item. Lazy-loads files on first expand.
1877 */
1878async function toggleFileSection(subtopicRow) {
1879    if (!subtopicRow) return;
1880    const topicId = parseInt(subtopicRow.dataset.topicId);
1881    if (!topicId) return;
1882
1883    const fileContainer = subtopicRow.querySelector(`.file-section-container[data-file-topic-id="${topicId}"]`);
1884    if (!fileContainer) return;
1885
1886    if (!fileCache[topicId]) fileCache[topicId] = { loaded: false, files: [], expanded: false };
1887
1888    const state = fileCache[topicId];
1889    state.expanded = !state.expanded;
1890
1891    if (state.expanded) {
1892        fileContainer.classList.remove('hidden');
1893
1894        if (!state.loaded) {
1895            // Load files from API
1896            const fileList = fileContainer.querySelector('.file-list');
1897            if (fileList) fileList.innerHTML = $.templates('#tpl-meeting-file-loading').render({});
1898
1899            try {
1900                const response = await projectMeetingsApi.getTopicFiles(currentProjectId, currentMeetingId, topicId);
1901                state.files = response?.files || [];
1902                state.loaded = true;
1903                renderFileList(topicId);
1904            } catch (error) {
1905                if (error.name === 'AbortError') return;
1906                if (fileList) fileList.innerHTML = $.templates('#tpl-meeting-file-error').render({});
1907            }
1908        }
1909    } else {
1910        fileContainer.classList.add('hidden');
1911    }
1912}
1913
1914/**
1915 * Render file list for a topic.
1916 */
1917function renderFileList(topicId) {
1918    const state = fileCache[topicId];
1919    if (!state) return;
1920
1921    const container = document.querySelector(`.file-section-container[data-file-topic-id="${topicId}"] .file-list`);
1922    if (!container) return;
1923
1924    const readOnly = isMeetingReadOnly();
1925
1926    if (state.files.length === 0) {
1927        container.innerHTML = $.templates('#tpl-meeting-file-empty').render({});
1928        return;
1929    }
1930
1931    container.innerHTML = $.templates('#tpl-meeting-file-item').render(state.files, { readOnly, topicId });
1932    afterRender(container);
1933}
1934
1935/**
1936 * Handle file input change — upload selected files.
1937 */
1938async function handleFileInputChange(e) {
1939    const input = e.target.closest('.file-upload-input');
1940    if (!input) return;
1941
1942    const topicId = parseInt(input.dataset.uploadForTopic);
1943    if (!topicId || !input.files || input.files.length === 0) return;
1944
1945    const files = Array.from(input.files);
1946    let uploadedCount = 0;
1947
1948    for (const file of files) {
1949        const formData = new FormData();
1950        formData.append('file', file);
1951        formData.append('title', file.name);
1952
1953        try {
1954            await projectMeetingsApi.uploadTopicFile(currentProjectId, currentMeetingId, topicId, formData);
1955            uploadedCount++;
1956
1957            // Update file count in local data
1958            const sub = findSubtopicById(topicId);
1959            if (sub) sub.fileCount = (sub.fileCount || 0) + 1;
1960            updateFileCountLabel(topicId, sub?.fileCount || 0);
1961        } catch (error) {
1962            if (error.name === 'AbortError') return;
1963            window.showToast?.('Failed to upload file', 'error', { description: `${file.name}: ${error.message}` });
1964        }
1965    }
1966
1967    // Refresh file list if section is expanded
1968    if (uploadedCount > 0 && fileCache[topicId]?.expanded) {
1969        fileCache[topicId].loaded = false;
1970        try {
1971            const response = await projectMeetingsApi.getTopicFiles(currentProjectId, currentMeetingId, topicId);
1972            fileCache[topicId].files = response?.files || [];
1973            fileCache[topicId].loaded = true;
1974            renderFileList(topicId);
1975        } catch { /* ignore refresh failure */ }
1976    }
1977
1978    // Reset input so the same file can be re-selected
1979    input.value = '';
1980
1981    if (uploadedCount > 0) {
1982        window.showToast?.(`${uploadedCount} file${uploadedCount !== 1 ? 's' : ''} uploaded`, 'success');
1983    }
1984}
1985
1986/**
1987 * View a file in the FileViewer overlay.
1988 */
1989function viewFile(actionEl) {
1990    const url = actionEl.dataset.fileUrl;
1991    const contentType = actionEl.dataset.contentType || 'application/octet-stream';
1992    const fileName = actionEl.dataset.fileName || 'File';
1993    if (url) {
1994        FileViewer.open({ url, contentType, fileName });
1995    } else {
1996        window.showToast?.('File URL not available', 'error');
1997    }
1998}
1999
2000/**
2001 * Delete a file attachment.
2002 */
2003async function deleteFile(actionEl, topicIdx, subtopicIdx) {
2004    const fileId = parseInt(actionEl.dataset.deleteFileId);
2005    const topicId = parseInt(actionEl.dataset.deleteFileTopicId);
2006    if (!fileId || !topicId) return;
2007    const projectId = currentProjectId;
2008    const meetingId = currentMeetingId;
2009    const pageSignal = signal;
2010    if (!projectId || !meetingId) return;
2011
2012    const committed = await confirm({
2013        title: 'Delete this file?',
2014        message: 'This cannot be undone.',
2015        icon: 'trash-2',
2016        confirmText: 'Delete',
2017        confirmClass: 'btn-st-error',
2018        signal: dialogAbort?.signal,
2019        async onConfirm() {
2020            await projectMeetingsApi.deleteTopicFile(projectId, meetingId, topicId, fileId);
2021        }
2022    });
2023    if (!committed || !isCurrentMeetingContext(projectId, meetingId, pageSignal)) return;
2024
2025    try {
2026        // Update file state only after the accepted mutation has closed the dialog.
2027            const sub = findSubtopicById(topicId);
2028            if (sub) sub.fileCount = Math.max(0, (sub.fileCount || 0) - 1);
2029            updateFileCountLabel(topicId, sub?.fileCount || 0);
2030
2031            // Remove from cache
2032            if (fileCache[topicId]) {
2033                fileCache[topicId].files = fileCache[topicId].files.filter(f => f.meetingsTopicFileId !== fileId);
2034                renderFileList(topicId);
2035            }
2036
2037            window.showToast?.('File deleted', 'success');
2038    } catch (error) {
2039        if (error?.name === 'AbortError' || pageSignal?.aborted) return;
2040        console.error('[ProjectMeetings] File deleted but refresh failed:', error);
2041        window.showToast?.('File deleted, but the list did not refresh', 'error', {
2042            description: 'Reload the meeting to see the committed change.',
2043        });
2044    }
2045}
2046
2047/**
2048 * Update the file count label on an action item row.
2049 */
2050function updateFileCountLabel(topicId, count) {
2051    const row = document.querySelector(`.meeting-subtopic-row[data-topic-id="${topicId}"]`);
2052    if (!row) return;
2053
2054    const btn = row.querySelector('[data-action="toggle-files"]');
2055    if (!btn) return;
2056
2057    const countLabel = btn.querySelector('.file-count-label');
2058    if (count > 0) {
2059        if (countLabel) {
2060            countLabel.textContent = count;
2061        } else {
2062            const span = document.createElement('span');
2063            span.className = 'file-count-label';
2064            span.textContent = count;
2065            btn.appendChild(span);
2066        }
2067        btn.classList.remove('opacity-0');
2068        btn.classList.add('text-st-primary', 'hover:text-st-primary/80');
2069        btn.classList.remove('text-theme-secondary');
2070    } else {
2071        if (countLabel) countLabel.remove();
2072    }
2073    btn.title = count > 0 ? `${count} file${count !== 1 ? 's' : ''}` : 'Attachments';
2074}
2075
2076/**
2077 * Find a subtopic (action item) by its meetingsTopicId across all topics.
2078 */
2079function findSubtopicById(topicId) {
2080    const topics = currentMeeting?.topics || [];
2081    for (const t of topics) {
2082        for (const s of (t.subtopics || [])) {
2083            if (s.meetingsTopicId === topicId) return s;
2084        }
2085    }
2086    return null;
2087}
2088
2089// ========================================
2090// LIST ↔ DETAIL NAVIGATION
2091// ========================================
2092
2093async function backToList({ context = null } = {}) {
2094    if (context && !isCurrentMeetingsMountContext(context)) return;
2095    invalidateMeetingScopedModals();
2096    viewState = 'list';
2097    currentMeetingId = null;
2098    currentMeeting = null;
2099    collapsedTopics.clear();
2100    commentCache = {};
2101    fileCache = {};
2102    clearAllDebounceTimers();
2103    destroySortables();
2104    await renderListView({ context });
2105    if (context && !isCurrentMeetingsMountContext(context)) return;
2106    onNavigateCallback?.();
2107}
2108
2109// ========================================
2110// MEETING CRUD
2111// ========================================
2112
2113async function showMeetingModal(meeting = null) {
2114    // Captured BEFORE the create-path team read (same reason as showFollowUpModal).
2115    const dialogSignal = dialogAbort?.signal;
2116    editingMeetingId = meeting?.meetingId || null;
2117    const isCreate = !editingMeetingId;
2118    createStagedParticipants = [];
2119
2120    // For create, load the project team so participants can be added inline (legacy parity).
2121    let teamItems = [];
2122    if (isCreate) {
2123        try {
2124            // Use email-options (actual user/contact ids), not getTeam — see buildParticipantOptions.
2125            // Page signal on the read (charter law 20 — the module's read cancellation stays the page's).
2126            const emailOptions = await projectTeamApi.getEmailOptions(currentProjectId, { signal });
2127            teamItems = buildParticipantOptions(emailOptions);
2128        } catch (error) {
2129            if (error?.name === 'AbortError') return;   // page torn down mid-read — nothing to open
2130            // Non-fatal: the meeting can still be created; participants can be added later.
2131            console.warn('Failed to load team for create modal', error);
2132        }
2133    }
2134
2135    const data = {
2136        meetingId: editingMeetingId || 0,
2137        meetingTitle: meeting?.meetingTitle || '',
2138        startDateLocal: meeting?.startDate ? toLocalDatetime(meeting.startDate) : '',
2139        endDateLocal: meeting?.endDate ? toLocalDatetime(meeting.endDate) : '',
2140        location: meeting?.location || '',
2141        teamItems
2142    };
2143
2144    activeModal = await showModal('#tpl-meeting-create-modal', data, {
2145        signal: dialogSignal,
2146        onClose: () => { activeModal = null; editingMeetingId = null; }
2147    });
2148
2149    // Focus title input
2150    const titleInput = document.getElementById('meeting-title-input');
2151    if (titleInput) titleInput.focus();
2152
2153    // Save button
2154    const saveBtn = activeModal.element.querySelector('[data-action="save-meeting"]');
2155    if (saveBtn) saveBtn.addEventListener('click', handleSaveMeeting);
2156
2157    // Wire create-only agenda template + participant staging
2158    if (isCreate) wireCreateParticipantStaging(activeModal.element, teamItems);
2159
2160    // Enter to save (Escape-to-close is handled by showModal). Not while typing in team search.
2161    activeModal.element.addEventListener('keydown', (e) => {
2162        if (e.key === 'Enter' && !e.target.classList.contains('create-team-search')) handleSaveMeeting();
2163    });
2164}
2165
2166function closeModal() {
2167    if (activeModal) {
2168        activeModal.close();
2169        activeModal = null;
2170    }
2171    editingMeetingId = null;
2172}
2173
2174async function handleSaveMeeting() {
2175    const title = document.getElementById('meeting-title-input')?.value?.trim();
2176    const startDate = document.getElementById('meeting-start-input')?.value || null;
2177    const endDate = document.getElementById('meeting-end-input')?.value || null;
2178    const location = document.getElementById('meeting-location-input')?.value?.trim() || null;
2179
2180    if (!title) {
2181        const titleInput = document.getElementById('meeting-title-input');
2182        flagFieldError(titleInput?.closest('.modal-backdrop-st') || document.body, titleInput, 'Meeting title is required');
2183        return;
2184    }
2185
2186    const data = {
2187        meetingTitle: title,
2188        startDate: startDate || null,
2189        endDate: endDate || null,
2190        location: location
2191    };
2192
2193    // Create-only: agenda template + staged participants (legacy parity)
2194    if (!editingMeetingId) {
2195        data.template = document.getElementById('meeting-template-input')?.value || 'B';
2196        data.participants = createStagedParticipants.map(p => ({
2197            userId: p.id,
2198            participantType: p.participantType,
2199            attendanceType: p.attendanceType
2200        }));
2201    }
2202
2203    try {
2204        if (editingMeetingId) {
2205            // Update existing
2206            await projectMeetingsApi.updateMeeting(currentProjectId, editingMeetingId, data);
2207            window.showToast?.('Meeting updated', 'success');
2208
2209            // If we're viewing this meeting's detail, refresh it
2210            if (viewState === 'detail' && currentMeetingId === editingMeetingId) {
2211                closeModal();
2212                await loadMeetingDetail(editingMeetingId);
2213            } else {
2214                closeModal();
2215                await loadMeetings();
2216            }
2217        } else {
2218            // Create new
2219            const newMeeting = await projectMeetingsApi.createMeeting(currentProjectId, data);
2220            window.showToast?.('Meeting created', 'success');
2221            closeModal();
2222
2223            // Refresh list and navigate to the new meeting
2224            await loadMeetings();
2225            if (newMeeting?.meetingId) {
2226                await transitionToMeetingDetail(newMeeting.meetingId);
2227            }
2228        }
2229    } catch (error) {
2230        if (error.name === 'AbortError') return;
2231        window.showToast?.('Failed to save meeting', 'error', { description: error.message });
2232    }
2233}
2234
2235/**
2236 * Wire the create-modal's agenda-template toggle and inline participant staging.
2237 * Staging mirrors legacy's three participant lists (attendees / virtual / notables):
2238 * the user picks an attendance type, then checks a team member to add them (checkbox-driven —
2239 * checking stages under the active attendance type, unchecking removes; chips mirror the checks).
2240 */
2241function wireCreateParticipantStaging(modalEl, teamItems) {
2242    const opts = signal ? { signal }
2242 : {};
2243
2244    // Agenda template segmented control → hidden input
2245    modalEl.querySelectorAll('[data-template]').forEach(btn => {
2246        btn.addEventListener('click', () => {
2247            modalEl.querySelectorAll('[data-template]').forEach(b => b.classList.remove('active'));
2248            btn.classList.add('active');
2249            const hidden = modalEl.querySelector('#meeting-template-input');
2250            if (hidden) hidden.value = btn.dataset.template;
2251        }, opts);
2252    });
2253
2254    // Attendance-type segmented control (current bucket for staging)
2255    modalEl.querySelectorAll('[data-create-attendance]').forEach(btn => {
2256        btn.addEventListener('click', () => {
2257            modalEl.querySelectorAll('[data-create-attendance]').forEach(b => b.classList.remove('active'));
2258            btn.classList.add('active');
2259        }, opts);
2260    });
2261
2262    // Search filter over the team checklist
2263    modalEl.querySelector('.create-team-search')?.addEventListener('input', (e) => {
2264        const term = e.target.value.toLowerCase();
2265        modalEl.querySelectorAll('.create-team-list .picker-item').forEach(item => {
2266            item.style.display = (item.dataset.search || '').includes(term) ? '' : 'none';
2267        });
2268    }, opts);
2269
2270    // Checkbox-driven membership: check a team member to add them under the active attendance
2271    // type, uncheck to remove them. (The checkbox IS the control — no separate "Add" step.)
2272    // Chips below mirror the checked rows; removing a chip unchecks its row via the sync.
2273    modalEl.querySelector('.create-team-list')?.addEventListener('change', (e) => {
2274        const cb = e.target.closest('.create-team-checkbox');
2275        if (!cb) return;
2276        const item = cb.closest('.picker-item');
2277        const id = parseInt(cb.value);
2278        const participantType = item?.dataset.type;
2279        if (cb.checked) {
2280            const alreadyStaged = createStagedParticipants.some(
2281                p => p.id === id && p.participantType === participantType);
2282            if (!alreadyStaged) {
2283                const attendanceType = modalEl.querySelector('[data-create-attendance].active')?.dataset.createAttendance || 'InPerson';
2284                const teamItem = teamItems.find(t => t.id === id && t.participantType === participantType);
2285                createStagedParticipants.push({
2286                    id, participantType, attendanceType,
2287                    attLabel: ATTENDANCE_LABELS[attendanceType] || attendanceType,
2288                    name: teamItem?.name || 'Unknown',
2289                    initials: teamItem?.initials || '??'
2290                });
2291            }
2292        } else {
2293            const idx = createStagedParticipants.findIndex(
2294                p => p.id === id && p.participantType === participantType);
2295            if (idx >= 0) createStagedParticipants.splice(idx, 1);
2296        }
2297        renderCreateStagedChips(modalEl);
2298    }, opts);
2299
2300    // Remove a staged chip
2301    modalEl.querySelector('.create-staged-participants')?.addEventListener('click', (e) => {
2302        const btn = e.target.closest('[data-remove-staged]');
2303        if (!btn) return;
2304        const idx = parseInt(btn.dataset.removeStaged);
2305        if (idx >= 0 && idx < createStagedParticipants.length) {
2306            createStagedParticipants.splice(idx, 1);
2307            renderCreateStagedChips(modalEl);
2308        }
2309    }, opts);
2310}
2311
2312function renderCreateStagedChips(modalEl) {
2313    const container = modalEl.querySelector('.create-staged-participants');
2314    if (!container) return;
2315    container.innerHTML = $.templates('#tpl-meeting-staged-participant').render(createStagedParticipants);
2316    afterRender(container);
2317    syncCreateChecklistChecks(modalEl);
2318}
2319
2320/**
2321 * Mirror the staged participants onto the team-checklist checkboxes: a box is checked iff that
2322 * person is staged. Driven from renderCreateStagedChips, so checking a box (→ staged), removing
2323 * a chip (→ unstaged), and the follow-up pre-fill all keep the list and the chips in lock-step.
2324 * The checkbox IS the membership control — carried-forward attendees open pre-checked; uncheck
2325 * one to drop it.
2326 */
2327function syncCreateChecklistChecks(modalEl) {
2328    const list = modalEl.querySelector('.create-team-list');
2329    if (!list) return;
2330    list.querySelectorAll('.picker-item').forEach(item => {
2331        const cb = item.querySelector('.create-team-checkbox');
2332        if (!cb) return;
2333        cb.checked = createStagedParticipants.some(
2334            p => p.id === parseInt(cb.value) && p.participantType === item.dataset.type);
2335    });
2336}
2337
2338async function handleDeleteMeeting() {
2339    if (!currentMeetingId || !currentMeeting) return;
2340
2341    const title = currentMeeting.meetingTitle || 'this meeting';
2342    const projectId = currentProjectId;
2343    const meetingId = currentMeetingId;
2344    const pageSignal = signal;
2345    const generation = meetingsGeneration;
2346    const context = { projectId, pageSignal, generation };
2347
2348    const committed = await confirm({
2349        title: `Delete "${title}"?`,
2350        message: 'This cannot be undone.',
2351        icon: 'trash-2',
2352        confirmText: 'Delete',
2353        confirmClass: 'btn-st-error',
2354        signal: dialogAbort?.signal,
2355        async onConfirm() {
2356            await projectMeetingsApi.deleteMeeting(projectId, meetingId);
2357        }
2358    });
2359    if (!committed || !isCurrentMeetingContext(projectId, meetingId, pageSignal, generation)) return;
2360
2361    window.showToast?.('Meeting deleted', 'success');
2362    meetings = meetings.filter(m => String(m.meetingId) !== String(meetingId));
2363    await backToList({ context });
2364}
2365
2366// ========================================
2367// TOPIC CRUD
2368// ========================================
2369
2370async function addTopic() {
2371    if (!currentMeetingId) return;
2372
2373    const topics = currentMeeting?.topics || [];
2374    const sortOrder = topics.length + 1;
2375
2376    try {
2377        const saved = await projectMeetingsApi.saveTopic(currentProjectId, currentMeetingId, {
2378            id: 0,
2379            parentMeetingsTopicId: 0,
2380            topicTitle: 'New Topic',
2381            sortOrder: sortOrder
2382        });
2383
2384        if (saved) {
2385            saved.subtopics = saved.subtopics || [];
2386            topics.push(saved);
2387            currentMeeting.topics = topics;
2388
2389            renderTopics();
2390            updateProgressBar();
2391            initSortables();
2392
2393            // Focus the new topic's title input
2394            const topicCards = document.querySelectorAll('.meeting-topic-card');
2395            const lastCard = topicCards[topicCards.length - 1];
2396            const nameInput = lastCard?.querySelector('input[data-field="topicTitle"]');
2397            if (nameInput) {
2398                nameInput.focus();
2399                nameInput.select();
2400            }
2401        }
2402    } catch (error) {
2403        if (error.name === 'AbortError') return;
2404        window.showToast?.('Failed to add topic', 'error', { description: error.message });
2405    }
2406}
2407
2408async function addSubtopic(topicIdx) {
2409    const topics = currentMeeting?.topics || [];
2410    const topic = topics[topicIdx];
2411    if (!topic) return;
2412
2413    if (!topic.subtopics) topic.subtopics = [];
2414    const sortOrder = topic.subtopics.length + 1;
2415
2416    try {
2417        const saved = await projectMeetingsApi.saveTopic(currentProjectId, currentMeetingId, {
2418            id: 0,
2419            parentMeetingsTopicId: topic.meetingsTopicId,
2420            topicTitle: 'New Action Item',
2421            sortOrder: sortOrder
2422        });
2423
2424        if (saved) {
2425            saved.subtopics = saved.subtopics || [];
2426            topic.subtopics.push(saved);
2427
2428            rerenderTopic(topicIdx);
2429            updateProgressBar();
2430
2431            // Focus the new subtopic's title input
2432            const topicCard = document.querySelector(`[data-topic-index="${topicIdx}"]`);
2433            const subRows = topicCard?.querySelectorAll('.meeting-subtopic-row');
2434            const lastRow = subRows?.[subRows.length - 1];
2435            const nameInput = lastRow?.querySelector('input[data-field="topicTitle"]');
2436            if (nameInput) {
2437                nameInput.focus();
2438                nameInput.select();
2439            }
2440        }
2441    } catch (error) {
2442        if (error.name === 'AbortError') return;
2443        window.showToast?.('Failed to add action item', 'error', { description: error.message });
2444    }
2445}
2446
2447async function handleDeleteTopic(topicIdx) {
2448    const topics = currentMeeting?.topics || [];
2449    const topic = topics[topicIdx];
2450    if (!topic) return;
2451
2452    const title = topic.topicTitle || 'this topic';
2453    const subtopicCount = (topic.subtopics || []).length;
2454    const msg = subtopicCount > 0
2455        ? `This will also delete its ${subtopicCount} action item${subtopicCount > 1 ? 's' : ''}.`
2456        : 'This cannot be undone.';
2457    const projectId = currentProjectId;
2458    const meetingId = currentMeetingId;
2459    const topicId = topic.meetingsTopicId;
2460    const pageSignal = signal;
2461
2462    const committed = await confirm({
2463        title: `Delete "${title}"?`,
2464        message: msg,
2465        icon: 'trash-2',
2466        confirmText: 'Delete',
2467        confirmClass: 'btn-st-error',
2468        signal: dialogAbort?.signal,
2469        async onConfirm() {
2470            await projectMeetingsApi.deleteTopic(projectId, meetingId, topicId);
2471        }
2472    });
2473    if (!committed || !isCurrentMeetingContext(projectId, meetingId, pageSignal)) return;
2474
2475    const currentTopics = currentMeeting?.topics || [];
2476    const currentIndex = currentTopics.findIndex(item => String(item.meetingsTopicId) === String(topicId));
2477    if (currentIndex < 0) return;
2478    currentTopics.splice(currentIndex, 1);
2479    currentMeeting.topics = currentTopics;
2480    renderTopics();
2481    updateProgressBar();
2482    initSortables();
2483}
2484
2485async function handleDeleteSubtopic(topicIdx, subtopicIdx) {
2486    const topics = currentMeeting?.topics || [];
2487    const topic = topics[topicIdx];
2488    const sub = topic?.subtopics?.[subtopicIdx];
2489    if (!sub) return;
2490
2491    const title = sub.topicTitle || 'this action item';
2492    const projectId = currentProjectId;
2493    const meetingId = currentMeetingId;
2494    const parentTopicId = topic.meetingsTopicId;
2495    const subtopicId = sub.meetingsTopicId;
2496    const pageSignal = signal;
2497
2498    const committed = await confirm({
2499        title: `Delete "${title}"?`,
2500        message: 'This cannot be undone.',
2501        icon: 'trash-2',
2502        confirmText: 'Delete',
2503        confirmClass: 'btn-st-error',
2504        signal: dialogAbort?.signal,
2505        async onConfirm() {
2506            await projectMeetingsApi.deleteTopic(projectId, meetingId, subtopicId);
2507        }
2508    });
2509    if (!committed || !isCurrentMeetingContext(projectId, meetingId, pageSignal)) return;
2510
2511    const currentTopics = currentMeeting?.topics || [];
2512    const currentTopicIndex = currentTopics.findIndex(item => String(item.meetingsTopicId) === String(parentTopicId));
2513    if (currentTopicIndex < 0) return;
2514    const currentTopic = currentTopics[currentTopicIndex];
2515    const currentSubtopicIndex = (currentTopic.subtopics || []).findIndex(
2516        item => String(item.meetingsTopicId) === String(subtopicId)
2517    );
2518    if (currentSubtopicIndex < 0) return;
2519    currentTopic.subtopics.splice(currentSubtopicIndex, 1);
2520    rerenderTopic(currentTopicIndex);
2521    updateProgressBar();
2522}
2523
2524// ========================================
2525// TOPIC REORDER (legacy arrow functions kept for fallback)
2526// ========================================
2527
2528function updateTopicSortOrders() {
2529    const topics = currentMeeting?.topics || [];
2530    topics.forEach((t, idx) => { t.sortOrder = idx + 1; });
2531}
2532
2533async function saveTopicOrder() {
2534    if (!currentMeetingId || !currentMeeting) return;
2535
2536    const topics = currentMeeting.topics || [];
2537    const orderData = {
2538        topics: topics.map(t => ({
2539            meetingsTopicId: t.meetingsTopicId,
2540            sortOrder: t.sortOrder
2541        }))
2542    };
2543
2544    try {
2545        await projectMeetingsApi.reorderTopics(currentProjectId, currentMeetingId, orderData);
2546    } catch (error) {
2547        if (error.name === 'AbortError') return;
2548        window.showToast?.('Failed to save topic order', 'error', { description: error.message });
2549    }
2550}
2551
2552// ========================================
2553// ACCORDION
2554// ========================================
2555
2556function toggleTopic(topicIdx, topicCard) {
2557    const topics = currentMeeting?.topics || [];
2558    const topic = topics[topicIdx];
2559    if (!topic) return;
2560
2561    if (!topicCard) topicCard = document.querySelector(`[data-topic-index="${topicIdx}"]`);
2562    if (!topicCard) return;
2563
2564    const body = topicCard.querySelector('.meeting-topic-body');
2565    const chevron = topicCard.querySelector('.meeting-chevron');
2566    if (!body) return;
2567
2568    const topicId = topic.meetingsTopicId;
2569
2570    if (collapsedTopics.has(topicId)) {
2571        collapsedTopics.delete(topicId);
2572        body.style.maxHeight = body.scrollHeight + 'px';
2573        body.style.overflow = '';
2574        chevron?.classList.add('rotate-90');
2575        setTimeout(() => {
2576            if (!collapsedTopics.has(topicId)) {
2577                body.style.maxHeight = '';
2578            }
2579        }, 300);
2580    } else {
2581        collapsedTopics.add(topicId);
2582        body.style.maxHeight = body.scrollHeight + 'px';
2583        body.offsetHeight; // force reflow
2584        body.style.maxHeight = '0';
2585        body.style.overflow = 'hidden';
2586        chevron?.classList.remove('rotate-90');
2587    }
2588}
2589
2590function expandAllTopics() {
2591    const topics = currentMeeting?.topics || [];
2592    topics.forEach((topic, idx) => {
2593        if (collapsedTopics.has(topic.meetingsTopicId)) {
2594            toggleTopic(idx);
2595        }
2596    });
2597}
2598
2599function collapseAllTopics() {
2600    const topics = currentMeeting?.topics || [];
2601    topics.forEach((topic, idx) => {
2602        if (!collapsedTopics.has(topic.meetingsTopicId)) {
2603            toggleTopic(idx);
2604        }
2605    });
2606}
2607
2608// ========================================
2609// AUTO-SAVE (Inline topic title editing)
2610// ========================================
2611
2612function debouncedSaveTopic(topicId) {
2613    clearDebounceTimer(topicId);
2614    saveDebounceTimers[topicId] = setTimeout(() => {
2615        delete saveDebounceTimers[topicId];
2616        saveTopicToServer(topicId);
2617    }, SAVE_DEBOUNCE_MS);
2618}
2619
2620async function saveTopicToServer(topicId) {
2621    if (!currentMeetingId || !currentMeeting) return;
2622
2623    // Find the topic/subtopic data
2624    const topics = currentMeeting.topics || [];
2625    let topicData = null;
2626
2627    for (const t of topics) {
2628        if (t.meetingsTopicId === topicId) {
2629            topicData = t;
2630            break;
2631        }
2632        for (const s of (t.subtopics || [])) {
2633            if (s.meetingsTopicId === topicId) {
2634                topicData = s;
2635                break;
2636            }
2637        }
2638        if (topicData) break;
2639    }
2640
2641    if (!topicData) return;
2642
2643    try {
2644        await projectMeetingsApi.saveTopic(currentProjectId, currentMeetingId, {
2645            id: topicData.meetingsTopicId,
2646            parentMeetingsTopicId: topicData.parentMeetingsTopicId || 0,
2647            topicTitle: topicData.topicTitle || '',
2648            sortOrder: topicData.sortOrder || 0
2649        });
2650    } catch (error) {
2651        if (error.name === 'AbortError') return;
2652        window.showToast?.('Failed to save topic', 'error', { description: error.message });
2653    }
2654}
2655
2656// ========================================
2657// POPOVER LIFECYCLE
2658// ========================================
2659
2660/**
2661 * Register Escape/click-outside dismiss for a popover, tracked for cleanup on destroy.
2662 */
2663function registerPopoverDismiss(popover, anchorEl) {
2664    const closePopover = (e) => {
2665        if (e.type === 'keydown' && e.key === 'Escape') {
2666            removePopover();
2667        }
2668        // The DatePicker renders its calendar on document.body (position:fixed), so a click
2669        // on a day is technically "outside" this popover. Don't let that dismiss us — otherwise
2670        // picking a due date closes the popover before Save (the due-date picker bug).
2671        if (e.type === 'mousedown'
2672            && !popover.contains(e.target)
2673            && e.target !== anchorEl
2674            && !e.target.closest('.datepicker-popover, .bottom-sheet-backdrop')) {
2675            removePopover();
2676        }
2677    };
2678
2679    const removePopover = () => {
2680        popover.remove();
2681        document.removeEventListener('keydown', closePopover);
2682        document.removeEventListener('mousedown', closePopover);
2683        activePopoverCleanups = activePopoverCleanups.filter(fn => fn !== removePopover);
2684    };
2685
2686    activePopov
2686erCleanups.push(removePopover);
2687
2688    requestAnimationFrame(() => {
2689        document.addEventListener('keydown', closePopover);
2690        document.addEventListener('mousedown', closePopover);
2691    });
2692}
2693
2694/**
2695 * Dismiss all active popovers (called on destroy and before opening a new one).
2696 */
2697function dismissActivePopovers() {
2698    activePopoverCleanups.forEach(fn => fn());
2699    activePopoverCleanups = [];
2700}
2701
2702// ========================================
2703// UTILITIES
2704// ========================================
2705
2706function formatMeetingDate(dateStr) {
2707    if (!dateStr) return '';
2708    try {
2709        // parseLocalDate keeps date-only values at local midnight (no UTC day-shift).
2710        const d = parseLocalDate(dateStr);
2711        if (!d) return dateStr;
2712        return d.toLocaleDateString('en-US', {
2713            month: 'short',
2714            day: 'numeric',
2715            year: 'numeric'
2716        });
2717    } catch {
2718        return dateStr;
2719    }
2720}
2721
2722function formatShortDate(dateStr) {
2723    if (!dateStr) return '';
2724    try {
2725        // parseLocalDate keeps date-only values at local midnight (no UTC day-shift).
2726        const d = parseLocalDate(dateStr);
2727        if (!d) return dateStr;
2728        return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
2729    } catch {
2730        return dateStr;
2731    }
2732}
2733
2734function toLocalDatetime(dateStr) {
2735    if (!dateStr) return '';
2736    try {
2737        const d = new Date(dateStr);
2738        // Format as YYYY-MM-DDThh:mm for datetime-local input
2739        const pad = n => String(n).padStart(2, '0');
2740        return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
2741    } catch {
2742        return '';
2743    }
2744}
2745
2746/** Current local datetime as YYYY-MM-DDThh:mm (for the close modal's "Meeting Ended" default). */
2747function nowLocalDatetime() {
2748    return toLocalDatetime(new Date());
2749}
2750
2751function clearDebounceTimer(topicId) {
2752    if (saveDebounceTimers[topicId]) {
2753        clearTimeout(saveDebounceTimers[topicId]);
2754        delete saveDebounceTimers[topicId];
2755    }
2756}
2757
2758function clearAllDebounceTimers() {
2759    Object.keys(saveDebounceTimers).forEach(key => {
2760        clearTimeout(saveDebounceTimers[key]);
2761    });
2762    saveDebounceTimers = {};
2763}
2764
2765// ========================================
2766// PHASE 7: PRINT & EMAIL
2767// ========================================
2768
2769async function handlePrintMeeting() {
2770    if (!currentProjectId || !currentMeetingId) return;
2771
2772    const btn = document.querySelector('[data-action="print-meeting"]');
2773    if (btn) setLoading(btn, true);
2774
2775    try {
2776        const result = await projectMeetingsApi.printMeeting(currentProjectId, currentMeetingId);
2777        if (!result || !result.blob) {
2778            throw new Error('No PDF data received');
2779        }
2780
2781        const { blob, fileName } = result;
2782
2783        downloadBlob(blob, fileName || 'MeetingMinutes.pdf', { successMessage: 'Meeting minutes PDF downloaded' });
2784        if (btn) setLoading(btn, false);
2785    } catch (error) {
2786        if (error.name === 'AbortError') return;
2787        console.error('Print meeting failed:', error);
2788        window.showToast?.('Failed to generate PDF', 'error', { description: error.message });
2789        if (btn) setLoading(btn, false);
2790    }
2791}
2792
2793async function handleEmailMeeting() {
2794    const projectId = currentProjectId;
2795    const meetingId = currentMeetingId;
2796    const pageSignal = signal;
2797    const generation = meetingsGeneration;
2798    if (!projectId || !meetingId || !isCurrentMeetingContext(projectId, meetingId, pageSignal, generation)) return;
2799    const owner = createMeetingModalOwner('email', pageSignal);
2800    const isCurrent = () => emailMeetingOwner === owner
2801        && !owner.controller.signal.aborted
2802        && isCurrentMeetingContext(projectId, meetingId, pageSignal, generation);
2803
2804    let options;
2805    try {
2806        options = await projectMeetingsApi.getEmailOptions(projectId, meetingId, { signal: owner.controller.signal });
2807        if (!isCurrent()) return;
2808    } catch (error) {
2809        if (error.name === 'AbortError' || !isCurrent()) return;
2810        invalidateMeetingModalOwner('email');
2811        window.showToast?.('Failed to load email options', 'error', { description: error.message });
2812        return;
2813    }
2814
2815    const modal = await showModal('#tpl-meeting-email-modal', {
2816        users: options?.users || [],
2817        contacts: options?.contacts || [],
2818    }, {
2819        size: 'md',
2820        signal: owner.controller.signal,
2821        onClose: () => releaseMeetingModalOwner('email', owner),
2822    });
2823    if (!isCurrent()) {
2824        modal.close();
2825        return;
2826    }
2827    owner.modal = modal;
2828
2829    const sendBtn = modal.element.querySelector('[data-action="send-meeting-email"]');
2830    sendBtn?.addEventListener('click', async () => {
2831        const toSelect = modal.element.querySelector('#meeting-email-to');
2832        const ccSelect = modal.element.querySelector('#meeting-email-cc');
2833        const messageEl = modal.element.querySelector('#meeting-email-message');
2834
2835        const toValues = toSelect ? Array.from(toSelect.selectedOptions).map(o => o.value) : [];
2836        const ccValues = ccSelect ? Array.from(ccSelect.selectedOptions).map(o => o.value) : [];
2837        const message = messageEl?.value?.trim() || '';
2838
2839        if (toValues.length === 0 && toSelect) {
2840            flagFieldError(modal.element, toSelect, 'Please select at least one recipient', { clearOn: 'change' });
2841            return;
2842        }
2843
2844        const parseIds = (values) => {
2845            const userIds = [];
2846            const contactIds = [];
2847            values.forEach(v => {
2848                const [type, id] = v.split('-');
2849                if (type === 'user') userIds.push(parseInt(id));
2850                else if (type === 'contact') contactIds.push(parseInt(id));
2851            });
2852            return { userIds, contactIds };
2853        };
2854
2855        const to = parseIds(toValues);
2856        const cc = parseIds(ccValues);
2857
2858        setLoading(sendBtn, true, 'Sending...');
2859        try {
2860            if (!isCurrent()) return;
2861            await projectMeetingsApi.sendMeetingEmail(projectId, meetingId, {
2862                userIds: to.userIds,
2863                contactIds: to.contactIds,
2864                ccUserIds: cc.userIds,
2865                ccContactIds: cc.contactIds,
2866                message,
2867            });
2868            if (!isCurrent()) return;
2869            window.showToast?.('Meeting minutes sent', 'success');
2870            modal.close();
2871        } catch (error) {
2872            if (error.name === 'AbortError' || !isCurrent()) return;
2873            window.showToast?.('Failed to send email', 'error', { description: error.message });
2874            setLoading(sendBtn, false);
2875        }
2876    });
2877}

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.