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.