1/** 2 * calendar.js â Standalone tenant calendar page (/calendar). 3 * 4 * One unified card: a custom header (nav + title + filter + My-Tasks + Month/Week/Day + 5 * Add) over a body that shows either FullCalendar (Month/Week) or a custom 2-column Day 6 * view (components/DayScheduleView.js). Merges three legacy event sources: 7 * ⢠calendar events (Task / Call / Meeting) â preview popover â detail modal 8 * ⢠meeting-minute topics â preview popover â "Go to meeting" 9 * ⢠project task due-dates (RFI / SI / CHG / â¦) â preview popover â "Go to module" 10 * 11 * Click is preview-first everywhere: every event opens an anchored popover (never punishes 12 * a click by navigating away). Reuses createPortalPopover, the SearchableDropdown filter, the project 13 * Tasks detail machinery (api/project-calendar.js), and the .view-toggle-st / .popover-st 14 * design-system classes. 15 */ 16 17// ======================================== 18// IMPORTS 19// ======================================== 20 21import { calendarApi } from '../api/calendar.js'; 22import { projectCalendarApi, statusOf } from '../api/project-calendar.js'; 23import { projectMeetingsApi } from '../api/project-meetings.js'; 24import { projectsApi } from '../api/projects.js'; 25import { contactsApi } from '../api/contacts.js'; 26import { showModal, confirm } from '../utils/modal.js'; 27import { renderTo, createTemplateLoader } from '../utils/templates.js'; 28import { validateRequired } from '../utils/form-validation.js'; 29import { runWithBusyButton, setLoading } from '../utils/dom.js'; 30import { formatDate, formatTimeDisplay } from '../utils/jsrender-helpers.js'; 31import { SearchableDropdown } from '../components/SearchableDropdown.js'; 32import { subscribeTaskUpdates } from '../realtime/TaskGroupSync.js'; 33import { createMobilePopoverBackdrop, createPortalPopover } from '../utils/popover-position.js'; 34import { renderDayScheduleView, eventIcon, eventType } from '../components/DayScheduleView.js'; 35import { renderMonthScheduleView, monthGridRange } from '../components/MonthScheduleView.js'; 36 37// ââ Event duration â CA-138 âââââââââââââââââââââââââââââââââââââââââââââââââ 38// `#calendar-event-duration` was `<input type="number" min="0" step="1">` until this card, and 39// `min`/`step` were affordances, never enforcement â the real filter was the ambient 40// `app.js:107-135` sanitizer this card narrows away from it. The read was 41// `durationRaw ? Number(durationRaw) : null`, so unreadable text became `NaN`, and the publication 42// guard's `(duration && duration > 0)` then quietly turned that NaN into `null`: everything the
43// user typed was DELETED on save with no message at all. 44// 45// Derived from `CalendarEvent.event_duration` â `decimal?`, and legacy's own insert takes it 46// through `Convert.ToDecimal` with no sign or precision constraint. So: a number, not negative, and 47// deliberately NOT integer â the unit selector beside it offers Hours and Days, where 1.5 is a real 48// duration. Optional; blankness stays the descriptor's business, and the blank/zero publication 49// contract below is preserved exactly. 50const CALENDAR_EVENT_DURATION = { minimum: 0 }; 51 52// ======================================== 53// MODULE STATE 54// ======================================== 55 56let calendar = null; 57let abortController = null; 58let projectsCache = []; // [{ projectId, projectTitle, projectNumber }] â filter + create picker 59let currentFilterId = ''; // '' = All, '0' = Personal, '{projectId}' = a project 60let assignedToMe = false; // "My Tasks" toggle 61 62let currentView = 'month'; // 'month' | 'week' | 'day' 63let currentDate = new Date(); // source of truth for the active month/day (month + day views own it; week syncs from FC) 64let currentDensity = 'standard'; // month chip detail: 'compact' | 'standard' | 'full' (CSS class on .month-scroll) 65let viewRenderGen = 0; // bumps each renderMonth/renderDay; a stale fetch resolving late (incl. across a view switch) is discarded 66 67let detailSub = null; // TaskGroupSync subscription while a detail modal is open 68let detailModalRef = null; // the open detail modal (so live refresh / actions can target it) 69let detailEventId = null; // event id of the open detail modal (URL sync + re-dispatch no-op guard) 70let detailOpenGen = 0; // bumps per detail-open attempt; a teardown/route-out/newer open invalidates an in-flight one 71let activePopover = null; // { cleanup, backdropCleanup, el, anchorEl, onDocClick, onKey } â the open event popover 72let popoverOpenGen = 0; // invalidates a detached render when a newer open or page teardown wins 73 74// True while applying a route (init / navigateCalendarPage, incl. browser Back/Forward). User-driven 75// view/date changes push the URL via window.page(); route-driven changes run under this flag and stay 76// silent, so the two never feed each other into a popstate loop. (page-js skill: #1 correctness rule.) 77let _routing = false; 78 79const ensureTemplates = createTemplateLoader('components/calendar-templates'); 80 81const STORAGE_ASSIGNED = 'calendarAssignedToMe'; // legacy localStorage key (parity) 82const STORAGE_DENSITY = 'calendarDensity'; // month chip density preference (per user) 83const DURATION_TYPE_LABELS = { M: 'Minutes', H: 'Hours', D: 'Days' }; 84const STATUS_TEXT = { completed: 'Completed', overdue: 'Overdue', today: 'Due Today', upcoming: 'Upcoming' }; 85// pageUrl â human label for the "Go to {module}" popover action. 86const MODULE_LABELS = { 87 rfi: 'RFI', si: 'Site Instruction', chg: 'Change', sub: 'Submittal', 88 meetings: 'Meeting', meeting: 'Meeting', invoices: 'Invoices', 89 desclar: 'Clarification', tenderclar: 'Tender Clarification', caltsk: 'Task', 90}; 91// pageUrl â full type label for the mini-detail header (mirrors the task-detail "entityLabel"). 92const MODULE_TYPE_LABELS = { 93 rfi: 'Request for Information', si: 'Field Memo / S.I.', chg: 'Change', sub: 'Submittal', 94 desclar: 'Design Clarification', tenderclar: 'Tender Clarification', 95}; 96 97// ======================================== 98// PUBLIC API 99// ======================================== 100 101/** 102 * Initialize Calendar Page (cold mount). Restores view + anchor date (+ optional event) from the route. 103 * @param {{ view?: 'month'|'week'|'day', date?: string, eventId?: string|number }} [options] 104 */ 105export async function initCalendarPage(options = {}) { 106 const calendarEl = document.getElementById('calendar-el'); 107 if (!calendarEl) return; 108 109 abortController = new AbortController(); 110 await ensureTemplates(); 111 assignedToMe = localStorage.getItem(STORAGE_ASSIGNED) === 'true'; 112 currentDensity = localStorage.getItem(STORAGE_DENSITY) || 'standard'; 113 114 // Restore view + anchor date from the route (deep-link / refresh / shared link) instead of 115 // resetting to month/today. _routing keeps the initial FC render + the restore from pushing URLs. 116 _routing = true; 117 currentView = options.view || 'month'; 118 currentDate = options.date ? new Date(options.date + 'T00:00:00') : new Date(); 119 120 initFullCalendar(calendarEl); 121 applyView(); // match the DOM/FC to the restored view + date 122 _routing = false;
123 124 bindHeader(); 125 updateViewToggle(); 126 updateDensityToggle(); 127 await loadProjects(); 128 syncMyTasksToggle(); 129 130 if (options.eventId) openEventDetail(options.eventId, { gotoDate: true }); 131} 132 133/** Clean up Calendar Page */ 134export function destroyCalendarPage() { 135 abortController?.abort(); 136 abortController = null; 137 closePopover({ restoreFocus: false }); 138 detailSub?.dispose(); 139 detailSub = null; 140 detailModalRef = null; 141 detailEventId = null; // §1 reset: cannot depend on onClose, which never runs if the modal never opened 142 const filterEl = document.getElementById('calendar-container'); 143 if (filterEl) SearchableDropdown.destroyAll(filterEl); 144 currentFilterId = ''; 145 projectsCache = []; 146 currentView = 'month'; 147 viewRenderGen = 0; 148 if (calendar) { 149 calendar.destroy(); 150 calendar = null; 151 } 152} 153 154/** 155 * Same-page route change â runs on browser Back/Forward and on our own page() pushes, via routeGuard 156 * (no FullCalendar teardown). Applies the URL's view/date/event under _routing so nothing re-pushes. 157 * @param {{ view?: 'month'|'week'|'day', date?: string, eventId?: string|number }} p 158 */ 159export function navigateCalendarPage(p = {}) { 160 if (!document.getElementById('calendar-el')) return; 161 _routing = true; 162 try { 163 if (p.eventId) { openEventDetail(p.eventId, { gotoDate: true }); return; } 164 // Route carries no event id â if the detail modal is open, the user Back'd out of it. Close it. 165 // If an open is still IN FLIGHT (claimed, modal not painted yet), cancel it instead â otherwise 166 // it paints onto a URL the user has already left. No teardown is involved in this variant. 167 if (detailModalRef) detailModalRef.close(); 168 else if (detailEventId) { detailEventId = null; detailOpenGen++; } 169 const targetView = p.view || 'month'; 170 const targetDate = p.date || isoToday(); 171 // No-op when the DOM already reflects this view+date â i.e. the re-dispatch immediately after 172 // our own page() push. Only a genuine Back/Forward to a different state re-applies. 173 if (targetView === currentView && targetDate === isoLocal(currentDate)) return; 174 currentDate = new Date(targetDate + 'T00:00:00'); 175 if (targetView !== currentView) { currentView = targetView; updateViewToggle(); } 176 applyView(); 177 } finally { 178 _routing = false; 179 } 180} 181 182// ======================================== 183// CALENDAR SETUP (FullCalendar â Month / Week) 184// ======================================== 185 186/** 187 * ApiClient.toCamelCase() has already converted all keys. Remap to FullCalendar's shape, 188 * carrying the full extendedProps through (used by click routing + detail). Colors come 189 * from per-event classes (eventClassNames), not the raw backend hex. 190 */ 191function transformEvent(ev) { 192 const ext = ev.extendedProps ?? {}; 193 return { 194 id: ext.calendarEventId, 195 title: ev.title, 196 start: ev.start || ext.eventDateStart || null, 197 end: ev.end || ext.eventDateEnd || null, 198 extendedProps: ext, 199 }; 200} 201 202/** Shared event fetch for both FullCalendar and the day view. */ 203async function fetchEvents(startStr, endStr) { 204 const events = await calendarApi.getEvents({ 205 start: startStr, end: endStr, filterId: currentFilterId, assignedToMe, 206 }); 207 return events || []; 208} 209 210function initFullCalendar(el) { 211 calendar = new FullCalendar.Calendar(el, { 212 initialView: 'dayGridMonth', 213 headerToolbar: false, // our custom header drives nav/views 214 themeSystem: 'standard', 215 dayMaxEvents: true, // cap busy month cells â "+N more" pill (legacy parity) 216 fixedWeekCount: false, // variable 5â6 week rows, not always 6 (legacy parity) 217 eventTimeFormat: { hour: '2-digit', minute: '2-digit', hour12: true }, // 12h chip times (legacy parity) 218 height: '100%', 219 events: (info, success, failure) => { 220 fetchEvents(info.startStr, info.endStr) 221 .then(evs => success(evs.map(transformEvent))) 222 .catch(err => { 223 if (err.name === 'AbortError') return; 224 window.showToast?.('Failed to load events', 'error', { description: err.message }); 225 failure(err); 226 }); 227 }, 228 eventClick: (info) => { 229 info.jsEvent?.preventDefault(); 230 handleEventClick(info.event.extendedProps || {}, info.el, info.event.title); 231 }, 232 dateClick: (info) => openEventModal(null, info.dateStr), 233 eventClassNames: eventClasses, 234 eventContent: renderChip, 235 eventDidMount: (info) => { window.lucide?.createIcons?.({ root: info.el }); }, 236 datesSet: (info) => { 237 // FC now drives ONLY the Week view (Month is the custom MonthScheduleView, Day is 238 // DayScheduleView). So we act on datesSet only while Week is active â FC's transient initial 239 // render at today (fired while currentView is 'month') is correctly ignored, which removes the 240 // old month-restore clobber gotcha entirely. For Week: mirror FC's date for USER moves, but
241 // not during a route apply (_routing) where WE drive the date via gotoDate. syncUrl no-ops under _routing. 242 if (currentView === 'week') { 243 if (!_routing) currentDate = calendar.getDate(); 244 setTitle(info.view.title); 245 syncUrl(); 246 } 247 }, 248 }); 249 calendar.render(); 250} 251 252/** 253 * Per-event CSS classes drive the on-brand styling (FC theme block in tailwind.css): 254 * source â fill family, priority (real calendar events) â left accent. 255 */ 256function eventClasses(arg) { 257 const ext = arg.event.extendedProps || {}; 258 const dt = (ext.displayType || '').toLowerCase(); 259 let src = 'event'; 260 if (dt.includes('meetingminutes')) src = 'meeting'; 261 else if (dt === 'accounting') src = 'accounting'; 262 else if (dt.includes('task')) src = 'task'; 263 264 const classes = ['cal-ev', `cal-ev-${src}`, `cal-ev-t-${eventType(ext)}`]; 265 const prio = (ext.priorityKey || '').toUpperCase(); 266 if (src === 'event' && (prio === 'H' || prio === 'M' || prio === 'L')) { 267 classes.push(`cal-ev-prio-${prio.toLowerCase()}`); 268 } 269 return classes; 270} 271 272/** Custom, informative event chip (time + title + affordance; richer meta in week view). */ 273function renderChip(arg) { 274 const ext = arg.event.extendedProps || {}; 275 const wrap = document.createElement('div'); 276 wrap.className = 'cal-chip'; 277 278 // Leading type icon (RFI / SI / CHG / Submittal / Meeting / Call / Task / â¦) â same vocabulary 279 // as the day view (shared eventIcon) so the type is scannable in the month grid at a glance. 280 const ic = document.createElement('i'); 281 ic.setAttribute('data-lucide', eventIcon(ext)); 282 ic.className = 'cal-chip-icon'; 283 ic.setAttribute('aria-hidden', 'true'); 284 wrap.appendChild(ic); 285 286 if (arg.timeText) { 287 const t = document.createElement('span'); 288 t.className = 'cal-chip-time'; 289 t.textContent = arg.timeText; 290 wrap.appendChild(t); 291 } 292 const title = document.createElement('span'); 293 title.className = 'cal-chip-title'; 294 title.textContent = arg.event.title || '(untitled)'; 295 wrap.appendChild(title); 296 297 const dt = (ext.displayType || '').toLowerCase(); 298 const isModule = !(dt === 'calendarevent' || dt === 'preconcalendarevent'); 299 if (isModule) { 300 const a = document.createElement('span'); 301 a.className = 'cal-chip-affordance'; 302 a.textContent = 'â'; 303 a.setAttribute('aria-hidden', 'true'); 304 wrap.appendChild(a); 305 } 306 if ((arg.view?.type || '').startsWith('timeGrid')) { 307 const meta = [ext.type, ext.assigned, ext.location].filter(Boolean).join(' · '); 308 if (meta) { 309 const m = document.createElement('div'); 310 m.className = 'cal-chip-meta'; 311 m.textContent = meta; 312 wrap.appendChild(m); 313 } 314 } 315 return { domNodes: [wrap] }; 316} 317 318// ======================================== 319// HEADER + VIEW ORCHESTRATION 320// ======================================== 321 322function bindHeader() { 323 const opts = abortController ? { signal: abortController.signal } : {}; 324 325 document.getElementById('cal-prev')?.addEventListener('click', () => navigate(-1), opts); 326 document.getElementById('cal-next')?.addEventListener('click', () => navigate(1), opts); 327 document.getElementById('cal-today')?.addEventListener('click', () => goToday(), opts); 328 document.getElementById('btn-add-event')?.addEventListener('click', () => openEventModal(), opts); 329 330 document.getElementById('calendar-view-toggle')?.addEventListener('click', (e) => { 331 const btn = e.target.closest('[data-view]'); 332 if (btn) setView(btn.dataset.view); 333 }, opts); 334 335 // Month chip density â an instant CSS class swap on .month-scroll (no re-render, no refetch). 336 document.getElementById('calendar-density-toggle')?.addEventListener('click', (e) => { 337 const btn = e.target.closest('[data-density]'); 338 if (!btn || btn.dataset.density === currentDensity) return; 339 currentDensity = btn.dataset.density; 340 localStorage.setItem(STORAGE_DENSITY, currentDensity); 341 updateDensityToggle(); 342 applyDensity(); 343 }, opts); 344 345 // Project / Personal filter (searchable select). Select values: 'all' (real, visible 346 // option â not an empty placeholder), '0' = Personal, {id} = project. 'all' maps to the 347 // backend's '' (All). currentFilterId stays '' / '0' / {id}. 348 document.getElementById('calendar-filter')?.addEventListener('change', (e) => { 349 currentFilterId = e.target.value === 'all' ? '' : (e.target.value ?? ''); 350 refresh(); 351 }, opts); 352 353 document.getElementById('my-tasks-toggle')?.addEventListener('change', (e) => { 354 assignedToMe = e.target.checked; 355 localStorage.setItem(STORAGE_ASSIGNED, assignedToMe ? 'true' : 'false'); 356 refresh(); 357 }, opts); 358} 359 360async function navigate(delta) { 361 const pageSignal = abortController?.signal; 362 if (!await requestClosePopover()) return; 363 if (!pageSignal || pageSignal.aborted || pageSignal !== abortController?.signal) return; 364 if (currentView === 'day') { 365 const d = new Date(currentDate); 366 d.setDate(d.getDate() + delta); 367 currentDate = d; 368 renderDay(); 369 } else if (currentView === 'month') { 370 // Step whole months off the 1st (avoids the Jan-31 + 1mo â Mar overflow); monthGridRange 371 // derives the visible window from whatever day this is. 372 currentDate = new Date(currentDate.getFullYear(), currentDate.getMonth() + delta, 1); 373 renderMonth(); 374 } else if (calendar) { 375 delta < 0 ? calendar.prev() : calendar.next(); // week â datesSet syncs currentDate + URL 376 currentDate = calendar.getDate(); 377 } 378 syncUrl(); // idempotent; the custom month/day views (no datesSet) rely on this 379} 380 381async function goToday() { 382 const pageSignal = abortController?.signal; 383 if (!await requestClosePopover()) return; 384 if (!pageSignal || pageSignal.aborted || pageSignal !== abortController?.signal) return; 385 currentDate = new Date(); 386 if (currentView === 'day') renderDay(); 387 else if (currentView === 'month') renderMonth();
388 else if (calendar) { calendar.today(); currentDate = calendar.getDate(); } 389 syncUrl(); 390} 391 392async function setView(v, { date = null } = {}) { 393 if (!v || v === currentView) return; 394 const pageSignal = abortController?.signal; 395 if (!await requestClosePopover()) return; 396 if (!pageSignal || pageSignal.aborted || pageSignal !== abortController?.signal) return; 397 // Only Week is FC-driven now â grab its date before leaving so the next view opens in the same place. 398 if (date) currentDate = date; 399 else if (currentView === 'week' && calendar) currentDate = calendar.getDate(); 400 currentView = v; 401 updateViewToggle(); 402 applyView(); 403 syncUrl(); 404} 405 406/** Match the DOM + FullCalendar to currentView + currentDate. No URL push, no same-view guard â 407 * shared by setView (user), initCalendarPage (cold restore), and navigateCalendarPage (Back/Forward). */ 408function applyView() { 409 const fcEl = document.getElementById('calendar-el'); 410 const dayEl = document.getElementById('calendar-day-view'); 411 const monthEl = document.getElementById('calendar-month-view'); 412 const densityEl = document.getElementById('calendar-density-toggle'); 413 414 // Hide all three mounts + the density dial; the active branch reveals what it needs. 415 monthEl?.classList.add('hidden'); 416 fcEl?.classList.add('hidden'); 417 dayEl?.classList.add('hidden'); 418 densityEl?.classList.add('hidden'); 419 420 if (currentView === 'day') { 421 dayEl?.classList.remove('hidden'); 422 renderDay(); 423 } else if (currentView === 'week') { 424 fcEl?.classList.remove('hidden'); 425 calendar.changeView('timeGridWeek'); 426 calendar.gotoDate(currentDate); 427 calendar.updateSize(); // FC needs a resize after being un-hidden 428 } else { 429 // month â our custom grid; the density dial only applies here. 430 monthEl?.classList.remove('hidden'); 431 densityEl?.classList.remove('hidden'); 432 renderMonth(); 433 } 434} 435 436function buildCalendarUrl() { 437 return `/calendar/${currentView}/${isoLocal(currentDate)}`; 438} 439 440/** Push the URL to match current view + date â USER-driven only. Route-driven changes run under 441 * _routing (silent). page() re-dispatches â navigateCalendarPage, which no-ops when state already 442 * matches (so this never loops and never double-renders). */ 443function syncUrl() { 444 if (_routing) return; 445 const url = buildCalendarUrl(); 446 if (url !== window.location.pathname) window.page(url); 447} 448 449function updateViewToggle() { 450 document.querySelectorAll('#calendar-view-toggle [data-view]').forEach((b) => { 451 b.setAttribute('aria-pressed', b.dataset.view === currentView ? 'true' : 'false'); 452 }); 453} 454 455function updateDensityToggle() { 456 document.querySelectorAll('#calendar-density-toggle [data-density]').forEach((b) => { 457 b.setAttribute('aria-pressed', b.dataset.density === currentDensity ? 'true' : 'false'); 458 }); 459} 460 461/** Apply the current density as a class on the live .month-scroll root (instant; no re-render). */ 462function applyDensity() { 463 const scroll = document.querySelector('#calendar-month-view .month-scroll'); 464 if (!scroll) return; 465 scroll.classList.remove('dense-compact', 'dense-standard', 'dense-full'); 466 scroll.classList.add(`dense-${currentDensity}`); 467} 468 469/** Re-load the active view (after a filter change or a create/edit/complete/delete). */ 470function refresh() { 471 if (currentView === 'day') renderDay(); 472 else if (currentView === 'month') renderMonth(); 473 else calendar?.refetchEvents(); 474} 475 476/** 477 * Render the custom month grid (MonthScheduleView). Mirrors renderDay: we own currentDate, compute the 478 * visible window via monthGridRange, fetch that range, render, then apply the density class. The click 479 * path is unchanged â handleEventClick â openEventPopover â the existing mini-detail. 480 */ 481async function renderMonth() { 482 const monthEl = document.getElementById('calendar-month-view'); 483 if (!monthEl) return; 484 485 const gen = ++viewRenderGen; // snapshot before the await; a newer render (this or renderDay) invalidates this one 486 487 setTitle(currentDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })); 488 489 const { startIso, endIso } = monthGridRange(currentDate); 490 let events = []; 491 try { 492 events = await fetchEvents(startIso, endIso); 493 } catch (err) { 494 if (err.name === 'AbortError') return; 495 window.showToast?.('Failed to load events', 'error', { description: err.message }); 496 } 497 498 // Rapid month nav fires several fetches; if an older one resolves after a newer render, drop it so we 499 // never paint the wrong month's events under the right title. 500 if (gen !== viewRenderGen) return; 501 502 await renderMonthScheduleView(monthEl, currentDate, events, {
503 onEventClick: (ev, anchorEl) => handleEventClick(ev.extendedProps || {}, anchorEl, ev.title), 504 onEmptyClick: (dateISO) => openEventModal(null, dateISO), 505 // Day number â jump to that day's Day view after any open-draft decision. 506 onDayClick: (dateISO) => setView('day', { date: new Date(dateISO + 'T00:00:00') }), 507 }); 508 applyDensity(); 509} 510 511async function renderDay() { 512 const dayEl = document.getElementById('calendar-day-view'); 513 if (!dayEl) return; 514 515 const gen = ++viewRenderGen; // same stale-fetch guard as renderMonth (shared counter) 516 517 const iso = isoLocal(currentDate); 518 setTitle(currentDate.toLocaleDateString('en-US', { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })); 519 520 const next = new Date(currentDate); 521 next.setDate(next.getDate() + 1); 522 523 let events = []; 524 try { 525 events = await fetchEvents(iso, isoLocal(next)); 526 } catch (err) { 527 if (err.name === 'AbortError') return; 528 window.showToast?.('Failed to load events', 'error', { description: err.message }); 529 } 530 531 // Drop a fetch that resolved after a newer render (rapid day nav, or a switch to another view). 532 if (gen !== viewRenderGen) return; 533 534 await renderDayScheduleView(dayEl, iso, events, { 535 onEventClick: (ev, anchorEl) => handleEventClick(ev.extendedProps || {}, anchorEl, ev.title), 536 onEmptyClick: (dateISO) => openEventModal(null, dateISO), 537 }); 538} 539 540function setTitle(text) { 541 const el = document.getElementById('calendar-title'); 542 if (el) el.textContent = text || ''; 543} 544 545// ======================================== 546// EVENT CLICK â PREVIEW POPOVER (preview-first for everything) 547// ======================================== 548 549function handleEventClick(ext, anchorEl, title) { 550 if (anchorEl) { 551 openEventPopover(ext, anchorEl, title); 552 return; 553 } 554 // Fallback (e.g. deep-link with no anchor element). 555 const dt = (ext.displayType || '').toLowerCase(); 556 if (dt === 'calendarevent' || dt === 'preconcalendarevent') openEventDetail(ext.calendarEventId, { updateUrl: true }); 557 else routeToModule(ext); 558} 559 560async function openEventPopover(ext, anchorEl, title) { 561 const pageSignal = abortController?.signal; 562 if (!pageSignal || pageSignal.aborted) return; 563 if (!await requestClosePopover()) return; 564 if (pageSignal.aborted) return; 565 const openGen = ++popoverOpenGen; 566 567 const dt = (ext.displayType || '').toLowerCase(); 568 const isCalendarEvent = dt === 'calendarevent' || dt === 'preconcalendarevent'; 569 // Module project-tasks (RFI/SI/CHG/Submittal/clarification) carry the ProjectTaskID as 570 // calendarEventId, so we can fetch the unified mini-detail. ACCINVOICE carries an InvoiceId 571 // (different id space) and meeting-minutes have no task-dates â they stay the lean popover. 572 const isModuleTask = dt === 'projecttask' || dt === 'precontask'; 573 // Submittals render as a module task (displayType 'projecttask') but live in a separate table 574 // (becc_pm_projectDocCtrl) â fetched via the submittal endpoint, not the project-task one. 575 const isSubmittal = isModuleTask && (ext.pageUrl || '').toLowerCase() === 'sub'; 576 // Meeting-minute topics carry their MeetingID as calendarEventId + the topic id as meetingsTopicId. 577 const isMeetingMinute = dt === 'meetingminutes' || dt === 'preconmeetingminutes'; 578 // Sources that get a live comment thread: module tasks, calendar events, meeting-minutes. 579 // (ACCINVOICE â displayType 'accounting' â is the only dated source with no comment thread.) 580 const hasComments = isModuleTask || isCalendarEvent || isMeetingMinute; 581 // Discriminator for the mini-detail fetch / live-refresh thread / comment endpoint. 582 const kind = isSubmittal ? 'submittal' : isMeetingMinute ? 'meeting' : isCalendarEvent ? 'event' : 'task'; 583 const isCompleted = !!ext.taskCompletedDate; 584 // Status is derivable for ANY dated row (module due-dates included), not just calendar events. 585 const status = statusOf(ext.taskCompletedDate, ext.eventDateStart); 586 const moduleLabel = MODULE_LABELS[(ext.pageUrl || '').toLowerCase()] || 'item'; 587 588 const vm = { 589 title: title || '(untitled)', 590 // Calendar events carry a real type (Task/Call/Meeting); module rows show their module label. 591 typeText: ext.type || (isCalendarEvent ? '' : moduleLabel), 592 priorityKey: (ext.priorityKey || '').toUpperCase(), 593 priorityText: ext.priority || '', // data-gated: only calendar events carry priority 594 statusKey: status, 595 statusText: STATUS_TEXT[status] || '', 596 whenText: buildWhenText(ext), 597 assigned: ext.assigned || '', // legacy showed assigned + invited as separate rows 598 invited: ext.invited || '', 599 location: ext.location || '', 600 projectName: ext.projectName || '', 601 descriptionSnippet: (ext.description || '').slice(0, 240), 602 isCalendarEvent, 603 isModuleTask, 604 hasComments, 605 // Detail-style header (hard-hat project + type label + bold title) for module tasks, calendar 606 // events, AND meeting-minutes â only ACCINVOICE stays the lean header. Same set as hasComments. 607 hasDetailHeader: hasComments, 608 isCompleted, 609 moduleLabel, 610 // Type label above the title: module â entity label; event â its Task/Call/Meeting type; 611 // meeting-minute â "Meeting Topic". (Module #-ref + project subtitle still fill on fetch.) 612 typeLabel: isModuleTask ? (MODULE_TYPE_LABELS[(ext.pageUrl || '').toLowerCase()] || moduleLabel) 613 : isCalendarEvent ? (ext.type || 'Event') 614 : isMeetingMinute ? 'Meeting Topic' 615 : '', 616 }; 617 618 // Render into a detached host; createPortalPopover portals firstElementChild to body.
619 // The await ensures the opening click has fully finished before we bind outside-click. 620 const host = document.createElement('div'); 621 await renderTo(host, '#tpl-calendar-event-popover', vm); 622 if (openGen !== popoverOpenGen || anchorEl.isConnected === false || pageSignal.aborted) return; 623 const popEl = host.firstElementChild; 624 if (!popEl) return; 625 626 // Side placement (right/left of the chip) so the popover never covers the day's other chips â 627 // legacy parity. createPortalPopover flips side near the viewport edge + drives the arrow. 628 const cleanup = createPortalPopover(anchorEl, popEl, { offset: 12, placement: 'side' }); 629 const backdropCleanup = hasComments 630 ? createMobilePopoverBackdrop(popEl, () => { void requestClosePopover(); }) 631 : null; 632 633 // popEl was rendered detached; createIcons({ root }) draws it either way â this pass covers portal re-parenting (CA-620). 634 window.lucide?.createIcons({ root: popEl }); 635 636 const onDocClick = (e) => { 637 if (activePopover?.el !== popEl || activePopover.dismissPending) return; 638 if (!popEl.contains(e.target) && !anchorEl.contains(e.target)) { 639 // These Calendar controls make their own awaited close request, so let the original 640 // click continue; otherwise a confirmed discard would force an unnecessary re-click. 641 if (e.target.closest('#cal-prev, #cal-next, #cal-today, #calendar-view-toggle [data-view], [data-event-key], [data-day-goto], .fc-event')) return; 642 if (popoverHasDraft(activePopover)) { 643 // The background click must not navigate or open another record while the user 644 // is deciding whether to discard the comment. They can repeat it after consent. 645 e.preventDefault(); 646 e.stopPropagation(); 647 } 648 void requestClosePopover(); 649 } 650 }; 651 const onKey = (e) => { 652 if (e.key !== 'Escape' || activePopover?.el !== popEl || activePopover.dismissPending) return; 653 e.preventDefault(); 654 e.stopPropagation(); 655 void requestClosePopover(); 656 }; 657 document.addEventListener('click', onDocClick, true); 658 document.addEventListener('keydown', onKey); 659 activePopover = { cleanup, backdropCleanup, el: popEl, anchorEl, onDocClick, onKey, dismissPending: false }; 660 if (hasComments) popEl.querySelector('[data-pop-close]')?.focus({ preventScroll: true }); 661 662 popEl.querySelectorAll('[data-pop-close]').forEach(b => b.addEventListener('click', () => { void requestClosePopover(); })); 663 popEl.querySelector('[data-pop-open]')?.addEventListener('click', async () => { 664 const actionSignal = abortController?.signal; 665 const id = ext.calendarEventId; 666 if (!await requestClosePopover()) return; 667 if (!actionSignal || actionSignal.aborted || actionSignal !== abortController?.signal) return; 668 openEventDetail(id, { updateUrl: true }); 669 }); 670 popEl.querySelector('[data-pop-goto]')?.addEventListener('click', async () => { 671 const actionSignal = abortController?.signal; 672 if (!await requestClosePopover()) return; 673 if (!actionSignal || actionSignal.aborted || actionSignal !== abortController?.signal) return; 674 routeToModule(ext, { newTab: isModuleTask }); // module "View" â new tab (keep calendar position) 675 }); 676 popEl.querySelector('[data-pop-complete]')?.addEventListener('click', async (e) => { 677 const actionSignal = abortController?.signal; 678 if (!await requestClosePopover({ keepOpen: true })) return; 679 if (!actionSignal || actionSignal.aborted || actionSignal !== abortController?.signal 680 || activePopover?.el !== popEl) return; 681 const id = ext.calendarEventId; 682 runWithBusyButton(e.currentTarget, 'Completingâ¦', async () => { 683 await projectCalendarApi.completeEvent(id, ''); 684 if (actionSignal.aborted || actionSignal !== abortController?.signal || activePopover?.el !== popEl) return; 685 closePopover(); 686 window.showToast?.('Event completed', 'success'); 687 refresh(); 688 }, { errorTitle: 'Failed to complete event' }); 689 }); 690 691 // Sources with a comment thread (module tasks, calendar events, meeting-minutes) â upgrade the 692 // popover to a mini-detail: fetch the thread (+ task-dates for module tasks), wire the composer 693 // (Ctrl/Cmd+Enter to post), and live-refresh while open. 694 if (hasComments) { 695 const addBtn = popEl.querySelector('[data-pop-add-comment]'); 696 addBtn?.addEventListener('click', () => postPopoverComment(addBtn, popEl, ext, kind)); 697 popEl.querySelector('#cal-pop-comment-input')?.addEventListener('keydown', (e) => { 698 if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); addBtn?.click(); } 699 }); 700 loadPopoverDetail(ext, popEl, kind); 701 } 702} 703 704function popoverHasDraft(owner) { 705 return !!owner?.el.querySelector('#cal-pop-comment-input')?.value.trim(); 706} 707 708/** Ask before abandoning authored text; the same decision owns outside, Escape, Close and actions. */ 709function requestClosePopover({ keepOpen = false } = {}) { 710 const owner = activePopover; 711 if (!owner) return true; 712 if (owner.dismissPending) return false;
713 if (owner.posting) { 714 window.showToast?.('Comment is still posting', 'info'); 715 return false; 716 } 717 if (!popoverHasDraft(owner)) { 718 if (!keepOpen) closePopover(); 719 return true; 720 } 721 722 owner.dismissPending = true; 723 return (async () => { 724 let discard; 725 try { 726 discard = await confirm({ 727 title: 'Discard comment?', 728 message: 'Your unsent comment will be lost.', 729 icon: 'message-circle', 730 confirmText: 'Discard', 731 confirmClass: 'btn-st-error', 732 signal: abortController?.signal, 733 }); 734 } catch (error) { 735 if (activePopover === owner) { 736 owner.dismissPending = false; 737 window.showToast?.('Could not open discard confirmation', 'error', { description: error.message }); 738 } 739 return false; 740 } 741 if (activePopover !== owner) return false; 742 owner.dismissPending = false; 743 if (!discard) { 744 owner.el.querySelector('#cal-pop-comment-input')?.focus({ preventScroll: true }); 745 return false; 746 } 747 if (keepOpen) owner.el.querySelector('#cal-pop-comment-input').value = ''; 748 else closePopover(); 749 return true; 750 })(); 751} 752 753/** Force teardown only for a completed action or page destruction; user exits use requestClosePopover. */ 754function closePopover({ restoreFocus = true } = {}) { 755 popoverOpenGen++; 756 if (!activePopover) return; 757 const { cleanup, backdropCleanup, el, anchorEl, onDocClick, onKey, sub } = activePopover; 758 activePopover = null; 759 sub?.dispose(); // TaskGroupSync live-refresh subscription (module mini-detail), if any 760 document.removeEventListener('click', onDocClick, true); 761 document.removeEventListener('keydown', onKey); 762 backdropCleanup?.(); 763 cleanup?.(); // removes reposition listeners (does NOT remove the node) 764 el?.remove(); // remove the portaled popover 765 if (restoreFocus && anchorEl?.isConnected) anchorEl.focus({ preventScroll: true }); 766} 767 768/** 769 * Mini-detail fetchers â every popover source resolves to the SAME uniform shape 770 * ({ assignees, taskDates, comments, ... }) so renderPopoverDetail/renderPopoverComments never 771 * change. Module tasks + submittals have dedicated calendar endpoints; calendar events and 772 * meeting-minutes reuse their own module APIs, normalized to the uniform shape here. 773 */ 774function fetchMiniDetail(ext, kind) { 775 const id = ext.calendarEventId; 776 switch (kind) { 777 case 'submittal': return calendarApi.getSubmittalDetail(id); 778 case 'event': return projectCalendarApi.getEventDetail(id).then(normalizeEventDetail); 779 case 'meeting': return projectMeetingsApi 780 .getTopicComments(ext.projectId, id, ext.meetingsTopicId) 781 .then(normalizeMeetingTopicDetail); 782 default: return calendarApi.getTaskDetail(id); // 'task' 783 } 784} 785 786/** Live-refresh thread id per source (matches the backend Notify* thread conventions). */ 787function threadFor(ext, kind) { 788 switch (kind) { 789 case 'submittal': return `submittal-${ext.calendarEventId}`; 790 case 'event': return `calagsn-${ext.calendarEventId}`; 791 case 'meeting': return `meeting-topic-${ext.meetingsTopicId}`; 792 default: return `asgn-${ext.calendarEventId}`; 793 } 794} 795 796/** getEventDetail() â uniform shape. Events have no task-dates; assignee shows in the lean rows. */ 797function normalizeEventDetail(d) { 798 return { 799 referenceNumber: '', projectNumber: '', assignedUserName: '', assignees: [], taskDates: [], 800 comments: (d?.comments || []).map(mapEventComment), 801 }; 802} 803 804/** getTopicComments() â uniform shape. Meeting-minutes have no task-dates; assignee in the lean rows. */ 805function normalizeMeetingTopicDetail(resp) { 806 return { 807 referenceNumber: '', projectNumber: '', assignedUserName: '', assignees: [], taskDates: [], 808 comments: (resp?.comments || []).map(mapMeetingComment), 809 }; 810} 811 812const mapEventComment = (c) => ({ createdByName: c.author || 'Unknown', comment: c.text || '', dateCreatedDisplay: c.date || '' }); 813const mapMeetingComment = (c) => ({ createdByName: c.createdByName || 'Unknown', comment: c.comment || '', dateCreatedDisplay: c.dateCreated ? formatDate(c.dateCreated) : '' }); 814 815function setPopoverDetailAvailability(popEl, available) { 816 const input = popEl.querySelector('#cal-pop-comment-input'); 817 const addBtn = popEl.querySelector('[data-pop-add-comment]'); 818 popEl.querySelectorAll('[data-pop-detail-region]').forEach(region => { 819 region.classList.toggle('hidden', !available); 820 }); 821 if (input) input.disabled = !available; 822 if (addBtn) addBtn.disabled = !available; 823} 824 825async function renderPopoverDetailState(popEl, state) { 826 const statusEl = popEl.querySelector('#cal-pop-detail-status'); 827 if (!statusEl) return activePopover?.el === popEl; 828 829 if (state === 'ready') { 830 statusEl.replaceChildren(); 831 statusEl.classList.add('hidden'); 832 setPopoverDetailAvailability(popEl, true); 833 return activePopover?.el === popEl; 834 } 835 836 setPopoverDetailAvailability(popEl, false); 837 statusEl.classList.remove('hidden'); 838 await renderTo(statusEl, '#tpl-calendar-mini-detail-state', { 839 isLoading: state === 'loading', 840 isDenied: state === 'denied', 841 canRetry: state === 'error', 842 message: state === 'denied' 843 ? 'You do not have access to this item.' 844 : state === 'error' 845 ? 'Could not load these details.' 846 : 'Loading detailsâ¦', 847 }); 848 return activePopover?.el === popEl; 849} 850 851/** 852 * Popover â fetch the unified mini-detail, fill the skeleton regions, then subscribe to live 853 * updates. Guards against the popover being closed/replaced mid-fetch. The fetch + thread vary by 854 * source kind (task / submittal / event / meeting); the rendered shape is uniform. 855 */ 856async function loadPopoverDetail(ext, popEl, kind) { 857 if (activePopover?.el !== popEl) return; 858 if (!await renderPopoverDetailState(popEl, 'loading')) return; 859 860 let detail; 861 try { 862 detail = await fetchMiniDetail(ext, kind); 863 } catch (err) { 864 if (err.name === 'AbortError') return; // navigated away mid-fetch â popover is already gone 865 if (activePopover?.el !== popEl) return; 866 867 const state = err.status === 403 ? 'denied' : 'error'; 868 if (!await renderPopoverDetailState(popEl, state)) return; 869 if (state === 'error') { 870 popEl.querySelector('[data-pop-retry-detail]')?.addEventListener('click', () => { 871 if (activePopover?.el === popEl) return loadPopoverDetail(ext, popEl, kind); 872 return undefined; 873 }, { once: true }); 874 } 875 return; 876 } 877 if (activePopover?.el !== popEl) return; // popover was closed/replaced during the fetch 878 879 renderPopoverDetail(popEl, detail); 880 await renderPopoverDetailState(popEl, 'ready'); 881 if (activePopover?.el !== popEl) return; 882 activePopover.cleanup?.reposition?.(); // content grew â re-clamp to viewport 883
884 // Live comment refresh: re-fetch via the same source on a refreshTaskComments ping. 885 activePopover.sub = subscribeTaskUpdates(threadFor(ext, kind), async () => { 886 if (activePopover?.el !== popEl) return; 887 try { 888 const fresh = await fetchMiniDetail(ext, kind); 889 if (activePopover?.el === popEl) { 890 renderPopoverDetail(popEl, fresh); 891 activePopover.cleanup?.reposition?.(); 892 } 893 } catch { /* ignore transient refresh errors */ } 894 }); 895} 896 897/** Fill the popover's Assigned / Task-Dates / Comments regions from a getTaskDetail() payload. */ 898function renderPopoverDetail(popEl, detail) { 899 // Header fields that only arrive with the fetch: reference # + project subtitle (number). 900 const refEl = popEl.querySelector('[data-ref-number]'); 901 if (refEl) refEl.textContent = detail.referenceNumber || ''; 902 const subEl = popEl.querySelector('[data-project-subtitle]'); 903 if (subEl) { 904 const sub = detail.projectNumber ? `No. ${detail.projectNumber}` : ''; 905 subEl.textContent = sub; 906 subEl.classList.toggle('hidden', !sub); 907 } 908 909 const assignedEl = popEl.querySelector('#cal-pop-assigned'); 910 if (assignedEl) { 911 const names = (detail.assignees || []).map(a => a.name).filter(Boolean); 912 const text = names.length ? names.join(', ') : (detail.assignedUserName || ''); 913 const target = assignedEl.querySelector('[data-assigned-names]'); 914 if (text && target) { 915 target.textContent = text; 916 assignedEl.classList.remove('hidden'); 917 assignedEl.classList.add('flex'); 918 } else { 919 assignedEl.classList.add('hidden'); 920 } 921 } 922 923 // Highlight the next-due row (first not-completed, not-N/A) â the milestone the clicked chip represents. 924 const datesEl = popEl.querySelector('#cal-pop-dates'); 925 if (datesEl) { 926 const dates = detail.taskDates || []; 927 const activeIdx = dates.findIndex(d => !d.dateCompletedDisplay && !d.isNA); 928 const rows = dates.map((d, i) => ({ ...d, isActive: i === activeIdx })); 929 renderTo(datesEl, '#tpl-cal-pop-dates', { rows }); 930 } 931 932 renderPopoverComments(popEl, detail.comments || []); 933} 934 935/** Render the popover comment thread (normalized to the shared comment-row shape). */ 936function renderPopoverComments(popEl, comments) { 937 const el = popEl.querySelector('#cal-pop-comments'); 938 if (!el) return; 939 const rows = (comments || []).map(c => ({ 940 author: c.createdByName || 'Unknown', text: c.comment || '', dateText: c.dateCreatedDisplay || '', 941 })); 942 if (rows.length) renderTo(el, '#tpl-calendar-comment-row', rows); 943 else renderTo(el, '#tpl-calendar-activity-empty', { label: 'No comments yet.' }); 944 945 const countEl = popEl.querySelector('[data-comment-count]'); 946 if (countEl) { 947 countEl.textContent = String(rows.length); 948 countEl.classList.toggle('hidden', rows.length === 0); 949 } 950} 951 952/** Post a comment from the popover â backend notifies the assignees â re-render the thread. */ 953async function postPopoverComment(btn, popEl, ext, kind) { 954 if (activePopover?.el !== popEl) return; 955 const owner = activePopover; 956 const input = popEl.querySelector('#cal-pop-comment-input'); 957 const text = (input?.value || '').trim(); 958 if (!text) { window.showToast?.('Enter a comment first', 'info'); return; } 959 if (btn.dataset.busy === '1') return; 960 btn.dataset.busy = '1'; 961 owner.posting = true; 962 setLoading(btn, true, '', { iconOnly: true }); 963 try { 964 const res = await addPopoverComment(ext, kind, text); 965 if (activePopover?.el === popEl) { 966 renderPopoverComments(popEl, res.comments || []); 967 if (input) input.value = ''; 968 window.showToast?.('Comment posted', 'success'); 969 } 970 } catch (err) { 971 if (err.name === 'AbortError') return; // navigated away mid-post â finally still resets the button 972 if (activePopover?.el === popEl) { 973 window.showToast?.('Failed to post comment', 'error', { description: err.message }); 974 } 975 } finally { 976 owner.posting = false;
977 if (activePopover === owner) { 978 setLoading(btn, false); 979 btn.dataset.busy = ''; 980 } 981 } 982} 983 984/** Post a comment for any source kind; resolves to { comments } in the uniform shape. */ 985async function addPopoverComment(ext, kind, text) { 986 const id = ext.calendarEventId; 987 switch (kind) { 988 case 'submittal': return calendarApi.addSubmittalComment(id, text); 989 case 'event': { 990 const comments = await projectCalendarApi.addEventComment(id, text); // returns mapped [{author,text,date}] 991 return { comments: (comments || []).map(mapEventComment) }; 992 } 993 case 'meeting': { 994 await projectMeetingsApi.addComment(ext.projectId, id, ext.meetingsTopicId, { comment: text }); 995 const resp = await projectMeetingsApi.getTopicComments(ext.projectId, id, ext.meetingsTopicId); 996 return { comments: (resp?.comments || []).map(mapMeetingComment) }; 997 } 998 default: return calendarApi.addTaskComment(id, text); // 'task' 999 } 1000} 1001 1002function buildWhenText(ext) { 1003 const parts = []; 1004 if (ext.eventDateStart) parts.push(formatDate(ext.eventDateStart)); 1005 if (ext.eventStartTime) { 1006 let t = formatTimeDisplay(ext.eventStartTime); 1007 if (ext.eventEndTime) t += ` â ${formatTimeDisplay(ext.eventEndTime)}`; 1008 parts.push(t); 1009 } 1010 // Duration ("2 Hours") adds info only when no explicit end time already conveys the span. 1011 // List events carry durationVal/durationType (NOT durationText â that backend field is unset 1012 // on the list payload), so build the label here like the detail modal does (calendar.js:~785). 1013 const dur = durationLabel(ext); 1014 if (dur && !ext.eventEndTime) parts.push(dur); 1015 return parts.join(' · '); 1016} 1017 1018/** Human duration ("2 Hours") from a list event's durationVal/durationType; '' when none/zero. */ 1019function durationLabel(ext) { 1020 const val = Number(ext.durationVal); 1021 if (!val || val <= 0) return ''; 1022 return `${ext.durationVal} ${DURATION_TYPE_LABELS[ext.durationType] || ''}`.trim(); 1023} 1024 1025/** 1026 * Route a module event to its owning page, deep-linking the specific RECORD where the 1027 * target sub-module's route opens it by the id the calendar carries (calendarEventId); 1028 * otherwise route to the module tab. Mapping verified 2026-06-24 â see 1029 * porting-maps/audits/calendar-audit-scorecard.md "Go-To deep-link spec". 1030 * ⢠Record-deep-linkable (calendarEventId IS the id the target opens): contract-admin 1031 * rfi/si/chg/sub (ProjectTaskID), contract-admin meetings from a meeting-minute 1032 * (MeetingID â the meeting itself), precon desclar/tenderclar (clarification item), 1033 * accounting/invoices (InvoiceID â backend now resolves the linked invoice id). 1034 * ⢠Tab-only (id space doesn't match â would silently mis-route): precon meeting-minutes 1035 * (meetings tab takes no id; 'meeting'â'meetings' slug fix). The !!recordId guard also 1036 * routes any row with no resolvable record id (e.g. an unlinked invoice) to the tab. 1037 */ 1038function routeToModule(ext, { newTab = false } = {}) { 1039 const projectId = ext.projectId; 1040 const recordId = ext.calendarEventId; 1041 const pageUrl = (ext.pageUrl || '').toLowerCase(); 1042 const displayType = (ext.displayType || '').toLowerCase(); 1043 1044 if (!projectId || !pageUrl) { 1045 window.showToast?.('Open the related module to view this item', 'info'); 1046 return; 1047 } 1048 1049 let base = 'contract-admin'; 1050 if (displayType === 'accounting') base = 'accounting'; 1051 else if (displayType.startsWith('precon')) base = 'pre-construction'; 1052 1053 // Legacy â new-app slug fixups (only known divergence: precon meeting-minutes emit singular 'meeting'). 1054 const slug = pageUrl === 'meeting' ? 'meetings' : pageUrl; 1055 1056 const canDeepLink = !!recordId && ( 1057 (base === 'contract-admin' && displayType === 'projecttask' && ['rfi', 'si', 'chg', 'sub'].includes(pageUrl)) || 1058 (base === 'contract-admin' && displayType === 'meetingminutes' && slug === 'meetings') || 1059 (base === 'pre-construction' && displayType === 'precontask' && ['desclar', 'tenderclar'].includes(pageUrl)) || 1060 (base === 'accounting' && displayType === 'accounting' && pageUrl === 'invoices') 1061 ); 1062 1063 const url = canDeepLink 1064 ? `/${base}/${projectId}/${slug}/${recordId}` 1065 : `/${base}/${projectId}/${slug}`; 1066 // From the popover "View" we open a new tab so the user keeps their calendar month/scroll 1067 // position (the calendar isn't URL-routed yet â see the calendar-routing follow-up). 1068 if (newTab) { window.open(url, '_blank', 'noopener'); return; } 1069 if (typeof window.page === 'function') window.page(url); 1070 else window.location.href = url; 1071} 1072 1073// ======================================== 1074// FILTER / MY-TASKS 1075// ======================================== 1076 1077async function loadProjects() { 1078 try { 1079 const resp = await projectsApi.getProjects(); 1080 const list = Array.isArray(resp) ? resp : (resp?.projects || resp?.items || []); 1081 projectsCache = list.map(p => ({ 1082 projectId: p.projectId ?? p.id, 1083 projectTitle: p.projectTitle ?? p.title ?? p.name ?? '(Untitled)', 1084 projectNumber: p.projectNumber ?? p.number ?? '', 1085 })).filter(p => p.projectId); 1086 } catch (error) { 1087 if (error.name === 'AbortError') return; 1088 projectsCache = []; 1089 } 1090 populateFilterDropdown(); 1091} 1092 1093/** 1094 * Populate the searchable filter select with the project options (after the static 1095 * All Events / Personal defaults), then re-sync the enhanced dropdown. Mirrors 1096 * messages.js populateProjectDropdown. XSS-safe (textContent, not innerHTML). 1097 */ 1098function populateFilterDropdown() { 1099 const select = document.getElementById('calendar-filter'); 1100 if (!select) return; 1101 // Drop any previously-appended project options; keep the 'all' All / '0' Personal defaults. 1102 select.querySelectorAll('option').forEach((o) => { 1103 if (o.value !== 'all' && o.value !== '0') o.remove(); 1104 }); 1105 for (const p of projectsCache) { 1106 const opt = document.createElement('option');
1107 opt.value = String(p.projectId); 1108 opt.textContent = p.projectNumber ? `(${p.projectNumber}) ${p.projectTitle}` : p.projectTitle; 1109 select.appendChild(opt); 1110 } 1111 // currentFilterId '' (All) â select value 'all'; '0' / {id} map directly. 1112 select.value = currentFilterId === '' ? 'all' : currentFilterId; 1113 const inst = SearchableDropdown.getInstance(select); 1114 inst?.refresh(); 1115 // Tag the panel itself so the calendar-only width/truncation CSS can target it: the 1116 // component portals the panel to <body> on open (createPortalPopover), so a 1117 // #calendar-container selector never matches it. The class rides with the element. 1118 inst?.dropdown?.classList.add('calendar-filter-dropdown'); 1119} 1120 1121function syncMyTasksToggle() { 1122 const toggle = document.getElementById('my-tasks-toggle'); 1123 if (toggle) toggle.checked = assignedToMe; 1124} 1125 1126// ======================================== 1127// CREATE / EDIT MODAL 1128// ======================================== 1129 1130function isoToday() { 1131 const d = new Date(); 1132 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; 1133} 1134 1135function isoLocal(d) { 1136 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; 1137} 1138 1139/** YYYY-MM-DD from an ISO/Date-ish value (for the DatePicker value on edit). */ 1140function toIsoDate(value) { 1141 if (!value) return ''; 1142 const d = new Date(value); 1143 if (isNaN(d.getTime())) return typeof value === 'string' ? value.slice(0, 10) : ''; 1144 return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; 1145} 1146 1147/** Coerce a stored start time ("09:00" or "9:00 AM") to the 24h "HH:mm" the editor needs. */ 1148function to24hTime(value) { 1149 if (!value) return ''; 1150 const m = /^(\d{1,2}):(\d{2})\s*(AM|PM)?$/i.exec(value.trim()); 1151 if (!m) return value; 1152 let hh = parseInt(m[1], 10); 1153 const mm = m[2]; 1154 const mer = (m[3] || '').toUpperCase(); 1155 if (mer === 'PM' && hh < 12) hh += 12; 1156 if (mer === 'AM' && hh === 12) hh = 0; 1157 return `${String(hh).padStart(2, '0')}:${mm}`; 1158} 1159 1160/** 1161 * Open the create/edit event modal. 1162 * @param {object|null} existing â normalized event (from openEventDetail) for edit; null = create. 1163 * @param {string} [defaultDate] â YYYY-MM-DD to preselect on create (from a date-cell click). 1164 */ 1165async function openEventModal(existing = null, defaultDate) { 1166 const isEdit = !!existing; 1167 const projectIdForEdit = isEdit ? (existing.typeCode === 'PRO' ? existing.typeId : 0) : 0; 1168 1169 const modalData = isEdit ? { 1170 calendarEventId: existing.id, 1171 typeCode: existing.typeCode, 1172 projectId: projectIdForEdit, 1173 type: existing.taskType, 1174 priority: existing.priority, 1175 title: existing.title, 1176 eventDateStart: toIsoDate(existing.dateStart), 1177 eventStartTime: to24hTime(existing.startTime), 1178 eventDuration: (existing.duration && Number(existing.duration) > 0) ? existing.duration : '', 1179 durationType: existing.durationType || 'M', 1180 description: existing.description, 1181 location: existing.location, 1182 projects: projectsCache, 1183 } : { 1184 eventDateStart: defaultDate || isoToday(), 1185 type: 'T', 1186 priority: 'M', 1187 projects: projectsCache, 1188 }; 1189 1190 // Backdrop-click closes here too â while the composer is UNTOUCHED. The calendar is an 1191 // all-click-and-explore surface, so refusing to close an empty form punishes a legitimate 1192 // "I clicked to see what it does" instinct (Dan's call). Once the user has actually entered 1193 // something, the app-wide unsaved-input guard takes over and a mis-click outside is refused; 1194 // the X asks before discarding. That is what "the guard subsumes both calendar opt-ins" means 1195 // â the opt-in stays, its blast radius does not. 1196 const modal = await showModal('#tpl-calendar-event-modal', modalData, { 1197 ...(abortController ? { signal: abortController.signal } : {}), 1198 closeOnBackdrop: true, 1199 }); 1200 const el = modal.element; 1201 1202 const projectSel = el.querySelector('#calendar-event-project'); 1203 const typeSel = el.querySelector('#calendar-event-type'); 1204 1205 await renderPeopleFields(el, selectedProjectId(projectSel), existing); 1206 1207 projectSel?.addEventListener('change', async () => { 1208 await renderPeopleFields(el, selectedProjectId(projectSel), null); 1209 }); 1210 typeSel?.addEventListener('change', () => syncTypeFields(el, typeSel)); 1211 1212 el.querySelector('#calendar-event-save') 1213 ?.addEventListener('click', () => handleSave(modal, el, existing)); 1214} 1215 1216function selectedProjectId(projectSel) { 1217 return parseInt(projectSel?.value || '0', 10) || 0; 1218} 1219 1220/** 1221 * Render the assign/invite/location fields. Project events (projectId > 0) get project members (Assign + 1222 * member invites) and project contacts from getTaskAddOptions. Personal events (projectId 0) can't assign 1223 * (no project members) but CAN invite the company's contacts â reuses the C
1223ontacts list endpoint 1224 * (`contactsApi.getAll`); the backend resolves each contact's email from its CompanyContactID on ICS send. 1225 */ 1226async function renderPeopleFields(el, projectId, existing) { 1227 const container = el.querySelector('#calendar-event-people'); 1228 if (!container) return; 1229 1230 const isProject = projectId > 0; 1231 let members = []; 1232 let contacts = []; 1233 if (isProject) { 1234 try { 1235 const opts = await projectCalendarApi.getTaskAddOptions(projectId); 1236 members = opts.members || []; 1237 contacts = opts.contacts || []; 1238 } catch (error) { 1239 if (error.name === 'AbortError') return; 1240 } 1241 } else { 1242 // Personal event â invite from the company contact list (same {id}|C token space as project contacts). 1243 try { 1244 const resp = await contactsApi.getAll(); 1245 const list = resp?.results || resp?.Results || []; 1246 contacts = list.map(c => ({ 1247 id: c.companyContactId, 1248 name: [c.firstName, c.lastName].filter(Boolean).join(' ').trim() || c.emailAddress || 'Unknown', 1249 companyName: c.companyName || '', 1250 })).filter(c => c.id); 1251 } catch (error) { 1252 if (error.name === 'AbortError') return; 1253 } 1254 } 1255 1256 // Precompute selected flags so options render pre-selected (reliable across the 1257 // SearchableDropdown enhancement renderTo triggers â a post-render setValue races it). 1258 const assignedSet = new Set((existing?.assignedIds || []).map(String)); 1259 const invitedSet = new Set(existing?.invitedTokens || []); 1260 const memberOpts = members.map(m => ({ 1261 ...m, 1262 assignSelected: assignedSet.has(String(m.id)), 1263 inviteSelected: invitedSet.has(`${m.id}|M`), 1264 })); 1265 const contactOpts = contacts.map(c => ({ 1266 ...c, 1267 inviteSelected: invitedSet.has(`${c.id}|C`), 1268 })); 1269 1270 await renderTo(container, '#tpl-calendar-event-people', { 1271 members: memberOpts, 1272 contacts: contactOpts, 1273 canAssign: isProject, // Assign (members) is project-only 1274 canInvite: memberOpts.length > 0 || contactOpts.length > 0, // Invite shows whenever there's anyone to invite 1275 location: existing?.location || '', 1276 }); 1277 1278 syncTypeFields(el, el.querySelector('#calendar-event-type')); 1279} 1280 1281/** Show Assign for Task, Invite for Meeting/Call, Location for Meeting. */ 1282function syncTypeFields(el, typeSel) { 1283 const t = typeSel?.value || 'T'; 1284 el.querySelector('[data-cal-field="assign"]')?.classList.toggle('hidden', t !== 'T'); 1285 el.querySelector('[data-cal-field="invite"]')?.classList.toggle('hidden', t !== 'M' && t !== 'C'); 1286 el.querySelector('[data-cal-field="location"]')?.classList.toggle('hidden', t !== 'M'); 1287} 1288 1289function handleSave(modal, el, existing) { 1290 const isEdit = !!existing; 1291 const titleEl = el.querySelector('#calendar-event-title'); 1292 const dateEl = el.querySelector('#calendar-event-date'); 1293 const priorityEl = el.querySelector('#calendar-event-priority'); 1294 1295 const type = isEdit ? existing.taskType : (el.querySelector('#calendar-event-type')?.value || 'T'); 1296 const projectId = isEdit ? (existing.typeCode === 'PRO' ? existing.typeId : 0) 1297 : selectedProjectId(el.querySelector('#calendar-event-project')); 1298 const isProject = projectId > 0; 1299 1300 const assignEl = el.querySelector('#calendar-event-assign'); 1301 const inviteEl = el.querySelector('#calendar-event-invite'); 1302 const durationEl = el.querySelector('#calendar-event-duration'); 1303 1304 const rules = [ 1305 { el: titleEl, label: 'Title' }, 1306 { el: dateEl, label: 'Date' }, 1307 { el: priorityEl, label: 'Priority' }, 1308 // Joins the SAME pass rather than gating separately (MCF-0425): a missing title and a 1309 // malformed duration are reported together, in one round. 1310 { el: durationEl, label: 'Duration', numeric: CALENDAR_EVENT_DURATION, required: false }, 1311 ]; 1312 if (isProject && type === 'T' && assignEl) rules.push({ el: assignEl, label: 'Assign To' }); 1313 else if (isProject && (type === 'M' || type === 'C') && inviteEl) rules.push({ el: inviteEl, label: 'Invite People' }); 1314 if (!validateRequired(el, rules)) return; 1315 1316 // Round-trip the FULL current set for BOTH lists (backend UpdateAsync removes-all-then-re-adds). 1317 let assignedIds = isEdit ? (existing.assignedIds || []).map(v => parseInt(v, 10)).filter(v => v > 0) : []; 1318 let invitations = isEdit ? [...(existing.invitedTokens || [])] : []; 1319 if (isProject && type === 'T' && assignEl) { 1320 const raw = SearchableDropdown.getInstance(assignEl)?.getValue() ?? []; 1321 assignedIds = (Array.isArray(raw) ? raw : [raw]).map(v => parseInt(v, 10)).filter(v => v > 0); 1322 } else if ((type === 'M' || type === 'C') && inviteEl) { 1323 // Invitations apply to project AND personal Meeting/Call (personal invites company contacts). 1324 const raw = SearchableDropdown.getInstance(inviteEl)?.getValue() ?? []; 1325 invitations = (Array.isArray(raw) ? raw : [raw]).filter(
1325Boolean); 1326 } 1327 1328 // Exact `Number` of text the gate above has already proven readable. The `(duration && duration 1329 // > 0) ? duration : null` publication below is UNCHANGED and stays the contract: blank publishes 1330 // `null`, and a deliberate `0` also publishes `null` because a zero-length event has no 1331 // duration. What changed is that garbage no longer reaches it â it is refused with a sentence 1332 // instead of being silently converted to `NaN` and then thrown away as `null`. 1333 const durationRaw = durationEl?.value.trim(); 1334 const duration = durationRaw ? Number(durationRaw) : null; 1335 const locationEl = el.querySelector('#calendar-event-location'); 1336 1337 const body = { 1338 typeCode: isProject ? 'PRO' : 'USR', 1339 typeId: isProject ? projectId : 0, 1340 title: titleEl.value.trim(), 1341 description: el.querySelector('#calendar-event-description')?.value.trim() || '', 1342 type, 1343 priority: priorityEl.value, 1344 eventDateStart: dateEl.value, 1345 eventStartTime: el.querySelector('#calendar-event-time')?.value || '', 1346 location: (type === 'M' && locationEl) ? locationEl.value.trim() : '', 1347 eventDuration: (duration && duration > 0) ? duration : null, 1348 eventDurationType: el.querySelector('#calendar-event-duration-type')?.value || 'M', 1349 }; 1350 1351 runWithBusyButton(el.querySelector('#calendar-event-save'), isEdit ? 'Savingâ¦' : 'Creatingâ¦', async () => { 1352 if (isEdit) { 1353 body.calendarEventId = existing.id; 1354 await calendarApi.update(existing.id, body, assignedIds, invitations); 1355 window.showToast?.('Event updated', 'success'); 1356 } else { 1357 await calendarApi.create(body, assignedIds, invitations); 1358 window.showToast?.('Event created', 'success'); 1359 } 1360 modal.close(); 1361 refresh(); 1362 }, { errorTitle: isEdit ? 'Failed to update event' : 'Failed to create event' }); 1363} 1364 1365// ======================================== 1366// DETAIL MODAL (real calendar events) 1367// ======================================== 1368 1369/** 1370 * Open the rich detail modal for a calendar event (completion + comments + history). 1371 * @param {number|string} eventId 1372 * @param {{ gotoDate?: boolean }} [opts] â gotoDate navigates the grid to the event (deep-link). 1373 */ 1374async function openEventDetail(eventId, opts = {}) { 1375 if (!eventId) return; 1376 // Capture the mount this open belongs to. destroyCalendarPage nulls abortController, so a 1377 // continuation that re-reads the module variable later cannot tell whose page it is standing on. 1378 const ctrl = abortController; 1379 if (!ctrl || ctrl.signal.aborted) return; 1380 const idStr = String(eventId); 1381 if (detailEventId === idStr) return; // already open / opening â no-op the page() re-dispatch 1382 detailEventId = idStr; // claim synchronously, before the await / the page() push 1383 const gen = ++detailOpenGen; // this attempt's identity; a route-out or a newer open invalidates it 1384 1385 // User-driven open (popover "Open details") â reflect in the URL so it's shareable + Back-closable. 1386 // The page() re-dispatch this triggers re-enters here and no-ops via the guard above. Route-driven 1387 // opens (cold deep-link / Back-Forward) run under _routing and skip the push (URL already correct). 1388 if (opts.updateUrl && !_routing) window.page('/calendar/event/' + idStr); 1389 1390 let data; 1391 try { 1392 data = await projectCalendarApi.getEventDetail(eventId, { signal: ctrl.signal }); 1393 } catch (error) { 1394 // Same ownership question as the success path below: a route-out can release our claim and let a 1395 // NEWER attempt for the same id claim it, so an unguarded `detailEventId = null` here would 1396 // destroy the live attempt's claim and toast an error onto a page the user is still using. 1397 const stillOurs = gen === detailOpenGen && abortController === ctrl && !ctrl.signal.aborted; 1398 if (gen === detailOpenGen) detailEventId = null; 1399 if (error.name === 'AbortError' || !stillOurs) return; 1400 window.showToast?.('Could not open the calendar event', 'error', { description: error.message }); 1401 return; 1402 } 1403 1404 // Ownership re-check â belt AND braces, because the signal above is necessary but not sufficient. 1405 // Two live paths still resolve (rather than reject) after we've lost the page: a same-page route-out 1406 // aborts nothing at all (navigateCalendarPage, no teardown), and api/base.js's offline interceptor 1407 // can turn a failed request into a resolved fallback. Either way this continuation may land on a 1408 // torn-down page, a page the user already routed away from, or behind a newer open. Publishing 1409 // anyway floats an orphan modal over the next page, and dismissing that orphan pushes 1410 // buildCalendarUrl() â the exact hijack this card exists to remove. 1411 if (gen !== detailOpenGen || abortController !== ctrl || ctrl.signal.aborted) { 1412 if (gen === detailOpenGen) detailEventId = null; // only release the claim if it is still ours 1413 return; 1414 } 1415 1416 const e = data?.event; 1417 if (!e || !e.id) { 1418 detailEventId = null; 1419 window.showToast?.('That calendar event no longer exists', 'info'); 1420 return; 1421 } 1422 1423 if (opts.gotoDate && e.dateStart && calendar) { 1424 const d = new Date(e.dateStart); 1425 if (!isNaN(d.getTime())) calendar.gotoDate(d); 1426 } 1427 1428 const durationText = (e.duration && Number(e.duration) > 0) 1429 ? `${e.duration} ${DURATION_TYPE_LABELS[e.durationType] || ''}`.trim() 1430 : ''; 1431 1432 detailSub?.dispose(); 1433 detailSub = null; 1434 1435 const modal = await showModal('#tpl-calendar-event-detail', { ...e, durationText }, { 1436 // Plain key, not a conditional spread: the ownership check above proves ctrl is live, and a 1437 // spread that evaluates to nothing would leave modal.js with no abort wiring at all â which 1438 // is precisely what made the 'abort' arm below unreachable. 1439 signal: ctrl.signal, 1440 closeOnBackdrop: true, // click-and-see: a mis-click outside dismisses an UNTOUCHED detail; 1441 // once anything is edited the unsaved-input guard refuses it 1442 onClose: (reason) => { 1443 detailSub?.dispose(); 1444 detailSub = null; 1445 detailModalRef = null; 1446 detailEventId = null; 1447 // User closed the modal â drop the event id from the URL. Three closes must NOT push: 1448 // ⢠'abort' â page teardown (destroyCalendarPage's abortController.abort()); pushing here 1449 // hijacks the route change the user just started. 1450 // ⢠'navigate' â an in-app jump that owns its own destination push (openMessage below); 1451 // pushing here sandwiches a calendar entry in front of it. 1452 // ⢠_routing â a route-driven close (Back); the URL already moved. 1453 if (reason !== 'abort' && reason !== 'navigate' && !_routing)
1453window.page(buildCalendarUrl()); 1454 }, 1455 }); 1456 detailModalRef = modal; 1457 1458 renderActivity(modal.element, data.comments || [], data.history || [], data.messages || []); 1459 bindDetailActions(modal, e, ctrl); 1460 1461 detailSub = subscribeTaskUpdates(`calagsn-${e.id}`, async () => { 1462 if (!detailModalRef) return; 1463 try { 1464 const fresh = await projectCalendarApi.getEventDetail(e.id); 1465 renderActivity(modal.element, fresh.comments || [], fresh.history || [], fresh.messages || []); 1466 } catch { /* best-effort */ } 1467 }); 1468} 1469 1470function renderActivity(root, comments, history, messages) { 1471 const commentsEl = root.querySelector('#cal-detail-comments'); 1472 if (commentsEl) { 1473 const rows = (comments || []).map(c => ({ 1474 author: c.author || 'Unknown', text: c.text || '', dateText: formatDate(c.date), 1475 })); 1476 if (rows.length) renderTo(commentsEl, '#tpl-calendar-comment-row', rows); 1477 else renderTo(commentsEl, '#tpl-calendar-activity-empty', { label: 'No comments yet.' }); 1478 } 1479 const historyEl = root.querySelector('#cal-detail-history'); 1480 if (historyEl) { 1481 const rows = (history || []).map(h => ({ text: h.text || '', dateText: formatDate(h.date) })); 1482 if (rows.length) renderTo(historyEl, '#tpl-calendar-history-row', rows); 1483 else renderTo(historyEl, '#tpl-calendar-activity-empty', { label: 'No history yet.' }); 1484 } 1485 const messagesEl = root.querySelector('#cal-detail-messages'); 1486 if (messagesEl) { 1487 const rows = (messages || []).map(m => ({ 1488 id: m.id, subject: m.subject || '(no subject)', from: m.from || '', 1489 sendTo: m.sendTo || '', dateText: m.date ? formatDate(m.date) : '', 1490 })); 1491 if (rows.length) renderTo(messagesEl, '#tpl-calendar-message-row', rows); 1492 else renderTo(messagesEl, '#tpl-calendar-activity-empty', { label: 'No linked messages.' }); 1493 1494 const countEl = root.querySelector('#cal-detail-messages-count'); 1495 if (countEl) { 1496 countEl.textContent = String(rows.length); 1497 countEl.classList.toggle('hidden', rows.length === 0); 1498 } 1499 } 1500} 1501 1502/** @param {AbortController} ctrl â the mount this modal belongs to; gates the destructive delete. */ 1503function bindDetailActions(modal, event, ctrl) { 1504 const el = modal.element; 1505 1506 // Messages: collapse toggle (long threads can overwhelm) + blurb click â full message. 1507 const msgToggle = el.querySelector('[data-cal-messages-toggle]'); 1508 const msgList = el.querySelector('#cal-detail-messages'); 1509 const msgChevron = el.querySelector('[data-cal-messages-chevron]'); 1510 msgToggle?.addEventListener('click', () => { 1511 const collapsed = msgList?.classList.toggle('hidden'); 1512 msgToggle.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); 1513 if (msgChevron) msgChevron.style.transform = collapsed ? 'rotate(180deg)' : ''; 1514 }); 1515 const openMessage = (id) => { 1516 if (!id) return; 1517 // 'navigate' â onClose skips its own URL push, so this jump leaves exactly one history entry. 1518 modal.close('navigate'); 1519 if (typeof window.page === 'function') window.page(`/messages/${id}`); 1520 else window.location.href = `/messages/${id}`; 1521 }; 1522 // Delegated on the (persistent) list container â survives renderActivity re-renders. 1523 msgList?.addEventListener('click', (e) => { 1524 const row = e.target.closest('[data-message-id]'); 1525 if (row) openMessage(row.dataset.messageId); 1526 }); 1527 msgList?.addEventListener('keydown', (e) => { 1528 if (e.key !== 'Enter' && e.key !== ' ') return; 1529 const row = e.target.closest('[data-message-id]'); 1530 if (row) { e.preventDefault(); openMessage(row.dataset.messageId); } 1531 }); 1532 1533 el.querySelector('[data-cal-edit]')?.addEventListener('click', () => { 1534 modal.close(); 1535 openEventModal(event); 1536 }); 1537 1538 el.querySelector('[data-cal-complete]')?.addEventListener('click', (e) => { 1539 const comment = el.querySelector('#cal-complete-comment')?.value || ''; 1540 runWithBusyButton(e.currentTarget, 'Completingâ¦', async () => { 1541 await projectCalendarApi.completeEvent(event.id, comment); 1542 window.showToast?.('Event completed', 'success'); 1543 modal.close(); 1544 refresh(); 1545 }, { errorTitle: 'Failed to complete event' }); 1546 }); 1547 1548 el.querySelector('[data-cal-add-comment]')?.addEventListener('click', (e) => { 1549 const input = el.querySelector('#cal-comment-input'); 1550 const text = (input?.value || '').trim(); 1551 if (!text) { window.showToast?.('Enter a comment first', 'info'); return; } 1552 runWithBusyButton(e.currentTarget, 'Postingâ¦', async () => { 1553 await projectCalendarApi.addEventComment(event.id, text); 1554 const fresh = await projectCalendarApi.getEventDetail(event.id); 1555 renderActivity(el, fresh.comments || [], fresh.history || [], fresh.messages || []); 1556 if (input) input.value = ''; 1557 }, { errorTitle: 'Failed to add comment', resetOnSuccess: true }); 1558 }); 1559 1560 el.querySelector('[data-cal-delete]')?.addEventListener('click', (e) => { 1561 runWithBusyButton(e.currentTarget, 'Deletingâ¦', async () => { 1562 const confirmed = await confirm({ 1563 title: 'Delete this event?', 1564 message: 'This cannot be undone.', 1565 icon: 'trash-2', 1566 confirmText: 'Delete', 1567 confirmClass: 'btn-st-error', 1568 signal: ctrl.signal, // without this the dialog survives teardown and floats over the next page 1569 }); 1570 if (!confirmed) return; 1571 // calendarApi.delete is an uncancellable mutation â a signal cannot un-commit it, so the 1572 // page must still be ours at the moment we decide to send it. 1573 if (ctrl.signal.aborted) return; 1574 await calendarApi.delete(event.id); 1575 window.showToast?.('Event deleted', 'success'); 1576 modal.close(); 1577 refresh(); 1578 }, { errorTitle: 'Failed to delete event' }); 1579 }); 1580}
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.