1/** 2 * Message Create-and-Link Module 3 * 4 * Opens the same create modals used on contract-admin pages (RFI, CHG, SI) 5 * from the message center's "Link Message To" flow. After creation the new 6 * item is auto-linked to the message. 7 */ 8 9// ======================================== 10// IMPORTS 11// ======================================== 12 13import { rfisApi } from '../api/rfis.js'; 14import { changeRequestsApi } from '../api/change-requests.js'; 15import { siteInstructionsApi } from '../api/site-instructions.js'; 16import { submittalsApi } from '../api/submittals.js'; 17import { messagesApi } from '../api/messages.js'; 18import { calendarApi } from '../api/calendar.js'; 19import { projectEmailApi } from '../api/project-email.js'; 20import { loadTemplates } from '../utils/templates.js'; 21import { showModal } from '../utils/modal.js'; 22import { setLoading, afterRender } from '../utils/dom.js'; 23import { SearchableDropdown } from '../components/SearchableDropdown.js'; 24import { DatePicker } from '../components/DatePicker.js'; 25import { validateRequired, validateForm } from '../utils/form-validation.js'; 26import { wireHelpButtons } from '../utils/help-popover.js'; 27import { applyCreateModalDefaults } from '../utils/create-modal-defaults.js'; 28import { openChgCreateModal } from '../utils/chg-create-modal.js'; 29import { stripHtml } from '../utils/html-sanitizer.js'; 30 31// ======================================== 32// TEMPLATE GUARDS 33// ======================================== 34 35// Template-loaded guards (one fetch per session) 36let rfiTemplatesLoaded = false; 37let siTemplatesLoaded = false; 38let submittalTemplatesLoaded = false; 39let messageTemplatesLoaded = false; 40const allowSettlement = () => true; 41 42/** Ensure the shared message templates (link pill, attachment row) are in the DOM. */ 43async function ensureMessageTemplates() { 44 if (!messageTemplatesLoaded) { 45 await loadTemplates('components/message-sub-templates'); 46 messageTemplatesLoaded = true; 47 } 48} 49 50// ======================================== 51// CREATE-AND-LINK: RFI 52// ======================================== 53 54export async function showCreateRfiAndLink(projectId, messageId, { signal, attachment, prefill, onCreated } = {}) { 55 if (!rfiTemplatesLoaded) { 56 await loadTemplates('components/rfi-templates'); 57 rfiTemplatesLoaded = true; 58 } 59 await ensureMessageTemplates(); 60 61 try { 62 const [response, recipients] = await Promise.all([ 63 rfisApi.getAddOptions(projectId), 64 projectEmailApi.getRecipients(projectId).catch(() => ({ contacts: [] })), 65 ]); 66 const members = response.members || []; 67 const contacts = recipients.contacts || []; 68 const nextRefNumber = response.nextRefNumber || 1; 69 70 const modal = await showModal('#tpl-rfi-add-modal', { members, contacts, nextRefNumber }, { signal }); 71 applyCreateModalDefaults(modal.element, { nextReferenceNumber: response.nextRefNumber }); 72 showPreloadedAttachment(modal.element, 'rfi', attachment); 73 74 // The shared #tpl-rfi-add-modal carries the project RFI form's three buttons 75 // (Review & Send · Save Draft · Reset). Create-and-link only creates+links â 76 // it has no send pipeline here â so hide the two that don't apply and drive 77 // creation off the Save Draft button below. 78 modal.element.querySelector('#btn-rfi-review-send')?.classList.add('hidden'); 79 modal.element.querySelector('#btn-rfi-reset')?.classList.add('hidden'); 80 if (prefill?.title) { 81 const titleEl = modal.element.querySelector('#rfi-title'); 82 if (titleEl) titleEl.value = prefill.title; 83 } 84 if (prefill?.description) { 85 // #rfi-clarification is the "Information Requested" field 86 const descEl = modal.element.querySelector('#rfi-clarification'); 87 if (descEl) descEl.value = prefill.description; 88 } 89 90 // Wire create button (Save Draft = create without send; see hidden buttons above) 91 const createBtn = modal.element.querySelector('#btn-rfi-draft'); 92 let committedRfi = null; 93 createBtn?.addEventListener('click', async () => { 94 const el = modal.element; 95 96 const title = el.querySelector('#rfi-title')?.value?.trim(); 97 const clarification = el.querySelector('#rfi-clarification')?.value?.trim() || null; 98 const specReference = el.querySelector('#rfi-ref-docs')?.value?.trim() || null; 99 const suggestions = el.querySelector('#rfi-suggestions')?.value?.trim() || null; 100 const dateDue = el.querySelector('#rfi-date-created')?.value || null; 101 const rfiType = el.querySelector('#rfi-type')?.value || null; 102 103 // Priority is multi-select (legacy parity) â stored pipe-delimited. 104 const priorityVals = Array.from(el.querySelector('#rfi-priority')?.selectedOptions || []) 105 .map(o =>
105 o.value).filter(Boolean); 106 const priority = priorityVals.length ? priorityVals.join('|') : null; 107 const priorityReason = el.querySelector('#rfi-priority-reason')?.value?.trim() || null; 108 109 // Assigned To â multi-select (SearchableDropdown-enhanced), same shape as the main 110 // create form's collectForm. Reading `.value` returns only the FIRST selected option 111 // (empty once enhanced), so gather every selected option â the create-from-message / 112 // markupâRFI path was silently dropping all-but-first assignee. 113 const assignedUserIds = Array.from(el.querySelector('#rfi-assigned-to')?.selectedOptions || []) 114 .map(o => parseInt(o.value, 10)).filter(v => !isNaN(v)); 115 116 // Issued To (single contact â ToContactId) + CC (multi contact) 117 const issuedToVal = el.querySelector('#rfi-issued-to')?.value; 118 const toContactId = issuedToVal ? parseInt(issuedToVal) : null; 119 const ccContactIds = Array.from(el.querySelector('#rfi-cc')?.selectedOptions || []) 120 .map(o => parseInt(o.value)).filter(v => !isNaN(v)); 121 122 const rules = [ 123 { el: el.querySelector('#rfi-title'), label: 'Title', maxLength: 500 }, 124 ]; 125 if (priorityVals.length > 0) { 126 rules.push({ el: el.querySelector('#rfi-priority-reason'), label: 'Priority Reason' }); 127 } 128 129 if (!validateRequired(el, rules)) return; 130 131 setLoading(createBtn, true, committedRfi ? 'Attaching...' : 'Creating...'); 132 133 if (!committedRfi) { 134 try { 135 const result = await rfisApi.create(projectId, { 136 title, 137 comments: clarification, 138 dateDue, 139 rfiType, 140 priority, 141 priorityReason, 142 suggestions, 143 specReference, 144 assignedUserIds: assignedUserIds.length ? assignedUserIds : null, 145 toContactId: toContactId && !isNaN(toContactId) ? toContactId : null, 146 ccContactIds: ccContactIds.length ? ccContactIds : null, 147 }); 148 committedRfi = Object.freeze({ itemId: result.rfiId, title, attachment }); 149 } catch (error) { 150 if (error.name === 'AbortError') return; 151 window.showToast?.('Failed to create RFI', 'error', { description: error.message }); 152 setLoading(createBtn, false); 153 return; 154 } 155 } 156 157 const { itemId, title: committedTitle } = committedRfi; 158 try { 159 await uploadMessageAttachment(rfisApi.uploadDocument, projectId, itemId, committedRfi.attachment); 160 } catch (error) { 161 if (error.name === 'AbortError') return; 162 // The RFI already exists. Preserve its identity and the exact Blob 163 // in this modal closure; the only retry offered is attachment-only. 164 window.showToast?.('RFI created; attachment failed', 'warning', { 165 description: `${error.message || 'The attachment could not be uploaded.'} Retry the attachment or close and attach it manually to RFI ${itemId}.`, 166 }); 167 setLoading(createBtn, false); 168 createBtn.textContent = 'Retry Attachment'; 169 return; 170 } 171 172 try { 173 // Callers that aren't tied to the message center (e.g. markup 174 // layer "Create RFI") use onCreated to hook in their own 175 // follow-up (e.g. create Link annotations back to the RFI). 176 if (onCreated) { 177 try { await onCreated(itemId, { title: committedTitle }); } 178 catch (hookErr) { console.error('onCreated hook failed', hookErr); } 179 } 180 await linkAndFinish(modal, messageId, 'RFI', itemId, committedTitle); 181 } catch (error) { 182 if (error.name === 'AbortError') return; 183 window.showToast?.('RFI created; follow-up failed', 'warning', { 184 description: `${error.message || 'The follow-up link could not be completed.'} RFI ${itemId}
184 was created and will not be created again.`, 185 }); 186 setLoading(createBtn, false); 187 } 188 }); 189 190 } catch (error) { 191 if (error.name === 'AbortError') return; 192 window.showToast?.('Failed to open RFI form', 'error', { description: error.message }); 193 } 194} 195 196// ======================================== 197// CREATE-AND-LINK: CHANGE REQUEST 198// ======================================== 199 200export async function showCreateChgAndLink(projectId, messageId, options = {}) { 201 const { signal, attachment, prefill, onCreated } = options; 202 const hasCustomSettlementOwnership = typeof options.canPublish === 'function' 203 || typeof options.canContinueDurable === 'function'; 204 let modalOpen = true; 205 const parentCanPublish = options.canPublish || allowSettlement; 206 const canPublish = () => modalOpen && parentCanPublish(); 207 const canContinueDurable = options.canContinueDurable || allowSettlement; 208 if (!canPublish() || !canContinueDurable()) return false; 209 await ensureMessageTemplates(); 210 if (!canPublish() || !canContinueDurable()) return false; 211 212 let opened = false; 213 await openChgCreateModal({ 214 projectId, 215 signal, 216 prefill, 217 canPublish, 218 canContinueDurable, 219 closeBeforeCreated: false, 220 onOpen: (element) => { 221 opened = true; 222 showPreloadedAttachment(element, 'chg', attachment); 223 }, 224 onClosed: () => { modalOpen = false; }, 225 onCreated: async ({ chgId, title }) => { 226 if (!canContinueDurable()) return false; 227 try { 228 const attached = await uploadMessageAttachment( 229 changeRequestsApi.uploadDocument, 230 projectId, 231 chgId, 232 attachment ? { ...attachment, failurePolicy: 'throw' } : null, 233 hasCustomSettlementOwnership ? { canContinueDurable } : null 234 ); 235 if (!attached || !canContinueDurable()) return false; 236 } catch (attachmentError) { 237 if (attachmentError.name === 'AbortError' || !canContinueDurable()) return false; 238 console.error('Proposed Change message attachment failed', attachmentError); 239 if (canPublish()) window.showToast?.('Proposed Change created; attachment failed', 'warning', { 240 description: `Change ${chgId} was saved. Attach the message file from its Documents panel.`, 241 }); 242 } 243 let externalFollowUpFailed = false; 244 if (onCreated) { 245 try { 246 if (await onCreated(chgId, { title }) === false) return false; 247 } catch (hookError) { 248 externalFollowUpFailed = true; 249 console.error('Proposed Change creation follow-up failed', hookError); 250 if (canPublish()) window.showToast?.('Proposed Change created; follow-up failed', 'warning', { 251 description: `Change ${chgId} was saved, but its external follow-up needs attention.`, 252 }); 253 } 254 } 255 if (!canContinueDurable()) return false; 256 if (!messageId && externalFollowUpFailed) return false; 257 return linkAndFinish(null, messageId, 'CHG', chgId, title, { 258 closeOperationModal: () => {}, 259 canPublish, 260 canContinueDurable, 261 }); 262 }, 263 }); 264 return opened; 265} 266// ======================================== 267// CREATE-AND-LINK: SITE INSTRUCTION 268// ======================================== 269 270export async function showCreateSiAndLink(projectId, messageId, { signal, attachment, prefill, onCreated } = {}) { 271 if (!siTemplatesLoaded) { 272 await loadTemplates('components/si-templates'); 273 siTemplatesLoaded = true; 274 } 275 await ensureMessageTemplates(); 276 277 try { 278 const response = await siteInstructionsApi.getAddOptions(projectId); 279 const members = response.members || []; 280 const nextRefNumber = response.nextRefNumber || 1; 281 282 const modal = await showModal('#tpl-si-add-modal', { members, nextRefNumber }, { signal }); 283 applyCreateModalDefaults(modal.element, { nextReferenceNumber: response.nextRefNumber }); 284 showPreloadedAttachment(modal.element, 'si', attachment); 285 if (prefill?.title) { const t = modal.element.querySelector('#si-title'); if (t) t.value = prefill.title; } 286 if (prefill?.description) { const d = modal.element.querySelector('#si-description'); if (d) d.value = prefill.description; } 287
288 // Wire cost impact conditional callout 289 const costImpactSelect = modal.element.querySelector('#si-cost-impact'); 290 const priceCallout = modal.element.querySelector('#si-price-callout'); 291 costImpactSelect?.addEventListener('change', () => { 292 priceCallout?.classList.toggle('hidden', costImpactSelect.value !== 'Yes'); 293 }); 294 295 const createBtn = modal.element.querySelector('#btn-create-si'); 296 createBtn?.addEventListener('click', async () => { 297 const el = modal.element; 298 299 const title = el.querySelector('#si-title')?.value?.trim(); 300 const description = el.querySelector('#si-description')?.value?.trim() || null; 301 const specReference = el.querySelector('#si-spec-ref')?.value?.trim() || null; 302 const costImpact = el.querySelector('#si-cost-impact')?.value || null; 303 const scheduleImpact = el.querySelector('#si-schedule-impact')?.value || null; 304 const comments = el.querySelector('#si-comments')?.value?.trim() || null; 305 306 const toVal = el.querySelector('#si-issued-to')?.value; 307 const toContactId = toVal ? parseInt(toVal) : null; 308 309 const assignedSelect = el.querySelector('#si-assigned-to'); 310 const assignedUserIds = assignedSelect 311 ? Array.from(assignedSelect.selectedOptions).map(o => parseInt(o.value)).filter(v => !isNaN(v)) 312 : []; 313 314 const rules = [ 315 { el: el.querySelector('#si-title'), label: 'Title', maxLength: 500 }, 316 { el: el.querySelector('#si-description'), label: 'Description' }, 317 { el: el.querySelector('#si-issued-to'), label: 'Issued To', type: 'select' }, 318 ]; 319 320 if (!validateRequired(el, rules)) return; 321 322 setLoading(createBtn, true, 'Creating...'); 323 324 try { 325 const result = await siteInstructionsApi.create(projectId, { 326 title, description, comments, specReference, costImpact, scheduleImpact, 327 toContactId: toContactId && !isNaN(toContactId) ? toContactId : null, 328 assignedUserIds: assignedUserIds.length > 0 ? assignedUserIds : null, 329 }); 330 331 const itemId = result.rfiId; 332 await uploadMessageAttachment(siteInstructionsApi.uploadDocument, projectId, itemId, attachment); 333 if (onCreated) { try { await onCreated(itemId, { title }); } catch (hookErr) { console.error('onCreated hook failed', hookErr); } } 334 await linkAndFinish(modal, messageId, 'SI', itemId, title); 335 } catch (error) { 336 if (error.name === 'AbortError') return; 337 window.showToast?.('Failed to create Site Instruction', 'error', { description: error.message }); 338 setLoading(createBtn, false); 339 } 340 }); 341 342 } catch (error) { 343 if (error.name === 'AbortError') return; 344 window.showToast?.('Failed to open Site Instruction form', 'error', { description: error.message }); 345 } 346} 347 348// ======================================== 349// CREATE-AND-LINK: SUBMITTAL 350// ======================================== 351 352export async function showCreateSubmittalAndLink(projectId, messageId, config = {}) { 353 const { signal, attachment, prefill, onCreated } = config; 354 const canPublishOrigin = config.canPublish || allowSettlement; 355 const canContinueDurable = config.canContinueDurable || allowSettlement; 356 let modal = null; 357 let operationModalClosed = false; 358 const isCurrent = () => !operationModalClosed && !signal?.aborted 359 && (!modal || modal.element.isConnected) && canPublishOrigin() && canContinueDurable(); 360 function closeOperationModal(reason = 'stale-owner') { 361 if (operationModalClosed || !modal) return false; 362 operationModalClosed = true; 363 modal.close(reason); 364 return true; 365 } 366 if (!isCurrent()) return false; 367 if (!submittalTemplatesLoaded) { 368 await loadTemplates('components/submittal-templates'); 369 submittalTemplatesLoaded = true; 370 } 371 if (!isCurrent()) return false;
372 await ensureMessageTemplates(); 373 if (!isCurrent()) return false; 374 375 try { 376 const options = await submittalsApi.getAddOptions(projectId, signal ? { signal } : {}); 377 if (!isCurrent()) return false; 378 const users = options.users || []; 379 380 modal = await showModal('#tpl-submittal-add-modal', { users }, { 381 signal, 382 onClose: () => { operationModalClosed = true; }, 383 }); 384 if (!isCurrent()) { 385 closeOperationModal(); 386 return false; 387 } 388 applyCreateModalDefaults(modal.element, { nextReferenceNumber: options.nextRefNumber }); 389 showPreloadedAttachment(modal.element, 'submittal', attachment); 390 if (prefill?.title) { const t = modal.element.querySelector('#submittal-title'); if (t) t.value = prefill.title; } 391 392 // The submittal add modal has manual / from-list tabs. We only drive the 393 // default "manual" create path (the from-list tab isn't wired â not needed 394 // for create-from-email). 395 const createBtn = modal.element.querySelector('#btn-create-submittal'); 396 createBtn?.addEventListener('click', async () => { 397 if (!isCurrent()) { 398 closeOperationModal(); 399 return; 400 } 401 const el = modal.element; 402 403 const refNumber = el.querySelector('#submittal-ref')?.value?.trim(); 404 const title = el.querySelector('#submittal-title')?.value?.trim(); 405 406 const rules = [ 407 { el: el.querySelector('#submittal-ref'), label: 'Reference #', maxLength: 50 }, 408 { el: el.querySelector('#submittal-title'), label: 'Title', maxLength: 500 }, 409 ]; 410 if (!validateRequired(el, rules)) return; 411 412 setLoading(createBtn, true, 'Creating...'); 413 414 try { 415 if (!canContinueDurable()) return; 416 const result = await submittalsApi.create(projectId, { title, refNumber }); 417 const itemId = result.submittalId; 418 if (!canContinueDurable()) return; 419 const followUpFailures = []; 420 421 try { 422 const strictAttachment = attachment ? { ...attachment, failurePolicy: 'throw' } : attachment; 423 const continued = await uploadMessageAttachment( 424 submittalsApi.uploadDocument, projectId, itemId, str
424ictAttachment, { canContinueDurable } 425 ); 426 if (!continued || !canContinueDurable()) return; 427 } catch (uploadErr) { 428 if (!canContinueDurable()) return; 429 console.error('Submittal attachment upload failed', uploadErr); 430 followUpFailures.push(`Attachment upload failed: ${uploadErr?.message || 'Unknown error'}`); 431 } 432 433 if (onCreated) { 434 try { 435 if (!canContinueDurable()) return; 436 const continued = await onCreated(itemId, { title }); 437 if (!canContinueDurable()) return; 438 if (continued === false) throw new Error('The provider follow-up did not complete'); 439 } catch (hookErr) { 440 if (!canContinueDurable()) return; 441 console.error('Submittal provider follow-up failed', hookErr); 442 followUpFailures.push(`Provider follow-up failed: ${hookErr?.message || 'Unknown error'}`); 443 } 444 } 445 446 if (!canContinueDurable()) return; 447 const linkOutcome = await linkAndFinish(modal, messageId, 'SUBMITTAL', itemId, title, { 448 canPublish: isCurrent, canContinueDurable, closeOperationModal, deferOutcome: true, 449 }); 450 if (!canContinueDurable()) return; 451 if (!linkOutcome.ok) { 452 followUpFailures.push(`Message link failed: ${linkOutcome.error?.message || 'Unknown error'}`); 453 } 454 455 if (!isCurrent()) { 456 closeOperationModal(); 457 return; 458 } 459 if (followUpFailures.length > 0) { 460 window.showToast?.('Submittal created with follow-up issues', 'warning', { 461 description: followUpFailures.join(' '), 462 }); 463 } else { 464 window.showToast?.(messageId ? 'Submittal created & linked' : 'Submittal created', 'success', { description: title }); 465 } 466 closeOperationModal('created'); 467 } catch (error) { 468 if (error.name === 'AbortError' || !isCurrent()) { 469 closeOperationModal(); 470 return; 471 } 472 window.showToast?.('Failed to create Submittal', 'error', { description: error.message }); 473 setLoading(createBtn, false); 474 } 475 }); 476 477 return true; 478 479 } catch (error) { 480 if (error.name === 'AbortError' || !isCurrent()) { 481 closeOperationModal(); 482 return false; 483 } 484 closeOperationModal('open-error'); 485 window.showToast?.('Failed to open Submittal form', 'error', { description: error.message }); 486 return false; 487 } 488} 489 490// ======================================== 491// SHARED HELPERS (attachments, linking, link pill) 492// ======================================== 493 494/** Show a read-only pre-populated attachment in a create modal's file section. */ 495function showPreloadedAttachment(modalEl, prefix, attachment) { 496 if (!attachment) return; 497 498 const dropZone = modalEl.querySelector(`#${prefix}-file-drop-zone`); 499 const preloadedHost = modalEl.querySelector(`#${prefix}-preloaded-attachment`); 500 const fileList = preloadedHost || modalEl.querySelector(`#${prefix}-file-list`); 501 502 if (!preloadedHost && dropZone) dropZone.classList.add('hidden'); 503 504 if (fileList) { 505 const row = document.createElement('div'); 506 row.className = 'flex items-center gap-3 p-2 rounded-lg bg-surface-2/10 border border-theme'; 507 row.innerHTML = $.templates('#tpl-message-preloaded-attachment').render({ 508 isImage: /^image\//i.test(attachment.contentType || ''), 509 fileName: attachment.fileName 510 }); 511 fileList.appendChild(row); 512 afterRender(row); 513 } 514} 515 516/** Fetch message attachment blob and upload it as a document on the newly created item. */ 517async function uploadMessageAttachment(uploadFn, projectId, itemId, attachment, ownership = null) { 518 if ((!attachment?.url && !attachment?.blob && !attachment?.file) || !itemId) return true; 519 const canContinueDurable = ownership?.canContinueDurable || allowSettlement; 520 if (!canContinueDurable()) return false;
521 try { 522 let file = attachment.file; 523 if (!file) { 524 let blob = attachment.blob; 525 if (!blob) { 526 const resp = await fetch(attachment.url, ownership ? {} : { signal: attachment.signal }); 527 if (!resp.ok) throw new Error(`Attachment fetch failed (${resp.status})`); 528 blob = await resp.blob(); 529 if (!canContinueDurable()) return false; 530 } 531 file = new File([blob], attachment.fileName || 'attachment', { 532 type: attachment.contentType || blob.type, 533 }); 534 } 535 if (!canContinueDurable()) return false; 536 const formData = new FormData(); 537 formData.append('file', file); 538 formData.append('title', file.name); 539 formData.append('description', ''); 540 await uploadFn(projectId, itemId, formData, ownership ? {} : { signal: attachment.signal }); 541 return true; 542 } catch (err) { 543 if (attachment.failurePolicy === 'throw' || err.name === 'AbortError') throw err; 544 console.error('Failed to upload message attachment:', err); 545 return true; 546 } 547} 548 549/** Upload a message attachment to a linked item. 550 * 551 * Two paths: 552 * 1. RFI / CHG / SI / SUBMITTAL â call the domain's uploadDocument with a fresh 553 * FormData copy of the file (creates a new blob in the item's storage). 554 * 2. PROGBILL / SUBMITTALITEM / SUPPLIERITEM â unified copy-to-item endpoint 555 * that inserts a reference row in the target's _doc table pointing at the 556 * same blob (no copy â matches legacy `savefile` semantics for these types). 557 * 558 * Both paths are best-effort: if the file copy fails, the message-link is still 559 * created and the user can attach manually from the item's docs UI. 560 */ 561export async function uploadAttachmentToItem(linkType, projectId, itemId, attachment) { 562 if (!attachment?.id || !itemId) return { attempted: false, copied: false }; 563 564 const COPY_BY_REF_TYPES = new Set(['PROGBILL', 'SUBMITTALITEM', 'SUPPLIERITEM']); 565 if (COPY_BY_REF_TYPES.has(linkType)) { 566 try { 567 await messagesApi.copyAttachmentToItem(attachment.id, { 568 targetType: linkType, 569 targetId: itemId, 570 }); 571 return { attempted: true, copied: true }; 572 } catch (err) { 573 if (err.name === 'AbortError') throw err; 574 console.error(`Failed to copy attachment to ${linkType}:`, err); 575 return { attempted: true, copied: false, error: err }; 576 } 577 } 578 579 // Domains with their own uploadDocument endpoints â copy the file bytes via 580 // the blob URL so the target gets its own independent blob. 581 const uploadFns = { 582 RFI: rfisApi.uploadDocument, 583 CHG: changeRequestsApi.uploadDocument, 584 SI: siteInstructionsApi.uploadDocument, 585 SUBMITTAL: submittalsApi.uploadDocument, 586 }; 587 const fn = uploadFns[linkType]; 588 if (!fn) return { attempted: false, copied: false }; 589 if (!attachment.url) return { attempted: true, copied: false, error: new Error('Attachment file is unavailable') }; 590 try { 591 const copied = await uploadMessageAttachment(fn, projectId, itemId, { ...attachment, failurePolicy: 'throw' }); 592 return { attempted: true, copied, error: copied ? null : new Error('Attachment copy did not complete') }; 593 } catch (err) { 594 if (err.name === 'AbortError') throw err; 595 console.error(`Failed to upload attachment to ${linkType}:`, err); 596 return { attempted: true, copied: false, error: err }; 597 } 598} 599 600const TYPE_LABELS = { RFI: 'RFI', CHG: 'Change Request', SI: 'Site Instruction', SUBMITTAL: 'Submittal' }; 601 602async function linkAndFinish(modal, messageId, linkType, itemId, title, ownership = null) { 603 const isCurrent = ownership?.canPublish || allowSettlement; 604 const canContinueDurable = ownership?.canContinueDurable || allowSettlement; 605 const closeOperationModal = ownership?.closeOperationModal || (() => modal.close()); 606 const deferOutcome = ownership?.deferOutcome === true; 607 if (!messageId) { 608 if (!deferOutcome && isCurrent()) { 609 window.showToast?.(`${TYPE_LABELS[linkType]} created`, 'success', { description: title }); 610 } 611 if (!deferOutcome) closeOperationModal(); 612 return deferOutcome ? { ok: isCurrent(), error: null } : isCurrent(); 613 } 614 if (!canContinueDurable()) { 615 if (!deferOutcome) closeOperationModal(); 616 return deferOutcome ? { ok: false, error: new Error('Message linking did n
616ot continue') } : false; 617 } 618 try { 619 const newLink = await messagesApi.createLink(messageId, linkType, itemId); 620 if (isCurrent()) { 621 if (!deferOutcome) window.showToast?.(`${TYPE_LABELS[linkType]} created & linked`, 'success', { description: title }); 622 appendLinkPill(newLink); 623 } 624 if (deferOutcome) return { ok: isCurrent(), error: null }; 625 } catch (linkErr) { 626 if (deferOutcome) return { ok: false, error: linkErr }; 627 if (linkErr.name === 'AbortError') return false; 628 // Item was created but linking failed â tell user so they can link manually 629 if (isCurrent()) { 630 window.showToast?.(`${TYPE_LABELS[linkType]} created but linking failed`, 'warning', { 631 description: 'You can link it manually from the dropdown.', 632 }); 633 } 634 } finally { 635 if (!deferOutcome) closeOperationModal(); 636 } 637 return isCurrent(); 638} 639 640/** Surgically append a link pill to #meta-links without re-rendering the entire message detail. */ 641function appendLinkPill(link) { 642 if (!link?.id) return; 643 644 let metaLinks = document.querySelector('#meta-links'); 645 646 // If no links section exists yet, create it inside #message-meta-block 647 if (!metaLinks) { 648 const metaBlock = document.querySelector('#message-meta-block'); 649 if (!metaBlock) return; 650 651 metaLinks = document.createElement('div'); 652 metaLinks.id = 'meta-links'; 653 654 const flexWrap = document.createElement('div'); 655 flexWrap.className = 'flex items-start gap-1.5 flex-wrap'; 656 657 const label = document.createElement('span'); 658 label.className = 'text-xs font-semibold text-theme-tertiary shrink-0'; 659 label.textContent = 'Linked Items:'; 660 661 flexWrap.appendChild(label); 662 metaLinks.appendChild(flexWrap); 663 664 // Insert after #meta-seen-by (matches template ordering) 665 const seenBy = metaBlock.querySelector('#meta-seen-by'); 666 if (seenBy) seenBy.after(metaLinks); 667 else metaBlock.prepend(metaLinks); 668 } 669 670 const container = metaLinks.querySelector('.flex'); 671 if (!container) return; 672 673 const displayText = link.itemNumber 674 ? `${link.itemNumber} â ${link.itemTitle || 'Untitled'}` 675 : (link.itemTitle || 'Untitled'); 676 677 // Build the link pill (same markup as tpl-message-detail-body {{for links}}) 678 const html = $.templates('#tpl-message-link-pill').render({ 679 id: link.id, 680 itemType: link.itemType, 681 itemId: link.itemId, 682 displayText 683 }); 684 container.insertAdjacentHTML('beforeend', html); 685 const pill = container.lastElementChild; 686 afterRender(pill); 687} 688 689// ======================================== 690// CREATE-AND-LINK: TASK (Calendar Event) 691// ======================================== 692 693/** 694 * Open the "create task from message" modal pre-filled with the message subject 695 * + a stripped excerpt of the body, then create a calendar event via calendarApi 696 * and auto-link it to the source message via messagesApi.createLink. 697 * 698 * Ported from legacy `cmdMessageAddTask` â the kebab "Add Task" / "Link Message 699 * To â Task â Create New" entry point. 700 * 701 * @param {number} projectId 702 * @param {number|null} messageId - The message that's seeding this task. Null = standalone. 703 * @param {{ signal?: AbortSignal, prefill?: { title?: string, description?: string } }} [opts] 704 */ 705export async function showCreateTaskAndLink(projectId, messageId, { signal, prefill, onCreated } = {}) { 706 await ensureMessageTemplates(); 707 708 // Default scheduling: one hour from now, snapped to the next quarter-hour. 709 const now = new Date(); 710 now.setMinutes(now.getMinutes() + 60); 711 const quarterHour = Math.ceil(now.getMinutes() / 15) * 15; 712 now.setMinutes(quarterHour % 60); 713 if (quarterHour >= 60) now.setHours(now.getHours() + 1); 714 715 const pad = (n) => String(n).padStart(2, '0'); 716 const yyyy = now.getFullYear(); 717 const mm = pad(now.getMonth() + 1); 718 const dd = pad(now.getDate()); 719 const dateDefault = `${yyyy}-${mm}-${dd}`; 720 const timeDefault = `${pad(now.getHours())}:${pad(now.getMinutes())}`; 721 722 const modal = await showModal('#tpl-create-task-from-message-modal', { 723 title: prefill?.title ? prefill.title.substring(0, 200) : '', 724 description: prefill?.description || '', 725 dateDefault, 726 timeDefault, 727 }, { signal }); 728 729 const titleEl = modal.element.querySelector('#new-task-title'); 730 const descriptionEl = modal.element.querySelector('#new-task-description'); 731 const dateEl = modal.element.querySelector('#new-task-date'); 732 const timeEl = modal.element.querySelector('#new-task-time'); 733 const priorityEl = modal.element.querySelector('#new-task-priority'); 734 const saveBtn = modal.element.querySelector('#btn-create-task-and-link'); 735 736 titleEl?.focus(); 737 titleEl?.select(); 738 739 saveBtn?.addEventListener('click', async () => { 740 if (!validateForm(modal.element)) return; 741 742 const title = titleEl?.value?.trim(); 743 if (!title) return; 744 745 // Combine date + time into a single DateTime the backend stores. 746 const dateVal = dateEl?.value || dateDefault; 747 const timeVal = timeEl?.value || '09:00'; 748 const eventStart = new Date(`${dateVal}T${timeVal}:00`); 749 if (isNaN(eventStart.getTime())) { 750 window.showToast?.('Invalid date / time', 'error'); 751 return; 752 } 753
754 setLoading(saveBtn, true, 'Creating...'); 755 try { 756 // type_code "PROJECT" + type_id = projectId is the legacy convention for 757 // a calendar task scoped to a project (vs personal / company-wide). 758 const eventData = { 759 title, 760 description: descriptionEl?.value?.trim() || null, 761 eventDateStart: eventStart.toISOString(), 762 eventStartTime: timeVal, 763 priority: priorityEl?.value || 'Medium', 764 type: 'Task', 765 typeCode: 'PROJECT', 766 typeId: projectId, 767 }; 768 769 const result = await calendarApi.create(eventData); 770 771 // calendarApi.create returns either a wrapped { data } envelope or the 772 // raw entity depending on ToHttpResult shape â handle both. 773 const newEvent = result?.data ?? result; 774 const newTaskId = newEvent?.calendarEventId ?? newEvent?.CalendarEventId ?? newEvent?.id; 775 776 if (!newTaskId) { 777 throw new Error('Task was created but no ID was returned'); 778 } 779 780 // Callers not tied to the message center (external email "Create Task") use 781 // onCreated to record their own follow-up link. 782 if (onCreated) { 783 try { await onCreated(newTaskId, { title }); } 784 catch (hookErr) { console.error('onCreated hook failed', hookErr); } 785 } 786 787 // Auto-link the source message to the new task. Best-effort â if the 788 // link insert fails, the task still exists and the user can link manually. 789 if (messageId) { 790 try { 791 // The created item is a calendar event (calendarEventId), so link it as 792 // CALENDARTASK â that resolves via the becc_pm_calendar_events JOIN in 793 // GetLinksAsync and routes to /calendar/event/{id}. Linking as 'TASK' 794 // (â PROJECTTASK) would store the event id against becc_pm_projecttasks, 795 // which finds nothing â dead, untitled pill. The link PICKER's 'TASK' 796 // option still returns real project tasks and stays PROJECTTASK. 797 const newLink = await messagesApi.createLink(messageId, 'CALENDARTASK', newTaskId); 798 window.showToast?.('Task created & linked', 'success', { description: title }); 799 appendLinkPill(newLink); 800 } catch (linkErr) { 801 if (linkErr.name === 'AbortError') return; 802 window.showToast?.('Task created â linking failed', 'warning', { 803 description: 'You can link it manually from the kebab menu.', 804 }); 805 } 806 } else { 807 window.showToast?.('Task created', 'success', { description: title }); 808 } 809 810 modal.close(); 811 } catch (err) { 812 if (err.name === 'AbortError') return; 813 setLoading(saveBtn, false); 814 window.showToast?.('Failed to create task', 'error', { description: err.message }); 815 } 816 }); 817}
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.