File
Edit
View
Options
Help
Ask Weyland
Ask Weyland
SubX Submittal Automation
Page
Sheet Index
- / -
📄 Reference
What type of document is this?
TakeOff Express needs to know how to process your upload
Schedule Preview
Full resolution preview

📋 Submittal Preview

Preview - Production Data
${logoUrl ? `Logo` : ''}

${escapeHtml(companyName)}

${companyAddress ? `

${escapeHtml(companyAddress).replace(/\\n/g, '
')}

` : ''}

${companyEmail ? 'Email: ' + escapeHtml(companyEmail) : ''}${companyEmail && companyPhone ? ' | ' : ''}${companyPhone ? 'Phone: ' + escapeHtml(companyPhone) : ''}

Quote #${escapeHtml(quoteNumber)}
Date: ${dateStr}
Total: ${grandTotal}
Prepared For
${escapeHtml(recipientName)}
${escapeHtml(recipientAddress)}
Hardware Sets
${hardwareRows.length > 0 ? hardwareRows.map(row => ` `).join('') : ''}
Set # Description Doors Unit Price Total
${escapeHtml(row.set || '—')}
${escapeHtml(row.description?.split('\\n')[0] || '—')}
${row.description?.includes('\\n') ? `
${escapeHtml(row.description.split('\\n').slice(1).join('\\n'))}
` : ''}
${escapeHtml(row.qty || '0')} ${escapeHtml(row.unitPrice || '$0.00')} ${escapeHtml(row.total || '$0.00')}
No hardware sets
Door Materials
${doorRows.length > 0 ? doorRows.map(row => ` `).join('') : ''}
Description Size Qty Unit Price Total
${escapeHtml(row.description || '—')} ${escapeHtml(row.size || '—')} ${escapeHtml(row.qty || '0')} ${escapeHtml(row.unitPrice || '$0.00')} ${escapeHtml(row.total || '$0.00')}
No doors added
Frames
${frameRows.length > 0 ? frameRows.map(row => ` `).join('') : ''}
Description Material Qty Unit Price Total
${escapeHtml(row.description || '—')} ${escapeHtml(row.material || '—')} ${escapeHtml(row.qty || '0')} ${escapeHtml(row.unitPrice || '$0.00')} ${escapeHtml(row.total || '$0.00')}
No frames added
Services
${serviceRows.length > 0 ? serviceRows.map(row => ` `).join('') : ''}
Description Qty Rate Total
${escapeHtml(row.description || '—')} ${escapeHtml(row.qty || '1')} ${escapeHtml(row.unitPrice || '$0.00')} ${escapeHtml(row.total || '$0.00')}
No services added
Hardware ${hardwareTotal}
Doors ${doorsTotal}
Frames ${framesTotal}
Services ${servicesTotal}
Subtotal ${subtotal}
${escapeHtml(taxJurisdiction)} (${taxRate}%) ${taxAmount}
TOTAL ${grandTotal}

Terms & Conditions

${exclusionsText ? `

Exclusions

` : `

Exclusions

`}
`; // Open print window const printWindow = window.open('', '_blank'); if (!printWindow) { alert('Please allow pop-ups to generate the PDF quote.'); return; } printWindow.document.write(printHTML); printWindow.document.close(); // Wait for content to load then trigger print setTimeout(() => { printWindow.print(); console.log('[PDF] Print dialog opened'); }, 500); } // Helper function to gather table row data function gatherTableRows(tbodyId, fields) { const tbody = document.getElementById(tbodyId); if (!tbody) return []; const rows = []; const trs = tbody.querySelectorAll('tr'); trs.forEach(tr => { const tds = tr.querySelectorAll('td'); if (tds.length < 2) return; // Skip empty/placeholder rows // Check if it's a placeholder row if (tds[0].getAttribute('colspan')) return; const row = {}; fields.forEach((field, idx) => { if (tds[idx]) { // Get text content, handling nested elements const cell = tds[idx]; const mainText = cell.querySelector('.main, div:first-child')?.textContent || cell.textContent; const detailsText = cell.querySelector('.details, div:nth-child(2)')?.textContent || ''; row[field] = detailsText ? mainText + '\n' + detailsText : mainText.trim(); } }); rows.push(row); }); return rows; } function switchLayout(layoutName) { console.log(`[LAYOUT SWITCH] Switching to: ${layoutName}`); // P0 FIX: Capture any unsaved input values before switching captureCurrentInputs(); // Hide all layouts document.querySelectorAll('.layout-view').forEach(view => { view.classList.remove('active'); }); // Show selected layout document.getElementById(`layout-${layoutName}`).classList.add('active'); // Update state sharedState.session.layout = layoutName; saveState(); // Update menu checkmarks updateViewMenuCheckmarks(); // Re-render components for new layout renderComponents(); // Re-render PDF to ensure canvas is properly sized for new layout if (pdfDocument && sharedState.session.currentPage) { // Small delay to ensure DOM has updated setTimeout(() => { renderPdfPage(sharedState.session.currentPage); }, 50); } console.log(`[LAYOUT] Active: ${layoutName}`); } // =========================== // 26L: DUAL EXTRACTION SELECTOR // =========================== /** * Check if current page has dual extractions (delete-the-loser interlock active) */ function hasDualExtraction() { const pageNum = sharedState.session.currentPage; const pageData = sharedState.allPageExtractions[pageNum]; return !!(pageData && pageData.previousExtraction); } /** * Render the extraction selector banner when dual extractions exist * Shows two cards: legacy vs re-extraction. User must delete one. */ function renderExtractionSelector(container) { const pageNum = sharedState.session.currentPage; const pageData = sharedState.allPageExtractions[pageNum]; if (!pageData || !pageData.previousExtraction) return false; // Count groups/components for each extraction const currentGroups = pageData.groups || []; const currentComponents = currentGroups.reduce((sum, g) => sum + (g.components?.length || 0), 0); const prevGroups = pageData.previousExtraction.groups || []; const prevComponents = prevGroups.reduce((sum, g) => sum + (g.components?.length || 0), 0); const reExtractedAt = pageData.reExtractedAt || 'Unknown'; const createdAt = pageData.createdAt || 'Unknown'; container.innerHTML = `
Dual Extraction — Choose One to Keep

This page was re-extracted. Affirm is blocked until you delete one extraction. Review both and keep the better one.

Legacy Extraction
${prevGroups.length} group(s), ${prevComponents} component(s)
${createdAt}
Re-Extraction (New)
${currentGroups.length} group(s), ${currentComponents} component(s)
${reExtractedAt}
`; return true; // banner was rendered } /** * Preview one of the dual extractions (switches displayed data without deleting) */ function previewDualExtraction(which) { const pageNum = sharedState.session.currentPage; const pageData = sharedState.allPageExtractions[pageNum]; if (!pageData) return; let groups; if (which === 'previous' && pageData.previousExtraction) { groups = pageData.previousExtraction.groups; } else { groups = pageData.groups; } if (!groups || groups.length === 0) { alert('No data in this extraction.'); return; } // Temporarily display these groups (read-only preview) sharedState.allGroups = groups.map(normalizeGroup).filter(Boolean); sharedState.currentGroupIndex = 0; const firstGroup = sharedState.allGroups[0]; sharedState.hardware = { groupNumber: firstGroup.group_number, groupName: firstGroup.group_name, doorMarks: firstGroup.door_marks || [], components: firstGroup.components || [] }; renderComponents(); } /** * 26L: Delete one extraction (delete-the-loser) * Calls backend DELETE, reloads surviving extraction */ async function deleteExtraction(which) { const confirmed = confirm( which === 'previous' ? 'Delete the LEGACY extraction? The re-extracted data will be kept.' : 'Delete the RE-EXTRACTION? The legacy data will be restored as current.' ); if (!confirmed) return; const sessionId = sharedState.session.id; const pageNum = sharedState.session.currentPage; try { const result = await callAPI( `/api/hardware-schedule/session/${sessionId}/page/${pageNum}/extraction/${which}`, { method: 'DELETE' } ); if (result.error) { alert('Failed to delete extraction: ' + result.error); return; } console.log(`[26L] Deleted ${which} extraction. Surviving data loaded.`); // Parse surviving extraction and reload UI const survivingData = JSON.parse(result.surviving_extraction); const rawGroups = survivingData.hardware_groups || survivingData.hardwareGroups || []; const normalizedGroups = rawGroups.map(normalizeGroup).filter(Boolean); // Apply surviving affirm state if available const groupsWithAffirm = result.surviving_affirm_state ? applyAffirmState(normalizedGroups, result.surviving_affirm_state) : normalizedGroups; // Clear dual-extraction state sharedState.allPageExtractions[pageNum] = { groups: groupsWithAffirm, extracted: true, pageNumber: pageNum, previousExtraction: null // interlock cleared }; // Reload into active state sharedState.allGroups = groupsWithAffirm; sharedState.currentGroupIndex = 0; if (sharedState.allGroups.length > 0) { const firstGroup = sharedState.allGroups[0]; sharedState.hardware = { groupNumber: firstGroup.group_number, groupName: firstGroup.group_name, doorMarks: firstGroup.door_marks || [], components: firstGroup.components || [] }; } renderComponents(); updateGroupNavigation(); renderTocProgressBar(); alert(`${which === 'previous' ? 'Legacy' : 'Re-extraction'} deleted. Affirm is now unblocked.`); } catch (error) { console.error('[26L] Delete extraction failed:', error); alert('Failed to delete extraction: ' + error.message); } } // =========================== // 26M: CARD-LEVEL RE-EXTRACTION // CH-2026-0209-REEXTRACT-002 // =========================== // State for targeted region re-extraction const reExtractState = { active: false, targetGroupIndex: null, targetGroupNumber: null, // After extraction, stores new group for delete-the-loser comparison pendingNewGroup: null, pendingOldGroup: null }; /** * 26M: Initiate card-level re-extraction for a specific group. * Opens region selector overlay in targeted mode (locked to hardware_schedule). */ function reExtractGroup(groupIndex) { const group = sharedState.allGroups[groupIndex]; if (!group) { showCpsToast('Group not found at index ' + groupIndex, 'error'); return; } // Set targeted mode state reExtractState.active = true; reExtractState.targetGroupIndex = groupIndex; reExtractState.targetGroupNumber = group.group_number; reExtractState.pendingNewGroup = null; reExtractState.pendingOldGroup = null; console.log(`[26M] Initiating card-level re-extraction for group #${group.group_number} (index ${groupIndex})`); // Open region selector in targeted mode openRegionSelectorOverlay(); // After overlay opens, lock to hardware_schedule and update title setTimeout(() => { const typeSelect = document.getElementById('region-schedule-type-select'); if (typeSelect) { typeSelect.value = 'hardware_schedule'; typeSelect.disabled = true; } const toolbarTitle = document.querySelector('#region-selector-overlay .toolbar-title'); if (toolbarTitle) { toolbarTitle.textContent = `Draw region for Group #${group.group_number}`; toolbarTitle.style.color = '#fbbf24'; } // Override the confirm button to use targeted extraction const confirmBtn = document.getElementById('region-confirm-btn'); if (confirmBtn) { confirmBtn.onclick = function() { confirmTargetedRegionExtraction(); }; } }, 200); } /** * 26M: Confirm targeted region extraction. * Instead of saving a candidate, immediately calls the region extract endpoint. */ async function confirmTargetedRegionExtraction() { const rect = regionOverlayState.currentRect; const sessionId = sharedState.session?.id || sharedState.session?.sessionId; if (!rect || !sessionId) { showCpsToast('Invalid selection', 'error'); return; } // Disable button to prevent double-submit const confirmBtn = document.getElementById('region-confirm-btn'); confirmBtn.disabled = true; confirmBtn.textContent = 'Extracting...'; try { // FX-2026-0122-REGION-003: Transform from rotated view back to original PDF orientation let rectForCalc = rect; let canvasWidth = regionOverlayState.sourceCanvasWidth || regionOverlayState.drawingCanvas?.width || 1; let canvasHeight = regionOverlayState.sourceCanvasHeight || regionOverlayState.drawingCanvas?.height || 1; if (regionOverlayState.rotation !== 0) { const transformed = transformToOriginalOrientation( rect, canvasWidth, canvasHeight, regionOverlayState.rotation ); rectForCalc = { x: transformed.x, y: transformed.y, width: transformed.width, height: transformed.height }; canvasWidth = transformed.refWidth; canvasHeight = transformed.refHeight; } const normalizedCoords = normalizeCoordinates(rectForCalc, canvasWidth, canvasHeight); // Calculate pixel-based bounding box for 600 DPI rendering // Region overlay renders at baseScale (3x = 216 DPI), the backend renders at 600 DPI (8.333x) // The pixel coords from the overlay are at baseScale. We need to convert to the 600 DPI scale. // renderRegionAt600DPI uses its own DPI scaling internally, so we pass the bounding box // in the same coordinate space as the 600 DPI render (PDF points * 8.333). // Actually, bounding box passed to renderRegionAt600DPI is already in 600DPI pixel space. // The region overlay coords are at baseScale (e.g., 3x). We need to scale up. const scale600DPI = 600 / 72; // 8.333 const baseScale = regionOverlayState.baseScale || 3.0; const scaleFactor = scale600DPI / baseScale; const boundingBoxPixels = { x: Math.round(rectForCalc.x * scaleFactor), y: Math.round(rectForCalc.y * scaleFactor), width: Math.round(rectForCalc.width * scaleFactor), height: Math.round(rectForCalc.height * scaleFactor) }; console.log(`[26M] Calling extract-region for page ${regionOverlayState.pageNumber}`); console.log(`[26M] Bounding box (pixels at 600DPI): ${JSON.stringify(boundingBoxPixels)}`); console.log(`[26M] Normalized: ${JSON.stringify(normalizedCoords)}`); const result = await callAPI( `/api/hardware-schedule/session/${sessionId}/page/${regionOverlayState.pageNumber}/extract-region`, { method: 'POST', body: JSON.stringify({ bounding_box: normalizedCoords, bounding_box_pixels: boundingBoxPixels }) } ); if (result.error) { throw new Error(result.error + (result.details ? ': ' + result.details : '')); } const newGroups = result.hardware_groups || []; console.log(`[26M] Extraction returned ${newGroups.length} group(s)`); closeRegionSelectorOverlay(); resetRegionSelectorFromTargetedMode(); if (newGroups.length === 0) { showCpsToast('No hardware groups found in the selected region', 'error'); reExtractState.active = false; return; } // If extraction returned exactly 1 group, show delete-the-loser at card level // If multiple groups returned, let user pick which one replaces the target const targetIdx = reExtractState.targetGroupIndex; const oldGroup = sharedState.allGroups[targetIdx]; if (newGroups.length === 1) { // Single group — show inline comparison below current group reExtractState.pendingNewGroup = normalizeGroup(newGroups[0]); reExtractState.pendingOldGroup = oldGroup; reExtractState._multiGroups = null; } else { // Multiple groups — show inline picker below current group reExtractState.pendingNewGroup = null; reExtractState._multiGroups = newGroups.map(g => normalizeGroup(g)); } // Navigate to the target group and re-render with comparison section sharedState.currentGroupIndex = targetIdx; const tgtGroup = sharedState.allGroups[targetIdx]; if (tgtGroup) { sharedState.hardware = { groupNumber: tgtGroup.group_number, groupName: tgtGroup.group_name, doorMarks: tgtGroup.door_marks || [], components: tgtGroup.components || [] }; } renderComponents(); updateGroupNavigation(); } catch (error) { console.error('[26M] Targeted extraction failed:', error); showCpsToast('Re-extraction failed: ' + error.message, 'error'); resetRegionSelectorFromTargetedMode(); } finally { confirmBtn.textContent = 'Confirm Region'; } } /** * 26M: Reset region selector from targeted mode back to normal */ function resetRegionSelectorFromTargetedMode() { const typeSelect = document.getElementById('region-schedule-type-select'); if (typeSelect) { typeSelect.disabled = false; } const toolbarTitle = document.querySelector('#region-selector-overlay .toolbar-title'); if (toolbarTitle) { toolbarTitle.textContent = 'Select Schedule Region'; toolbarTitle.style.color = ''; } const confirmBtn = document.getElementById('region-confirm-btn'); if (confirmBtn) { confirmBtn.onclick = function() { confirmRegionSelectionOverlay(); }; } } /** * 26M: Show card-level delete-the-loser comparison within the group slot. * Old group vs new group side by side. */ function renderGroupReExtractComparison(groupIndex, oldGroup, newGroup) { const layout = sharedState.session.layout; const containerId = `components-${layout === 'sidebyside' ? 'sbs' : layout === 'topbottom' ? 'tb' : layout === 'tabbed' ? 'tabbed' : 'modal'}`; const container = document.getElementById(containerId); if (!container) return; const oldComps = oldGroup.components || []; const newComps = newGroup.components || []; container.innerHTML = `
Card-Level Re-Extraction — Group #${oldGroup.group_number || groupIndex}

Choose which version to keep. The loser will be discarded. Other groups are unaffected.

Current Group #${oldGroup.group_number}
${oldComps.length} component(s): ${oldComps.map(c => c.type || c.component_type || '?').join(', ')}
Doors: ${(oldGroup.door_marks || []).join(', ') || 'none'}
Re-extracted (New)
${newComps.length} component(s): ${newComps.map(c => c.type || c.component_type || '?').join(', ')}
Group: ${newGroup.group_number || '?'}, Doors: ${(newGroup.door_marks || []).join(', ') || 'none'}
`; } /** * 26M: User chose which group to keep (delete-the-loser at card level) */ async function keepReExtractGroup(which) { const targetIdx = reExtractState.targetGroupIndex; const sessionId = sharedState.session.id; const pageNum = sharedState.session.currentPage; if (which === 'old') { // Keep current, discard re-extraction console.log(`[26M] User kept current group at index ${targetIdx}. Discarding re-extraction.`); reExtractState.active = false; reExtractState.pendingNewGroup = null; reExtractState.pendingOldGroup = null; renderComponents(); showCpsToast('Current group kept. Re-extraction discarded.', 'info'); return; } // Keep new: call backend PATCH to replace group in extracted_data const newGroup = reExtractState.pendingNewGroup; if (!newGroup) { showCpsToast('No re-extracted group data available', 'error'); return; } try { showCpsToast('Saving new group...', 'info'); // Convert normalized group back to extraction format for storage const storageGroup = { group_number: newGroup.group_number, group_name: newGroup.group_name, door_marks: newGroup.door_marks || [], keying_system: newGroup.keying_system || null, construction_cores: newGroup.construction_cores || null, keys_provided: newGroup.keys_provided || null, components: (newGroup.components || []).map(c => ({ component_type: c.type || c.component_type || '', quantity: c.quantity || '', uom: c.uom || 'EA', manufacturer_code: c.manufacturer || c.manufacturer_code || '', model_number: c.model || c.model_number || '', finish_code: c.finish || c.finish_code || '', description: c.description || '', notes: c.notes || '' })) }; const result = await callAPI( `/api/hardware-schedule/session/${sessionId}/page/${pageNum}/group/${targetIdx}/replace`, { method: 'PATCH', body: JSON.stringify({ new_group_data: storageGroup }) } ); if (result.error) { throw new Error(result.error); } console.log(`[26M] Group ${targetIdx} replaced successfully`); // Update local state sharedState.allGroups[targetIdx] = newGroup; // Clear affirm on the replaced group sharedState.allGroups[targetIdx].components = (newGroup.components || []).map(c => ({ ...c, affirmed: false })); // Update allPageExtractions const pageData = sharedState.allPageExtractions[pageNum]; if (pageData && pageData.groups) { pageData.groups[targetIdx] = sharedState.allGroups[targetIdx]; } // If this was the currently displayed group, reload it if (sharedState.currentGroupIndex === targetIdx) { const group = sharedState.allGroups[targetIdx]; sharedState.hardware = { groupNumber: group.group_number, groupName: group.group_name, doorMarks: group.door_marks || [], components: group.components || [] }; } // Clean up state reExtractState.active = false; reExtractState.pendingNewGroup = null; reExtractState.pendingOldGroup = null; renderComponents(); updateGroupNavigation(); showCpsToast('Group replaced. Affirm state reset — please review and re-affirm.', 'success'); } catch (error) { console.error('[26M] Group replace failed:', error); showCpsToast('Failed to replace group: ' + error.message, 'error'); } } /** * 26M: Cancel re-extraction comparison — keep current group */ function cancelReExtractGroup() { reExtractState.active = false; reExtractState.pendingNewGroup = null; reExtractState.pendingOldGroup = null; renderComponents(); showCpsToast('Re-extraction cancelled.', 'info'); } /** * 26M: Multi-group picker when extraction returns more than one group. * User picks which extracted group should replace the target. */ function showMultiGroupReExtractPicker(targetIdx, oldGroup, extractedGroups) { const layout = sharedState.session.layout; const containerId = `components-${layout === 'sidebyside' ? 'sbs' : layout === 'topbottom' ? 'tb' : layout === 'tabbed' ? 'tabbed' : 'modal'}`; const container = document.getElementById(containerId); if (!container) return; let groupCards = extractedGroups.map((g, i) => { const comps = g.components || []; return `
Extracted Group ${i + 1}: #${g.group_number || '?'}
${comps.length} component(s): ${comps.map(c => c.type || '?').join(', ')}
`; }).join(''); container.innerHTML = `
Multiple Groups Found — Pick One for Group #${oldGroup.group_number || targetIdx}

The selected region contained ${extractedGroups.length} groups. Choose which one should replace Group #${oldGroup.group_number || targetIdx}.

${groupCards}
`; // Store extracted groups for picking reExtractState._multiGroups = extractedGroups; } /** * 26M: User picks one of multiple extracted groups */ function pickReExtractGroup(extractedIndex) { const groups = reExtractState._multiGroups; if (!groups || !groups[extractedIndex]) { showCpsToast('Invalid group selection', 'error'); return; } const targetIdx = reExtractState.targetGroupIndex; const oldGroup = sharedState.allGroups[targetIdx]; const newGroup = groups[extractedIndex]; // Show inline comparison with the picked group reExtractState.pendingNewGroup = newGroup; reExtractState.pendingOldGroup = oldGroup; reExtractState._multiGroups = null; renderComponents(); } // =========================== // 26M v2: INLINE RE-EXTRACT COMPARISON // Renders full-detail secondary card within renderComponents // Replaces the abbreviated overlay (renderGroupReExtractComparison) // =========================== function buildReExtractSection() { if (!reExtractState.active || sharedState.currentGroupIndex !== reExtractState.targetGroupIndex) { return ''; } // Multi-group picker mode if (reExtractState._multiGroups && reExtractState._multiGroups.length > 0 && !reExtractState.pendingNewGroup) { const extractedGroups = reExtractState._multiGroups; const targetGroup = sharedState.allGroups[reExtractState.targetGroupIndex]; let sections = extractedGroups.map((g, i) => { const comps = g.components || []; const doorMarks = (g.door_marks || []).join(', ') || '(not specified)'; let compRows = comps.map((c, ci) => `
${ci + 1} ${c.type || c.component_type || '?'} ${c.quantity || ''} ${c.uom || 'EA'} ${c.manufacturer || c.manufacturer_code || ''} ${c.model || c.model_number || ''} ${c.finish || c.finish_code || ''}
`).join(''); return `
Extracted Group ${i + 1}: #${g.group_number || '?'} ${g.group_name || ''}
Doors: ${doorMarks} | ${comps.length} component(s)
# Type Qty Mfr Model Finish
${compRows}
`; }).join(''); return `
Multiple Groups Found — Choose One for Group #${targetGroup?.group_number || reExtractState.targetGroupIndex}

The selected region contained ${extractedGroups.length} groups. Review each below and choose which one replaces the current group above.

${sections}
`; } // Single group comparison mode const newGroup = reExtractState.pendingNewGroup; if (!newGroup) return ''; const comps = newGroup.components || []; const doorMarks = (newGroup.door_marks || []).join(', ') || '(not specified)'; let compCards = comps.map((comp, idx) => `
Component #${idx + 1}
${comp.type || comp.component_type || ''}
${comp.quantity || ''}
${comp.uom || 'EA'}
${comp.manufacturer || comp.manufacturer_code || ''}
${comp.model || comp.model_number || ''}
${comp.finish || comp.finish_code || ''}
`).join(''); return `
Re-Extracted Version — Group #${newGroup.group_number || '?'}
${comps.length} component(s)
${newGroup.group_number || ''}
${newGroup.group_name || ''}
${doorMarks}
${compCards}
`; } // =========================== // COMPONENT RENDERING // =========================== function renderComponents() { const layout = sharedState.session.layout; const containerId = `components-${layout === 'sidebyside' ? 'sbs' : layout === 'topbottom' ? 'tb' : layout === 'tabbed' ? 'tabbed' : 'modal'}`; const container = document.getElementById(containerId); if (!container) return; // 26L: Show dual-extraction selector banner if interlock is active if (hasDualExtraction()) { // Create a wrapper that shows selector above components const selectorDiv = document.createElement('div'); selectorDiv.id = 'extraction-selector-banner'; renderExtractionSelector(selectorDiv); // Build component content below the banner const componentDiv = document.createElement('div'); componentDiv.id = 'extraction-component-preview'; // Render the rest of the component panel into componentDiv for preview container.innerHTML = ''; container.appendChild(selectorDiv); // Show current extraction data read-only below the banner const pageData = sharedState.allPageExtractions[sharedState.session.currentPage]; const groups = pageData?.groups || []; const totalComps = groups.reduce((sum, g) => sum + (g.components?.length || 0), 0); componentDiv.innerHTML = `
Affirm blocked — resolve dual extraction above
Showing: ${groups.length} group(s), ${totalComps} component(s)
`; container.appendChild(componentDiv); return; } // Format door marks for display - merge extraction data with database index // First, get the current group for cross-referencing const currentGroup = sharedState.allGroups[sharedState.currentGroupIndex]; const mergedDoors = currentGroup ? getMergedDoorsForGroup(currentGroup) : (sharedState.hardware.doorMarks || []); const doorMarksDisplay = mergedDoors.length > 0 ? mergedDoors.join(', ') : '(not specified)'; // Calculate affirm status for this group const affirmedComponents = sharedState.hardware.components.filter(c => c.affirmed).length; const totalComponents = sharedState.hardware.components.length; const allComponentsAffirmed = totalComponents > 0 && affirmedComponents === totalComponents; const groupAffirmed = sharedState.hardware.affirmed || false; const hasFlaggedComponents = sharedState.hardware.components.some(c => c.flagged); // Calculate can affirm for group const canAffirmGroup = allComponentsAffirmed && !hasFlaggedComponents && sharedState.hardware.groupNumber; container.innerHTML = `
${allComponentsAffirmed && groupAffirmed ? '' : '' } Affirmed: ${affirmedComponents + (groupAffirmed ? 1 : 0)} of ${totalComponents + 1} ${!allComponentsAffirmed ? ' (affirm all components first)' : !groupAffirmed ? ' (affirm group to complete)' : ' Ready for submittal!'} ${totalComponents > 0 && !allComponentsAffirmed ? ` ` : ''} ${totalComponents > 0 ? ` ` : ''}
Hardware Group #${sharedState.hardware.groupNumber} ${groupAffirmed ? 'Affirmed' : ''}
Group ${sharedState.currentGroupIndex + 1} of ${sharedState.allGroups.length}
${sharedState.hardware.components.map((comp, idx) => { const canAffirmComp = !comp.flagged && (comp.type || comp.component_type) && (comp.manufacturer || comp.manufacturer_code); return `
${comp.affirmed ? '✓' : ''} Component #${idx + 1}
${comp.cutSheetStatus === 'verified' ? ` 📄 Cut Sheet Verified ` : comp.cutSheetStatus === 'pending' ? ` ⏳ Awaiting Review ` : comp.cutSheetStatus === 'searching' ? ` 🔍 Searching... ` : comp.cutSheetStatus === 'not_found' ? ` 📭 Not in Catalogue ` : comp.cutSheetStatus === 'found' ? ` 📋 Match Found ` : ` ${comp.affirmed ? ` ` : ''} ${!comp.affirmed ? 'Affirm for CPS' : ''} `}
`}).join('')} ${sharedState.hardware.components.length > 0 ? `
${canAffirmGroup ? 'All components validated - ready to affirm group' : `${affirmedComponents}/${totalComponents} components affirmed`}
` : `

No components extracted for this group.


`} ${buildReExtractSection()} `; console.log(`[RENDER] Components rendered for layout: ${layout}`); } // =========================== // DATA UPDATES // =========================== function updateGroupData(field, value) { sharedState.hardware[field] = value; saveState(); // Mark page as modified in cache savePageData(sharedState.session.id, sharedState.session.currentPage, { extracted: true, modified: true, approved: false, hardwareData: sharedState.hardware }); // Update progress bar to reflect modification renderTocProgressBar(); // Real-time submittal preview update refreshSubmittalPreviewIfOpen(); console.log(`[UPDATE] Set ${field}:`, value); } function updateComponent(idx, field, value) { sharedState.hardware.components[idx][field] = value; saveState(); // Mark page as modified in cache savePageData(sharedState.session.id, sharedState.session.currentPage, { extracted: true, modified: true, approved: false, hardwareData: sharedState.hardware }); // Update progress bar to reflect modification renderTocProgressBar(); // Real-time submittal preview update refreshSubmittalPreviewIfOpen(); console.log(`[UPDATE] Component ${idx} ${field}:`, value); } function toggleFlag(idx) { const comp = sharedState.hardware.components[idx]; comp.flagged = !comp.flagged; // If flagging, also unaffirm the component and group if (comp.flagged) { comp.affirmed = false; sharedState.hardware.affirmed = false; } saveState(); // Sync flag change to allGroups saveCurrentGroupEdits(); // Mark page as modified in cache savePageData(sharedState.session.id, sharedState.session.currentPage, { extracted: true, modified: true, approved: false, hardwareData: sharedState.hardware, allGroups: sharedState.allGroups // Save all groups including flag state }); renderComponents(); renderTocProgressBar(); updateSubmittalButtonState(); console.log(`[FLAG] Component ${idx} flagged:`, comp.flagged); } // =========================== // AFFIRM TOGGLE FUNCTIONS // P0 Feature: Human validation workflow // =========================== /** * Toggle affirm state for a single component * Persists to D1 via API call */ async function toggleComponentAffirm(idx, affirmed) { // 26L: Block affirm during dual-extraction interlock if (affirmed && hasDualExtraction()) { alert('Cannot affirm: dual extractions exist. Delete one extraction first.'); renderComponents(); return; } const comp = sharedState.hardware.components[idx]; // Cannot affirm if flagged or missing required fields if (affirmed && comp.flagged) { alert('Cannot affirm a flagged component. Remove the flag first.'); renderComponents(); return; } if (affirmed && (!comp.type && !comp.component_type)) { alert('Cannot affirm: Component type is required.'); renderComponents(); return; } if (affirmed && (!comp.manufacturer && !comp.manufacturer_code)) { alert('Cannot affirm: Manufacturer is required.'); renderComponents(); return; } // Persist to server FIRST (trust substrate - server is source of truth) try { const sessionId = sharedState.session?.id; const pageNum = sharedState.session?.currentPage || 1; const groupIndex = sharedState.hardware?.groupNumber || 0; if (sessionId) { // Send componentData and groupData so backend can handle // components added client-side (not yet in extracted_data) const result = await callAPI( `/api/hardware-schedule/session/${sessionId}/page/${pageNum}/component/${idx}/affirm`, { method: 'PATCH', body: JSON.stringify({ groupIndex: groupIndex, affirmed: affirmed, componentData: { component_type: comp.type || comp.component_type || '', quantity: comp.quantity || '1', manufacturer_code: comp.manufacturer || comp.manufacturer_code || '', model_number: comp.model || comp.model_number || '', finish_code: comp.finish || comp.finish_code || '', description: comp.description || '', notes: comp.notes || '' }, groupData: { group_number: sharedState.hardware.groupNumber, group_name: sharedState.hardware.groupName || '' } }) } ); if (result.error) { console.error('[AFFIRM] Server error:', result.error); alert('Failed to save affirmation: ' + (result.message || result.error)); renderComponents(); return; } console.log('[AFFIRM] Server confirmed:', result); } } catch (error) { console.error('[AFFIRM] API call failed:', error); alert('Failed to save affirmation to server: ' + error.message); renderComponents(); return; } // Update local state after server confirms comp.affirmed = affirmed; // If unaffirming a component, also unaffirm the group if (!affirmed && sharedState.hardware.affirmed) { sharedState.hardware.affirmed = false; } saveState(); saveCurrentGroupEdits(); // Mark page as modified in cache savePageData(sharedState.session.id, sharedState.session.currentPage, { extracted: true, modified: true, approved: false, hardwareData: sharedState.hardware, allGroups: sharedState.allGroups }); renderComponents(); updateSubmittalButtonState(); refreshSubmittalPreviewIfOpen(); console.log(`[AFFIRM] Component ${idx} affirmed:`, affirmed); } /** * Toggle affirm state for the entire group * Can only be affirmed when ALL components are affirmed */ function toggleGroupAffirm(affirmed) { // 26L: Block affirm during dual-extraction interlock if (affirmed && hasDualExtraction()) { alert('Cannot affirm group: dual extractions exist. Delete one extraction first.'); renderComponents(); return; } if (affirmed) { // Verify all components are affirmed const unaffirmed = sharedState.hardware.components.filter(c => !c.affirmed); if (unaffirmed.length > 0) { alert(`Cannot affirm group: ${unaffirmed.length} component(s) must be affirmed first.`); renderComponents(); return; } // Check for flagged components const flagged = sharedState.hardware.components.filter(c => c.flagged); if (flagged.length > 0) { alert(`Cannot affirm group: ${flagged.length} component(s) are flagged for review.`); renderComponents(); return; } // Check group has required fields if (!sharedState.hardware.groupNumber) { alert('Cannot affirm group: Group number is required.'); renderComponents(); return; } } sharedState.hardware.affirmed = affirmed; saveState(); saveCurrentGroupEdits(); // Mark page as modified in cache savePageData(sharedState.session.id, sharedState.session.currentPage, { extracted: true, modified: true, approved: false, hardwareData: sharedState.hardware, allGroups: sharedState.allGroups }); renderComponents(); updateSubmittalButtonState(); refreshSubmittalPreviewIfOpen(); console.log(`[AFFIRM] Group affirmed:`, affirmed); // 26K: Notify server of group affirm — triggers materialization into // hardware_sets + hardware_components for downstream consumers (takeoff, submittal, HW set pages) const groupIndex = sharedState.hardware.groupNumber || sharedState.hardware.group_number || '0'; callAPI( `/api/hardware-schedule/session/${sharedState.session.id}/page/${sharedState.session.currentPage}/group/${groupIndex}/affirm`, { method: 'PATCH', body: JSON.stringify({ affirmed }) } ).then(result => { if (result.materialization) { console.log(`[AFFIRM] Materialized:`, result.materialization); } }).catch(err => console.warn('[AFFIRM] Group server sync failed (non-blocking):', err.message)); } /** * Affirm all valid components at once */ function affirmAllComponents() { let affirmedCount = 0; let skippedCount = 0; sharedState.hardware.components.forEach((comp, idx) => { const canAffirm = !comp.flagged && (comp.type || comp.component_type) && (comp.manufacturer || comp.manufacturer_code); if (canAffirm && !comp.affirmed) { comp.affirmed = true; affirmedCount++; } else if (!canAffirm) { skippedCount++; } }); saveState(); saveCurrentGroupEdits(); savePageData(sharedState.session.id, sharedState.session.currentPage, { extracted: true, modified: true, approved: false, hardwareData: sharedState.hardware, allGroups: sharedState.allGroups }); renderComponents(); updateSubmittalButtonState(); refreshSubmittalPreviewIfOpen(); if (skippedCount > 0) { alert(`Affirmed ${affirmedCount} component(s). ${skippedCount} skipped (flagged or missing required fields).`); } else { console.log(`[AFFIRM] Affirmed all ${affirmedCount} components`); } } /** * Unaffirm a component when its data changes */ function unaffirmComponent(idx) { const comp = sharedState.hardware.components[idx]; if (comp.affirmed) { comp.affirmed = false; sharedState.hardware.affirmed = false; // Also unaffirm group console.log(`[AFFIRM] Component ${idx} unaffirmed due to edit`); } } /** * Unaffirm the group when group data changes */ function unaffirmGroup() { if (sharedState.hardware.affirmed) { sharedState.hardware.affirmed = false; console.log('[AFFIRM] Group unaffirmed due to edit'); } } /** * Update the Submittal button state based on affirm status */ function updateSubmittalButtonState() { // Find submittal button - :contains() is jQuery, use valid CSS selectors const submittalBtn = document.querySelector('.btn-nav-action[onclick*="submittal"], .btn-submittal, [data-action="submittal"]'); // Will implement in next iteration - for now just log console.log('[AFFIRM] Submittal button state check triggered'); } // ================================================================ // MARK REVIEW FUNCTIONS — Gate 1 Trust Substrate // CH-2026-0126-UI-002 // ================================================================ // State for door schedule mark review let doorScheduleMarks = []; let selectedMarkIndex = -1; let markReviewActive = false; /** * Render a single door entry card */ function renderDoorEntryCard(mark, idx) { const isAffirmed = mark.validation_status === 'affirmed'; const isFlagged = mark.validation_status === 'rejected' || mark.flagged; const isSelected = idx === selectedMarkIndex; // Fire rating options const fireOptions = ['NR', '20 MIN', '45 MIN', '60 MIN', '90 MIN']; // Get unique hardware groups from current marks for dropdown const hwGroups = [...new Set(doorScheduleMarks.map(m => m.hardware_group).filter(Boolean))]; return `
${isAffirmed ? '✓ ' : ''}Door Entry #${idx + 1}
Identity & Assignment
${hwGroups.map(g => `
Fire & Dimensions
Door Properties
Frame Properties
Additional
`; } /** * Display all door schedule marks for a session */ async function displayDoorScheduleMarks(sessionId) { console.log('[MARK REVIEW] Loading marks for session:', sessionId); try { const response = await fetch(`${API_BASE_URL}/api/door-schedule/session/${sessionId}/marks`, { headers: { 'Authorization': `Bearer ${authState.token}` } }); if (!response.ok) { throw new Error(`Failed to fetch marks: ${response.status}`); } const data = await response.json(); doorScheduleMarks = data.marks || []; console.log(`[MARK REVIEW] Loaded ${doorScheduleMarks.length} marks`); // Update stats updateMarkStats(data.summary || {}); // Show panel, hide components document.getElementById('mark-review-panel').style.display = 'block'; document.getElementById('components-sbs').style.display = 'none'; // Activate keyboard navigation markReviewActive = true; selectedMarkIndex = doorScheduleMarks.length > 0 ? 0 : -1; // Render based on mode (default: focused) // Reset mode toggle checkbox to match state const modeToggle = document.getElementById('mark-list-mode-toggle'); if (modeToggle) modeToggle.checked = (markReviewMode === 'list'); if (markReviewMode === 'list') { renderAllMarkCards(); document.getElementById('mark-nav-container').style.display = 'none'; document.getElementById('mark-progress-bar').style.display = 'none'; } else { // Focused mode (default) if (selectedMarkIndex >= 0) { renderSingleMarkCard(selectedMarkIndex); updateMarkNavigation(); } document.getElementById('mark-nav-container').style.display = 'flex'; document.getElementById('mark-progress-bar').style.display = 'flex'; } } catch (error) { console.error('[MARK REVIEW] Error loading marks:', error); alert('Failed to load door schedule marks: ' + error.message); } } /** * Update mark review statistics display */ function updateMarkStats(summary) { document.getElementById('mark-total-count').textContent = summary.total || doorScheduleMarks.length; document.getElementById('mark-affirmed-count').textContent = summary.affirmed || 0; document.getElementById('mark-flagged-count').textContent = summary.rejected || 0; } /** * Select a mark (for keyboard navigation) */ function selectMark(idx) { selectedMarkIndex = idx; highlightSelectedMark(); } /** * Highlight the currently selected mark */ function highlightSelectedMark() { // Remove selected from all document.querySelectorAll('.door-entry-card').forEach(card => card.classList.remove('selected')); // Add selected to current const current = document.getElementById(`door-entry-${selectedMarkIndex}`); if (current) { current.classList.add('selected'); current.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); } } /** * Update a mark field — CRITICAL: auto-unaffirm on any edit */ function updateMarkField(idx, field, value) { const mark = doorScheduleMarks[idx]; if (!mark) return; // Store original value for correction tracking const originalValue = mark[field]; mark[field] = value; // CRITICAL: Auto-unaffirm on any edit if (mark.validation_status === 'affirmed') { mark.validation_status = 'pending'; console.log(`[MARK REVIEW] Mark ${idx} auto-unaffirmed due to edit of ${field}`); } // Call API to persist correction correctMarkAPI(mark.id, { [field]: value }); // Re-render the card to reflect state change const cardEl = document.getElementById(`door-entry-${idx}`); if (cardEl) { cardEl.outerHTML = renderDoorEntryCard(mark, idx); } // Update stats recalculateMarkStats(); } /** * Unaffirm a mark (called on edit) */ function unaffirmMark(idx) { const mark = doorScheduleMarks[idx]; if (mark && mark.validation_status === 'affirmed') { mark.validation_status = 'pending'; console.log(`[MARK REVIEW] Mark ${idx} unaffirmed`); recalculateMarkStats(); } } /** * Toggle mark affirmation */ async function toggleMarkAffirm(idx, affirmed) { const mark = doorScheduleMarks[idx]; if (!mark) return; // Cannot affirm a flagged mark if (affirmed && (mark.validation_status === 'rejected' || mark.flagged)) { alert('Cannot affirm a flagged entry. Remove flag first.'); return; } try { if (affirmed) { await fetch(`${API_BASE_URL}/api/door-schedule/mark/${mark.id}/affirm`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${authState.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({}) }); mark.validation_status = 'affirmed'; console.log(`[MARK REVIEW] Mark ${idx} affirmed`); // FX-2026-0206-NAV-001: Auto-advance removed. // Was causing double-advance when user pressed Enter (affirm) then ArrowRight (next). // Affirm handler advanced to next unaffirmed, then dpad advanced one more. // User controls navigation via dpad. Affirm stays on current card. } else { // Unaffirm via correction endpoint (sets status to pending) mark.validation_status = 'pending'; } // Re-render card const cardEl = document.getElementById(`door-entry-${idx}`); if (cardEl) { cardEl.outerHTML = renderDoorEntryCard(mark, idx); } recalculateMarkStats(); } catch (error) { console.error('[MARK REVIEW] Error toggling affirm:', error); } } /** * Toggle mark flag (for review) */ async function toggleMarkFlag(idx) { const mark = doorScheduleMarks[idx]; if (!mark) return; const wasFlagged = mark.validation_status === 'rejected' || mark.flagged; try { if (!wasFlagged) { // Flag the mark await fetch(`${API_BASE_URL}/api/door-schedule/mark/${mark.id}/reject`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${authState.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: 'flagged_for_review' }) }); mark.validation_status = 'rejected'; mark.flagged = true; console.log(`[MARK REVIEW] Mark ${idx} flagged for review`); } else { // Unflag by setting to pending (via correction with empty body) mark.validation_status = 'pending'; mark.flagged = false; console.log(`[MARK REVIEW] Mark ${idx} unflagged`); } // Re-render card const cardEl = document.getElementById(`door-entry-${idx}`); if (cardEl) { cardEl.outerHTML = renderDoorEntryCard(mark, idx); } recalculateMarkStats(); } catch (error) { console.error('[MARK REVIEW] Error toggling flag:', error); } } /** * Call API to persist mark correction */ async function correctMarkAPI(markId, corrections) { try { await fetch(`${API_BASE_URL}/api/door-schedule/mark/${markId}/correct`, { method: 'PATCH', headers: { 'Authorization': `Bearer ${authState.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ corrections }) }); } catch (error) { console.error('[MARK REVIEW] Error saving correction:', error); } } /** * Recalculate and update mark statistics */ function recalculateMarkStats() { const total = doorScheduleMarks.length; const affirmed = doorScheduleMarks.filter(m => m.validation_status === 'affirmed').length; const flagged = doorScheduleMarks.filter(m => m.validation_status === 'rejected' || m.flagged).length; document.getElementById('mark-total-count').textContent = total; document.getElementById('mark-affirmed-count').textContent = affirmed; document.getElementById('mark-flagged-count').textContent = flagged; } /** * Keyboard navigation for mark review */ function handleMarkReviewKeyboard(e) { if (!markReviewActive) return; // Skip if focus is in an input field if (['INPUT', 'SELECT', 'TEXTAREA'].includes(e.target.tagName)) { if (e.key === 'Escape') { e.target.blur(); e.preventDefault(); } return; } switch (e.key) { // Focused mode: ←→ for navigation case 'ArrowLeft': if (markReviewMode === 'focused') { e.preventDefault(); prevMark(); } break; case 'ArrowRight': if (markReviewMode === 'focused') { e.preventDefault(); nextMark(); } break; // List mode: ↓↑/jk for navigation case 'ArrowDown': case 'j': if (markReviewMode === 'list') { e.preventDefault(); if (selectedMarkIndex < doorScheduleMarks.length - 1) { selectedMarkIndex++; highlightSelectedMark(); } } break; case 'ArrowUp': case 'k': if (markReviewMode === 'list') { e.preventDefault(); if (selectedMarkIndex > 0) { selectedMarkIndex--; highlightSelectedMark(); } } break; case 'Enter': e.preventDefault(); if (selectedMarkIndex >= 0) { const mark = doorScheduleMarks[selectedMarkIndex]; if (mark.validation_status !== 'affirmed' && mark.validation_status !== 'rejected') { toggleMarkAffirm(selectedMarkIndex, true); } } break; case 'f': case 'F': e.preventDefault(); if (selectedMarkIndex >= 0) { toggleMarkFlag(selectedMarkIndex); } break; case 'e': case 'E': // Only in list mode — focused mode uses Tab if (markReviewMode === 'list') { e.preventDefault(); if (selectedMarkIndex >= 0) { const card = document.getElementById(`door-entry-${selectedMarkIndex}`); if (card) { const firstInput = card.querySelector('input, select'); if (firstInput) firstInput.focus(); } } } break; case 'Escape': // Blur any focused element and deselect in list mode if (document.activeElement) document.activeElement.blur(); if (markReviewMode === 'list') { selectedMarkIndex = -1; document.querySelectorAll('.door-entry-card').forEach(card => card.classList.remove('selected')); } break; } } // Register keyboard handler document.addEventListener('keydown', handleMarkReviewKeyboard); // ================================================================ // MARK N-of-M NAVIGATION (CH-2026-0128-UI-004) // ================================================================ // Mode state: 'focused' (default) or 'list' let markReviewMode = 'focused'; /** * Toggle between focused (N-of-M) and list view modes */ function toggleMarkReviewMode(isListMode) { markReviewMode = isListMode ? 'list' : 'focused'; console.log('[MARK NAV] Mode changed to:', markReviewMode); const navContainer = document.getElementById('mark-nav-container'); const progressBar = document.getElementById('mark-progress-bar'); const keyboardHint = document.getElementById('mark-keyboard-hint'); if (markReviewMode === 'list') { // List mode: hide nav, show all cards if (navContainer) navContainer.style.display = 'none'; if (progressBar) progressBar.style.display = 'none'; if (keyboardHint) keyboardHint.textContent = '↓↑ navigate · Enter affirm · f flag · e edit'; renderAllMarkCards(); } else { // Focused mode: show nav, single card if (navContainer) navContainer.style.display = 'flex'; if (progressBar) progressBar.style.display = 'flex'; if (keyboardHint) keyboardHint.textContent = '←→ navigate · Tab fields · Enter affirm · f flag'; renderSingleMarkCard(selectedMarkIndex >= 0 ? selectedMarkIndex : 0); updateMarkNavigation(); } } /** * Navigate to previous MARK */ function prevMark() { if (selectedMarkIndex > 0) { selectedMarkIndex--; renderSingleMarkCard(selectedMarkIndex); updateMarkNavigation(); console.log('[MARK NAV] Prev → MARK', selectedMarkIndex + 1); } } /** * Navigate to next MARK */ function nextMark() { if (selectedMarkIndex < doorScheduleMarks.length - 1) { selectedMarkIndex++; renderSingleMarkCard(selectedMarkIndex); updateMarkNavigation(); console.log('[MARK NAV] Next → MARK', selectedMarkIndex + 1); } } /** * Jump to a specific MARK number (1-indexed) */ function jumpToMark(markNum) { const idx = parseInt(markNum, 10) - 1; if (idx >= 0 && idx < doorScheduleMarks.length) { selectedMarkIndex = idx; renderSingleMarkCard(selectedMarkIndex); updateMarkNavigation(); console.log('[MARK NAV] Jump → MARK', markNum); } // Clear the input const jumpInput = document.getElementById('mark-jump-input'); if (jumpInput) jumpInput.value = ''; } /** * Update N-of-M label, button states, and progress bar */ function updateMarkNavigation() { const total = doorScheduleMarks.length; const current = selectedMarkIndex + 1; // Update label const label = document.getElementById('mark-nav-label'); if (label) label.textContent = `MARK ${current} of ${total}`; // Update button states const prevBtn = document.getElementById('mark-nav-prev'); const nextBtn = document.getElementById('mark-nav-next'); if (prevBtn) prevBtn.disabled = selectedMarkIndex <= 0; if (nextBtn) nextBtn.disabled = selectedMarkIndex >= total - 1; // Update jump input max const jumpInput = document.getElementById('mark-jump-input'); if (jumpInput) jumpInput.max = total; // Render progress bar renderMarkProgressBar(); } /** * Render progress dots showing status of each MARK */ function renderMarkProgressBar() { const container = document.getElementById('mark-progress-bar'); if (!container) return; let html = ''; doorScheduleMarks.forEach((mark, idx) => { let color = '#475569'; // gray = pending let title = 'Pending'; if (mark.validation_status === 'affirmed') { color = '#22c55e'; // green title = 'Affirmed'; } else if (mark.validation_status === 'rejected') { color = '#eab308'; // yellow title = 'Flagged'; } const isCurrent = idx === selectedMarkIndex; const size = isCurrent ? '10px' : '8px'; const border = isCurrent ? '2px solid #3b82f6' : 'none'; html += `
`; }); container.innerHTML = html; } /** * Render a single MARK card (focused mode) */ function renderSingleMarkCard(idx) { const cardList = document.getElementById('mark-card-list'); if (!cardList || !doorScheduleMarks[idx]) return; const mark = doorScheduleMarks[idx]; cardList.innerHTML = renderDoorEntryCard(mark, idx); // Highlight as selected const card = document.getElementById(`door-entry-${idx}`); if (card) card.classList.add('selected'); } /** * Render all MARK cards (list mode) */ function renderAllMarkCards() { const cardList = document.getElementById('mark-card-list'); if (!cardList) return; cardList.innerHTML = doorScheduleMarks.map((mark, idx) => renderDoorEntryCard(mark, idx)).join(''); // Highlight selected if (selectedMarkIndex >= 0) { highlightSelectedMark(); } } // ================================================================ // CUT SHEET DISCOVERY FUNCTIONS // ================================================================ // Track current component for manual URL modal let currentCutSheetComponentIdx = null; /** * Extract model number from full model string * "4900 LE PUSH PAIR 88"" → "4900" (first token) * "L9040 26D" → "L9040" * "4040XP-CUSH" → "4040XP" * Returns first alphanumeric token that looks like a product code */ function extractModelNumber(modelString) { if (!modelString) return ''; // Clean up and get first token const cleaned = modelString.trim(); // Match known product patterns first // Schlage L-series: L followed by 4 digits const lSeriesMatch = cleaned.match(/\b(L[V]?\d{4})\b/i); if (lSeriesMatch) return lSeriesMatch[1].toUpperCase(); // LCN 4000 series: 4 digits followed by letters const lcnMatch = cleaned.match(/\b(\d{4}[A-Z]{1,4})\b/i); if (lcnMatch) return lcnMatch[1].toUpperCase(); // Von Duprin 98/99 series const vdMatch = cleaned.match(/\b(98|99)[A-Z]{0,3}\b/i); if (vdMatch) return vdMatch[0].toUpperCase(); // Generic: first alphanumeric token (at least 3 chars, starts with letter or digit) const tokens = cleaned.split(/[\s,;:'"]+/); for (const token of tokens) { // Must be at least 3 chars and start with alphanumeric if (token.length >= 3 && /^[A-Z0-9]/i.test(token)) { // Strip trailing punctuation but KEEP hyphens (e.g., B520-295) const clean = token.replace(/[^A-Z0-9-]/gi, '').replace(/-+$/, ''); if (clean.length >= 3) return clean.toUpperCase(); } } return ''; } /** * Check if manufacturer names match (handles variations) */ function manufacturerMatches(compMfr, catalogueMfr) { if (!compMfr || !catalogueMfr) return true; // Can't verify, allow const a = compMfr.toLowerCase().replace(/[^a-z]/g, ''); const b = catalogueMfr.toLowerCase().replace(/[^a-z]/g, ''); return a.includes(b) || b.includes(a); } /** * Find cut sheet for component - searches CPS catalogue first, then queues for web discovery */ async function findCutSheet(idx) { const comp = sharedState.hardware.components[idx]; if (!comp) { alert('Component not found'); return; } // P0 BLOCKING RULE: Component must be affirmed before cut sheet search // This prevents "garbage in, garbage out" by ensuring users verify hardware data first // Design principle: Users must confirm component data is correct before associating cut sheets if (!comp.affirmed) { alert('⚠️ BLOCKING: Component must be affirmed first\n\n' + 'Before searching for cut sheets, please:\n' + '1. Verify the component data is correct\n' + '2. Check the "Affirm" checkbox\n\n' + 'This ensures accurate cut sheet associations.'); console.log('[CPS] Blocked cut sheet search - component not affirmed:', { idx, model: comp.model }); return; } if (!comp.manufacturer && !comp.model) { alert('Component must have manufacturer or model to search for cut sheets'); return; } // Update UI to show searching comp.cutSheetStatus = 'searching'; renderComponents(); const rawModel = comp.model || ''; const searchMfr = comp.manufacturer || ''; try { // ═══════════════════════════════════════════════════════════════════ // STEP 0: Check user's affirmed cache first (fastest path) // If user previously affirmed this component, serve from their cache // ═══════════════════════════════════════════════════════════════════ console.log('[CPS] Step 0: Checking user cache for:', searchMfr, '+', rawModel); const userCacheUrl = `/api/user/cutsheets/check?manufacturer=${encodeURIComponent(searchMfr)}&model=${encodeURIComponent(rawModel)}`; const userCacheResult = await callAPI(userCacheUrl); if (userCacheResult && userCacheResult.found) { console.log('[CPS] Step 0 HIT: User has cached cutsheet:', userCacheResult); // User already affirmed this component - use cached cut sheet comp.cutSheetStatus = 'verified'; comp.cutSheetAffirmed = true; comp.cutSheetCatalogueId = userCacheResult.catalogueId; comp.cutSheetPageNum = userCacheResult.pageStart; comp.cutSheetConfidence = 1.0; // User-affirmed = 100% confidence saveState(); renderComponents(); // Show the cached cut sheet in review modal (already affirmed) showCpsCutSheetReview({ mappingId: `user-cache-${userCacheResult.componentHash || ''}`, model: userCacheResult.model || rawModel, manufacturer: userCacheResult.manufacturer || searchMfr, confidence: 1.0, imageUrl: userCacheResult.imageUrl, componentIdx: idx, catalogueId: userCacheResult.catalogueId, pageNum: userCacheResult.pageStart, fromCache: true // Flag to indicate this is from user cache }); return; // Done - cache hit, no need to search CPS } console.log('[CPS] Step 0 MISS: No user cache, proceeding to CPS search'); // ═══════════════════════════════════════════════════════════════════ // STEP 1: Multi-field CPS search (v2.8.0) // Sends ALL component fields, ranks pages by how many fields match // ═══════════════════════════════════════════════════════════════════ const extractedModel = extractModelNumber(rawModel); const searchQuery = extractedModel || rawModel; console.log('[CPS Multi-Field] Searching with all fields:', { model: searchQuery, manufacturer: searchMfr, description: comp.description, type: comp.type, notes: comp.notes }); // Skip search if we don't have a model if (!searchQuery || searchQuery.length < 2) { console.log('[CPS] No valid model number to search, skipping CPS'); throw new Error('NO_MODEL'); } // Use multi-field scoring endpoint - ranks by how many fields match const cpsResult = await callAPI('/api/cps/search-component', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model: searchQuery, manufacturer: searchMfr, description: comp.description || '', type: comp.type || '', notes: comp.notes || comp.fieldNotes || '' }) }); // Multi-field search already ranks by score - take top result if (cpsResult && cpsResult.results && cpsResult.results.length > 0) { // Results are sorted by score - highest first const bestMatch = cpsResult.results[0]; console.log('[CPS Multi-Field] Best match:', { page: bestMatch.page_num, score: bestMatch.score, matches: bestMatch.matches?.map(m => m.term).join(', ') }); console.log('[CPS] Found validated catalogue match:', bestMatch); // Construct on-demand render URL for the cut sheet page // CRITICAL: Must use API_BASE_URL for Worker routing const pageNum = bestMatch.page_num || bestMatch.page_start || 1; const imageUrl = `${API_BASE_URL}/api/cps/catalogues/${bestMatch.catalogue_id}/pages/${pageNum}/render`; // Calculate confidence - already validated, so start higher let confidence = 0.90; // Manufacturer match boosts confidence if (comp.manufacturer && bestMatch.manufacturer) { if (manufacturerMatches(comp.manufacturer, bestMatch.manufacturer)) { confidence = 0.95; } } // Update component status with all CPS match data comp.cutSheetStatus = 'found'; comp.cutSheetCatalogueId = bestMatch.catalogue_id; comp.cutSheetMappingId = bestMatch.mapping_id; comp.cutSheetPageNum = pageNum; comp.cutSheetConfidence = confidence; saveState(); renderComponents(); // Open CPS Review Modal - pass all data needed for affirmation showCpsCutSheetReview({ mappingId: bestMatch.mapping_id || `cps-${bestMatch.catalogue_id}-${pageNum}`, model: comp.model || bestMatch.model_number, manufacturer: comp.manufacturer || bestMatch.manufacturer, confidence: confidence, imageUrl: imageUrl, componentIdx: idx, catalogueId: bestMatch.catalogue_id, pageNum: pageNum }); return; } // STEP 2: No CPS match found - show "not found" message // v2.7.0: Web discovery deprecated; CPS is the sole cut sheet source console.log('[CPS] No catalogue match found for:', searchMfr, searchQuery); comp.cutSheetStatus = 'not_found'; saveState(); renderComponents(); // Inform user and suggest manual link submission alert(`No cut sheet found in catalogue library.\n\n` + `Searched for: ${searchMfr ? searchMfr + ' ' : ''}${searchQuery}\n\n` + `Options:\n` + `1. Use the "I Have Link" button to submit a cut sheet URL manually\n` + `2. Contact support to request this catalogue be added\n\n` + `Currently indexed catalogues: Schlage, LCN, Von Duprin, Ives, BEA`); } catch (error) { // Handle CPS-specific "not found" cases if (error.message === 'NO_MODEL' || error.message === 'NO_VALID_MATCH') { console.log('[CPS] CPS search returned no valid match:', error.message); comp.cutSheetStatus = 'not_found'; saveState(); renderComponents(); alert(`No matching cut sheet found in catalogue library.\n\n` + `The model "${comp.model || 'unknown'}" was not found in our indexed catalogues.\n\n` + `Use the "I Have Link" button to submit a cut sheet URL manually.`); return; } // Real error - show alert console.error('[CPS] Search error:', error); comp.cutSheetStatus = null; renderComponents(); alert('Failed to search for cut sheet: ' + error.message); } } /** * Find cut sheets for ALL components with manufacturer data * Shows progress modal with batch results * v2.7.0: Uses CPS catalogue search only (web discovery deprecated) */ async function findAllCutSheets() { const components = sharedState.hardware.components; // Filter to components that can be searched // P0 BLOCKING RULE: Only affirmed components can search for cut sheets const searchable = components.filter((c, idx) => { const isAffirmed = c.affirmed === true; // BLOCKING: Must be affirmed first const hasSearchData = c.manufacturer || c.model; const notVerified = c.cutSheetStatus !== 'verified'; const notSearching = c.cutSheetStatus !== 'searching'; const notFound = c.cutSheetStatus !== 'found'; return isAffirmed && hasSearchData && notVerified && notSearching && notFound; }); // Count non-affirmed components for better error message const notAffirmedCount = components.filter(c => !c.affirmed && (c.manufacturer || c.model)).length; if (searchable.length === 0) { let message = 'No components available for cut sheet search.\n\n'; if (notAffirmedCount > 0) { message += `⚠️ ${notAffirmedCount} component(s) must be AFFIRMED first.\n\n`; } message += 'Components need to be:\n' + '✓ Affirmed (verify data is correct)\n' + '✓ Have manufacturer or model data\n' + '✓ Not already have verified/found cut sheets'; alert(message); return; } // Show progress modal showBatchDiscoveryModal(searchable.length); const results = { total: searchable.length, found: 0, notFound: 0, errors: 0 }; // Process each component using CPS search for (let i = 0; i < searchable.length; i++) { const comp = searchable[i]; const idx = components.indexOf(comp); updateBatchProgress(i + 1, searchable.length, comp.manufacturer || 'Unknown', comp.model || 'Unknown'); try { comp.cutSheetStatus = 'searching'; // Extract model number for search const rawModel = comp.model || ''; const extractedModel = extractModelNumber(rawModel); const searchQuery = extractedModel || ''; const searchMfr = comp.manufacturer || ''; if (!searchQuery || searchQuery.length < 2) { console.log(`[BATCH CPS] Skipping component ${idx} - no valid model number`); comp.cutSheetStatus = 'not_found'; results.notFound++; continue; } // Build CPS search URL let cpsSearchUrl = `/api/cps/search?q=${encodeURIComponent(searchQuery)}`; if (searchMfr) { cpsSearchUrl += `&manufacturer=${encodeURIComponent(searchMfr)}`; } const cpsResult = await callAPI(cpsSearchUrl); if (cpsResult && cpsResult.results && cpsResult.results.length > 0) { // Find valid match with manufacturer/model validation let validMatch = null; for (const match of cpsResult.results) { if (comp.manufacturer && match.manufacturer) { if (!manufacturerMatches(comp.manufacturer, match.manufacturer)) { continue; } } if (searchQuery && match.excerpt) { const normalizedQuery = searchQuery.toUpperCase().replace(/[^A-Z0-9]/g, ''); const normalizedExcerpt = match.excerpt.toUpperCase().replace(/[^A-Z0-9]/g, ''); if (!normalizedExcerpt.includes(normalizedQuery)) { continue; } } validMatch = match; break; } if (validMatch) { const pageNum = validMatch.page_num || validMatch.page_start || 1; let confidence = 0.90; if (comp.manufacturer && validMatch.manufacturer && manufacturerMatches(comp.manufacturer, validMatch.manufacturer)) { confidence = 0.95; } comp.cutSheetStatus = 'found'; comp.cutSheetCatalogueId = validMatch.catalogue_id; comp.cutSheetMappingId = validMatch.mapping_id; comp.cutSheetPageNum = pageNum; comp.cutSheetConfidence = confidence; results.found++; } else { comp.cutSheetStatus = 'not_found'; results.notFound++; } } else { comp.cutSheetStatus = 'not_found'; results.notFound++; } // Small delay to avoid overwhelming API await new Promise(resolve => setTimeout(resolve, 100)); } catch (error) { console.error(`[BATCH CPS] Error for component ${idx}:`, error); comp.cutSheetStatus = 'not_found'; results.errors++; } } // Save state and update UI saveState(); renderComponents(); // Show results summary showBatchCpsResults(results); } /** * Show the batch discovery progress modal */ function showBatchDiscoveryModal(total) { // Remove any existing modal const existing = document.getElementById('batch-discovery-modal'); if (existing) existing.remove(); const modal = document.createElement('div'); modal.id = 'batch-discovery-modal'; modal.className = 'modal-overlay'; modal.innerHTML = `

🔍 Finding Cut Sheets...

Preparing to search ${total} component(s)...
Starting...
`; document.body.appendChild(modal); } /** * Update the batch discovery progress */ function updateBatchProgress(current, total, manufacturer, model) { const progressText = document.getElementById('batch-progress-text'); const progressFill = document.getElementById('batch-progress-fill'); const currentItem = document.getElementById('batch-current-item'); if (progressText) { progressText.textContent = `Processing ${current} of ${total}...`; } if (progressFill) { progressFill.style.width = `${(current / total) * 100}%`; } if (currentItem) { currentItem.textContent = `${manufacturer} - ${model}`; } } // ========================================================================= // ARCHIVED: showBatchDiscoveryResults() - Dead code removed 2026-01-13 // See DELETED_CODE_LOG.md Entry 001 for full function // Reason: Never called - findAllCutSheets() uses showBatchCpsResults() instead // ========================================================================= /** * Close the batch discovery modal */ function closeBatchDiscoveryModal() { const modal = document.getElementById('batch-discovery-modal'); if (modal) modal.remove(); } /** * Show batch CPS search results summary (v2.7.0) */ function showBatchCpsResults(results) { const modal = document.getElementById('batch-discovery-modal'); if (!modal) return; const content = modal.querySelector('.batch-discovery-modal'); if (content) { content.innerHTML = `

📚 Catalogue Search Complete

Total Searched ${results.total}
Found in Catalogue ${results.found}
Not in Catalogue ${results.notFound}
${results.errors > 0 ? `
Errors ${results.errors}
` : ''}

${results.found > 0 ? 'Click "Review Match" on found components to affirm cut sheets.' : results.notFound > 0 ? 'Components not found can use "I Have Link" to submit manually.' : 'Search complete.'}

`; } } /** * Show modal for manual cut sheet URL submission */ function showManualCutSheetModal(idx) { currentCutSheetComponentIdx = idx; const comp = sharedState.hardware.components[idx]; const modal = document.createElement('div'); modal.id = 'cut-sheet-modal'; modal.className = 'modal-overlay'; modal.innerHTML = ` `; document.body.appendChild(modal); // Focus URL input setTimeout(() => document.getElementById('cut-sheet-url')?.focus(), 100); } /** * Close the manual cut sheet modal */ function closeManualCutSheetModal() { const modal = document.getElementById('cut-sheet-modal'); if (modal) modal.remove(); currentCutSheetComponentIdx = null; } /** * Submit manual cut sheet URL * * TODO [CPS-MIGRATION]: Replace with CPS equivalent after core system is stable * This function uses the old Discovery Engine endpoint /api/cut-sheets/discoveries/manual * Future: Should upload URL content to R2, create catalogue entry, and use CPS affirmation flow * Blocked by: Need CPS core flow working first */ async function submitManualCutSheet() { const url = document.getElementById('cut-sheet-url')?.value?.trim(); const title = document.getElementById('cut-sheet-title')?.value?.trim(); if (!url) { alert('Please enter a URL'); return; } // Basic URL validation try { new URL(url); } catch { alert('Please enter a valid URL'); return; } const comp = sharedState.hardware.components[currentCutSheetComponentIdx]; if (!comp) { alert('Component not found'); closeManualCutSheetModal(); return; } try { const response = await callAPI('/api/cut-sheets/discoveries/manual', { method: 'POST', body: JSON.stringify({ sourceUrl: url, componentId: comp.id || `comp-${sharedState.session.id}-${currentCutSheetComponentIdx}`, manufacturer: comp.manufacturer, model: comp.model, documentTitle: title || null }) }); const result = await response.json(); if (result.id) { comp.cutSheetStatus = 'pending'; comp.cutSheetDiscoveryId = result.id; closeManualCutSheetModal(); saveState(); renderComponents(); if (result.warning) { alert(`Submitted for review.\n\n⚠️ Warning: ${result.warning}`); } else { alert('Cut sheet submitted for review!'); } } else { alert('Submission failed: ' + (result.error || 'Unknown error')); } } catch (error) { console.error('[CUT SHEET] Manual submit error:', error); alert('Failed to submit: ' + error.message); } } /** * Open cut sheet review panel for a component * * TODO [CPS-MIGRATION]: Replace with CPS affirmation queue after core system is stable * This function uses old Discovery Engine endpoint /api/cut-sheets/discoveries/pending * Future: Should use /api/cps/queue to fetch pending CPS affirmations */ async function openCutSheetReview(idx) { const comp = sharedState.hardware.components[idx]; if (!comp) return; // Fetch pending discoveries for this component try { // Note: callAPI already returns parsed JSON const data = await callAPI('/api/cut-sheets/discoveries/pending?limit=20'); const discoveries = data.discoveries || []; if (discoveries.length === 0) { alert('No pending cut sheets to review.'); return; } showCutSheetReviewPanel(discoveries, idx); } catch (error) { console.error('[CUT SHEET] Review fetch error:', error); alert('Failed to load pending cut sheets: ' + error.message); } } /** * Show the cut sheet review panel */ function showCutSheetReviewPanel(discoveries, componentIdx) { // Remove existing panel if any const existing = document.getElementById('cut-sheet-review-panel'); if (existing) existing.remove(); const panel = document.createElement('div'); panel.id = 'cut-sheet-review-panel'; panel.className = 'modal-overlay'; panel.innerHTML = ` `; document.body.appendChild(panel); } /** * Close the cut sheet review panel */ function closeCutSheetReviewPanel() { const panel = document.getElementById('cut-sheet-review-panel'); if (panel) panel.remove(); } /** * Preview cut sheet in new tab */ function previewCutSheet(url) { window.open(url, '_blank'); } /** * Approve a cut sheet discovery * * TODO [CPS-MIGRATION]: Replace with CPS affirmation after core system is stable * This function uses old Discovery Engine endpoint /api/cut-sheets/discoveries/:id/approve * Future: Should use /api/cps/mappings/:id/affirm */ async function approveCutSheet(discoveryId, componentIdx) { if (!confirm('Approve this cut sheet? It will be saved as the verified document for this product.')) { return; } try { const response = await callAPI(`/api/cut-sheets/discoveries/${discoveryId}/approve`, { method: 'POST', body: JSON.stringify({ title: null, // Use extracted title corrections: null }) }); const result = await response.json(); if (result.success) { // Update component status if (componentIdx !== undefined && sharedState.hardware.components[componentIdx]) { sharedState.hardware.components[componentIdx].cutSheetStatus = 'verified'; sharedState.hardware.components[componentIdx].cutSheetDocId = result.documentId; saveState(); renderComponents(); } // Remove from panel const item = document.querySelector(`[data-id="${discoveryId}"]`); if (item) item.remove(); alert('Cut sheet approved and saved!'); // Close panel if no more items const remaining = document.querySelectorAll('.cut-sheet-item'); if (remaining.length === 0) { closeCutSheetReviewPanel(); } } else { alert('Approval failed: ' + (result.error || 'Unknown error')); } } catch (error) { console.error('[CUT SHEET] Approve error:', error); alert('Failed to approve: ' + error.message); } } /** * Reject a cut sheet discovery * * TODO [CPS-MIGRATION]: Replace with CPS rejection after core system is stable * This function uses old Discovery Engine endpoint /api/cut-sheets/discoveries/:id/reject * Future: Should use /api/cps/mappings/:id/reject */ async function rejectCutSheet(discoveryId) { const reason = prompt('Please provide a reason for rejection (required):'); if (!reason || reason.trim().length < 5) { alert('Rejection reason is required (minimum 5 characters)'); return; } try { const response = await callAPI(`/api/cut-sheets/discoveries/${discoveryId}/reject`, { method: 'POST', body: JSON.stringify({ reason: reason.trim() }) }); const result = await response.json(); if (result.success) { // Remove from panel const item = document.querySelector(`[data-id="${discoveryId}"]`); if (item) item.remove(); // Close panel if no more items const remaining = document.querySelectorAll('.cut-sheet-item'); if (remaining.length === 0) { closeCutSheetReviewPanel(); } } else { alert('Rejection failed: ' + (result.error || 'Unknown error')); } } catch (error) { console.error('[CUT SHEET] Reject error:', error); alert('Failed to reject: ' + error.message); } } /** * View a verified cut sheet */ async function viewCutSheet(idx) { const comp = sharedState.hardware.components[idx]; if (!comp || !comp.cutSheetDocId) { alert('No verified cut sheet found for this component'); return; } try { // Fetch PDF binary with auth header const headers = { 'Authorization': `Bearer ${authState.token}` }; const response = await fetch(`${API_BASE_URL}/api/cut-sheets/download/${comp.cutSheetDocId}`, { headers }); if (!response.ok) { const errorData = await response.json().catch(() => ({ error: 'Download failed' })); throw new Error(errorData.error || `HTTP ${response.status}`); } // Create blob URL and open in new tab const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); window.open(blobUrl, '_blank'); // Clean up blob URL after a delay (browser keeps it for the tab) setTimeout(() => URL.revokeObjectURL(blobUrl), 60000); } catch (error) { console.error('[CUT SHEET] View error:', error); alert('Failed to get cut sheet: ' + error.message); } } // ================================================================ // CPS CUT SHEET REVIEW MODAL FUNCTIONS (v2.6.6) // ================================================================ /** * Current CPS mapping being reviewed * @type {{mappingId: string, componentIdx: number, model: string, manufacturer: string, confidence: number, imageUrl: string} | null} */ let currentCpsCutSheetMapping = null; /** * Open CPS review modal for a component that already has a 'found' status * Used when user clicks "Review Match" button after a previous search found a match * @param {number} idx - Component index in sharedState */ function openCpsReviewForComponent(idx) { const comp = sharedState.hardware.components[idx]; if (!comp) { alert('Component not found'); return; } // Check if we have the required CPS data stored if (!comp.cutSheetCatalogueId) { // Re-run the search to get fresh data findCutSheet(idx); return; } // Build the image URL from stored data // CRITICAL: Must use API_BASE_URL for Worker routing const pageNum = comp.cutSheetPageNum || 1; const imageUrl = `${API_BASE_URL}/api/cps/catalogues/${comp.cutSheetCatalogueId}/pages/${pageNum}/render`; // Open the review modal with stored data showCpsCutSheetReview({ mappingId: comp.cutSheetMappingId || `cps-${comp.cutSheetCatalogueId}-${pageNum}`, model: comp.model, manufacturer: comp.manufacturer, confidence: comp.cutSheetConfidence || 0.90, imageUrl: imageUrl, componentIdx: idx, catalogueId: comp.cutSheetCatalogueId, pageNum: pageNum }); } /** * Show the CPS Cut Sheet Review Modal * @param {string} mappingId - CPS mapping ID * @param {string} model - Product model number * @param {string} manufacturer - Manufacturer name * @param {number} confidence - Match confidence (0-1) * @param {string} imageUrl - URL to cut sheet image/PDF * @param {number} componentIdx - Index of the component in sharedState */ function showCpsCutSheetReview(opts) { const { mappingId, model, manufacturer, confidence, imageUrl, componentIdx, catalogueId, pageNum } = opts; console.log('[CPS] Opening cut sheet review modal:', opts); // Store current mapping data (including catalogue info for affirmation) currentCpsCutSheetMapping = { mappingId, componentIdx, model, manufacturer, confidence, imageUrl, catalogueId, pageNum }; // Update modal header info const modelNameEl = document.getElementById('cpsModelName'); const manufacturerEl = document.getElementById('cpsManufacturerBadge'); const confidenceEl = document.getElementById('cpsConfidenceBadge'); if (modelNameEl) modelNameEl.textContent = model || 'Unknown Model'; if (manufacturerEl) manufacturerEl.textContent = manufacturer || 'Unknown Manufacturer'; // Confidence badge with color coding if (confidenceEl) { const confidencePercent = Math.round((confidence || 0) * 100); confidenceEl.textContent = `${confidencePercent}% Match`; // Remove existing classes and add appropriate one confidenceEl.classList.remove('high', 'medium', 'low'); if (confidence >= 0.8) { confidenceEl.classList.add('high'); } else if (confidence >= 0.5) { confidenceEl.classList.add('medium'); } else { confidenceEl.classList.add('low'); } } // Update image container const imageContainer = document.getElementById('cpsCutSheetImageContainer'); if (imageContainer) { if (imageUrl) { // Check if it's a PDF or image // CPS render endpoint returns PDF even though URL doesn't end in .pdf const isPdf = imageUrl.toLowerCase().endsWith('.pdf') || imageUrl.includes('/api/cps/') && imageUrl.includes('/render'); if (isPdf) { // Show loading state imageContainer.innerHTML = `
Loading cut sheet PDF...
`; // Fetch PDF with auth token, then display via blob URL // (Iframes can't access localStorage tokens directly) (async () => { try { const token = authState.token; if (!token) { throw new Error('Not authenticated'); } const response = await fetch(imageUrl, { headers: { 'Authorization': `Bearer ${token}` } }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const pdfBlob = await response.blob(); const blobUrl = URL.createObjectURL(pdfBlob); imageContainer.innerHTML = ` `; console.log('[CPS] PDF blob URL created:', blobUrl.substring(0, 50) + '...'); } catch (err) { console.error('[CPS] Failed to load PDF:', err); imageContainer.innerHTML = `
⚠️ Failed to load cut sheet PDF ${err.message}
`; } })(); } else { imageContainer.innerHTML = ` Cut Sheet for ${model} `; } } else { imageContainer.innerHTML = `
📄 No cut sheet image available The cut sheet URL was not provided or is unavailable.
`; } } // Show modal with animation const modal = document.getElementById('cpsCutSheetModal'); if (modal) { modal.classList.add('visible'); console.log('[CPS] Modal shown'); } // Add Escape key listener document.addEventListener('keydown', handleCpsModalEscapeKey); } /** * Handle image load errors gracefully * @param {HTMLImageElement} imgElement - The image element that failed to load */ function handleCpsCutSheetImageError(imgElement) { console.warn('[CPS] Image failed to load:', imgElement.src); const container = imgElement.parentElement; if (container) { container.innerHTML = `
⚠️ Failed to load cut sheet image Open in new tab
`; } } /** * Close the CPS Cut Sheet Review Modal */ function closeCpsCutSheetReview() { const modal = document.getElementById('cpsCutSheetModal'); if (modal) { modal.classList.remove('visible'); console.log('[CPS] Modal closed'); } // Clear current mapping currentCpsCutSheetMapping = null; // Remove Escape key listener document.removeEventListener('keydown', handleCpsModalEscapeKey); } /** * Handle Escape key to close modal * @param {KeyboardEvent} event */ function handleCpsModalEscapeKey(event) { if (event.key === 'Escape') { closeCpsCutSheetReview(); } } /** * Handle backdrop click to close modal * @param {MouseEvent} event */ function handleCpsModalBackdropClick(event) { // Only close if clicking directly on the backdrop (not the content) if (event.target.id === 'cpsCutSheetModal') { closeCpsCutSheetReview(); } } /** * Affirm the current CPS cut sheet mapping */ async function affirmCpsCutSheet() { if (!currentCpsCutSheetMapping) { console.error('[CPS] No mapping to affirm'); alert('Error: No cut sheet mapping selected'); return; } const { mappingId, componentIdx, model, manufacturer, catalogueId, pageNum } = currentCpsCutSheetMapping; console.log('[CPS] Affirming cut sheet:', { mappingId, componentIdx, catalogueId, pageNum }); try { // callAPI returns parsed JSON, not Response object // TRUST SUBSTRATE: Pass all data needed to create the mapping on first affirmation const result = await callAPI(`/api/cps/mappings/${mappingId}/affirm`, { method: 'PUT', body: JSON.stringify({ catalogue_id: catalogueId, page_num: pageNum, model: model, manufacturer: manufacturer, affirmedBy: authState.user?.email || 'anonymous', affirmedAt: new Date().toISOString() }) }); if (result.error) { throw new Error(result.error); } console.log('[CPS] Affirm successful:', result); // STEP 2: Store to user's personal cache (enables Step 0 cache hit on future searches) // This creates the user-specific PNG cache entry that bypasses CPS search next time try { const userCacheResult = await callAPI('/api/user/cutsheets', { method: 'POST', body: JSON.stringify({ manufacturer: manufacturer, model: model, catalogueId: catalogueId, pageStart: pageNum, pageEnd: pageNum, // Single page for now trade: 'doors' }) }); console.log('[CPS] User cache stored:', userCacheResult); } catch (cacheErr) { // Non-fatal: CPS affirm succeeded, cache store failed console.warn('[CPS] User cache store failed (non-fatal):', cacheErr); } // Update component in sharedState if valid index if (typeof componentIdx === 'number' && sharedState.hardware.components[componentIdx]) { sharedState.hardware.components[componentIdx].cutSheetStatus = 'verified'; sharedState.hardware.components[componentIdx].cutSheetAffirmed = true; sharedState.hardware.components[componentIdx].cutSheetMappingId = mappingId; saveState(); renderComponents(); } // Show success notification alert(`Cut sheet affirmed for ${model || 'component'}\n\nThis cut sheet is now cached for faster lookup next time.`); // Close modal closeCpsCutSheetReview(); } catch (error) { console.error('[CPS] Affirm error:', error); alert('Failed to affirm cut sheet: ' + error.message); } } /** * Reject the current CPS cut sheet mapping */ async function rejectCpsCutSheet() { if (!currentCpsCutSheetMapping) { console.error('[CPS] No mapping to reject'); alert('Error: No cut sheet mapping selected'); return; } const { mappingId, componentIdx, model, manufacturer } = currentCpsCutSheetMapping; // Prompt for rejection reason const reason = prompt( `Reject cut sheet for ${model || 'this component'}?\n\n` + `Please provide a reason for rejection (optional):`, '' ); // User cancelled the prompt if (reason === null) { console.log('[CPS] Rejection cancelled by user'); return; } console.log('[CPS] Rejecting cut sheet:', { mappingId, componentIdx, reason }); try { // callAPI returns parsed JSON, not Response object const result = await callAPI(`/api/cps/mappings/${mappingId}/reject`, { method: 'PUT', body: JSON.stringify({ rejectedBy: authState.user?.email || 'anonymous', rejectedAt: new Date().toISOString(), reason: reason || 'No reason provided' }) }); if (result.error) { throw new Error(result.error); } console.log('[CPS] Reject successful:', result); // Update component in sharedState if valid index if (typeof componentIdx === 'number' && sharedState.hardware.components[componentIdx]) { sharedState.hardware.components[componentIdx].cutSheetStatus = 'rejected'; sharedState.hardware.components[componentIdx].cutSheetAffirmed = false; sharedState.hardware.components[componentIdx].cutSheetRejectedReason = reason; saveState(); renderComponents(); } // Show success notification alert(`Cut sheet rejected for ${model || 'component'}`); // Close modal closeCpsCutSheetReview(); } catch (error) { console.error('[CPS] Reject error:', error); alert('Failed to reject cut sheet: ' + error.message); } } // ============================================ // CPS PORTAL - Gate 1+2: JavaScript Functions // Agent: ALPHA | Status: COMPLETE // Deliverables: [x] toggleCpsPortal, [x] searchCpsForComponent, // [x] renderCpsResults, [x] Keyboard navigation // Issues: None // ============================================ /** * CPS Portal State * Tracks the currently open portal and selected results */ const cpsPortalState = { activeComponentIdx: null, searchResults: [], selectedResults: [], focusedIndex: -1, previewResult: null, isSearching: false, // v2.8.11: Result organization sortPreference: 'confidence', // 'confidence' | 'name' | 'datePublished' | 'dateUploaded' | 'previouslyAffirmed' collapsedGroups: {}, // { [catalogueId]: true } for collapsed groups groupedResults: {} // { [catalogueId]: { name, results[] } } }; /** * v2.8.11: Group CPS results by catalogue ID * @param {Array} results - Flat array of search results * @returns {Object} Grouped results { catalogueId: { name, results[] } } */ function groupResultsByCatalogue(results) { var grouped = {}; results.forEach(function(result) { var catId = result.catalogue_id || 'unknown'; if (!grouped[catId]) { grouped[catId] = { name: result.catalogue_name || result.manufacturer || catId, catalogueId: catId, results: [] }; } grouped[catId].results.push(result); }); return grouped; } /** * v2.8.11: Sort grouped results based on preference * @param {Object} grouped - Grouped results from groupResultsByCatalogue * @param {string} sortMode - Sort mode: 'name' | 'datePublished' | 'dateUploaded' | 'previouslyAffirmed' * @returns {Array} Sorted array of group objects */ function sortGroupedResults(grouped, sortMode) { var groups = Object.values(grouped); // v2.8.14: Helper to get max score in a group function getMaxScore(group) { return Math.max.apply(null, group.results.map(function(r) { return r.score || 0; })); } // Sort groups groups.sort(function(a, b) { if (sortMode === 'confidence') { // v2.8.14: Sort groups by their highest-scoring result (descending) return getMaxScore(b) - getMaxScore(a); } else if (sortMode === 'name') { return (a.name || '').localeCompare(b.name || ''); } else if (sortMode === 'previouslyAffirmed') { // Groups with affirmed pages first var aHasAffirmed = a.results.some(function(r) { return isPagePreviouslyAffirmed(r); }); var bHasAffirmed = b.results.some(function(r) { return isPagePreviouslyAffirmed(r); }); if (aHasAffirmed && !bHasAffirmed) return -1; if (!aHasAffirmed && bHasAffirmed) return 1; return (a.name || '').localeCompare(b.name || ''); } // Default: name sort for datePublished/dateUploaded (data may not exist) return (a.name || '').localeCompare(b.name || ''); }); // Sort results within each group groups.forEach(function(group) { group.results.sort(function(a, b) { if (sortMode === 'confidence') { // v2.8.14: Highest score first within group return (b.score || 0) - (a.score || 0); } else if (sortMode === 'previouslyAffirmed') { var aAffirmed = isPagePreviouslyAffirmed(a); var bAffirmed = isPagePreviouslyAffirmed(b); if (aAffirmed && !bAffirmed) return -1; if (!aAffirmed && bAffirmed) return 1; } // Secondary sort by page number return (a.page_num || 0) - (b.page_num || 0); }); }); return groups; } /** * v2.8.11: Check if a page was previously affirmed by user * @param {Object} result - CPS search result * @returns {boolean} True if previously affirmed */ function isPagePreviouslyAffirmed(result) { // Check against affirmed cutsheets in shared state if (!sharedState.affirmedCutsheets) return false; return sharedState.affirmedCutsheets.some(function(affirmed) { return affirmed.catalogueId === result.catalogue_id && affirmed.pageNum === result.page_num; }); } /** * v2.8.11: Toggle collapse state of a catalogue group * @param {string} catalogueId - Catalogue ID * @param {number} componentIdx - Component index for re-render */ function toggleCpsGroupCollapse(catalogueId, componentIdx) { cpsPortalState.collapsedGroups[catalogueId] = !cpsPortalState.collapsedGroups[catalogueId]; // Re-render results with new collapse state renderCpsResults(componentIdx, cpsPortalState.searchResults); } /** * v2.8.11: Handle sort preference change * @param {string} sortMode - New sort mode * @param {number} componentIdx - Component index for re-render */ function handleCpsSortChange(sortMode, componentIdx) { cpsPortalState.sortPreference = sortMode; renderCpsResults(componentIdx, cpsPortalState.searchResults); console.log('[CPS Sort] Changed to:', sortMode); } /** * Toggle the CPS Portal open/closed for a component * @param {number} componentIdx - Index of the component in sharedState */ function toggleCpsPortal(componentIdx) { const portalId = `cps-portal-${componentIdx}`; const portal = document.getElementById(portalId); if (!portal) { console.error('[CPS Portal] Portal element not found:', portalId); return; } const isCurrentlyExpanded = portal.classList.contains('expanded'); // Close all other portals first document.querySelectorAll('.cps-portal.expanded').forEach(p => { if (p.id !== portalId) { p.classList.remove('expanded'); } }); if (isCurrentlyExpanded) { // Collapse this portal portal.classList.remove('expanded'); cpsPortalState.activeComponentIdx = null; cpsPortalState.focusedIndex = -1; // Remove keyboard listener document.removeEventListener('keydown', handleCpsPortalKeyboard); // Restore schedule viewer if we were in cut sheet mode if (sharedState.viewerMode === 'cutsheet') { restoreScheduleViewer(); } console.log('[CPS Portal] Collapsed portal for component', componentIdx); } else { // Expand this portal portal.classList.add('expanded'); cpsPortalState.activeComponentIdx = componentIdx; cpsPortalState.selectedResults = []; cpsPortalState.focusedIndex = -1; // Initialize Gate 3+4 extended state for this component initCpsExtendedState(componentIdx); // Add keyboard listener document.addEventListener('keydown', handleCpsPortalKeyboard); // Gate 5 (CHARLIE): Restore draft selections on portal expand restoreCpsDraftOnExpand(componentIdx); // Auto-search on expand const comp = sharedState.hardware.components[componentIdx]; if (comp && (comp.model || comp.manufacturer)) { searchCpsForComponent(componentIdx); } else { // Show empty state if no search data renderCpsEmptyState(componentIdx, 'No model or manufacturer data to search'); } console.log('[CPS Portal] Expanded portal for component', componentIdx); } } /** * Gate 5 (CHARLIE): Restore draft selections when portal expands * @param {number} componentIdx - Component index */ async function restoreCpsDraftOnExpand(componentIdx) { try { const draft = await CpsDraftService.loadDraft(componentIdx); if (draft && draft.selectedPages && draft.selectedPages.length > 0) { console.log(`[CPS Draft] Restoring ${draft.selectedPages.length} selections for component ${componentIdx}`); // Restore to extended state cpsPortalExtendedState.componentSelections[componentIdx] = draft.selectedPages; // Sync to main portal state syncSelectionState(componentIdx); // Show toast notification showCpsToast(`Restored ${draft.selectedPages.length} previous selections`, 'info'); // Re-render pills after search results load (delay to ensure DOM ready) setTimeout(() => { renderSelectionPills(componentIdx); // Update visual state of any matching result items draft.selectedPages.forEach(sel => { updateResultItemSelection(componentIdx, sel.catalogueId, sel.pageNum); }); }, 500); } } catch (err) { console.warn('[CPS Draft] Failed to restore draft:', err); } } /** * Search CPS for a component's cut sheet * @param {number} componentIdx - Index of the component * @param {string} [customQuery] - Optional custom search query */ async function searchCpsForComponent(componentIdx, customQuery = null) { const comp = sharedState.hardware.components[componentIdx]; if (!comp) { console.error('[CPS Portal] Component not found:', componentIdx); return; } // Build search parameters from component context const model = comp.model || ''; const manufacturer = comp.manufacturer || ''; const description = comp.description || ''; const type = comp.type || ''; const notes = comp.notes || ''; // v2.8.13: Use custom query for fallback to GET endpoint const hasComponentContext = model.trim().length >= 2; if (!hasComponentContext && !customQuery) { renderCpsEmptyState(componentIdx, 'No model data to search (min 2 characters)'); return; } // Update state cpsPortalState.isSearching = true; cpsPortalState.searchResults = []; cpsPortalState.focusedIndex = -1; // Show loading state renderCpsLoadingState(componentIdx); console.log('[CPS Portal] Searching with component context:', { model, manufacturer }); try { let response; if (hasComponentContext) { // v2.8.13: Use POST /api/cps/search-component for weighted scoring // Returns results with score field for confidence badges response = await callAPI('/api/cps/search-component', { method: 'POST', body: JSON.stringify({ model: model.trim(), manufacturer: manufacturer.trim(), description: description.trim(), type: type.trim(), notes: notes.trim(), limit: 30 }) }); } else { // Fallback: custom query uses GET endpoint (no scoring) response = await callAPI(`/api/cps/search?q=${encodeURIComponent(customQuery)}`); } if (response.error) { throw new Error(response.error); } // Store results - POST endpoint returns score field let results = response.results || response.pages || []; // v2.8.13: Normalize scores to 0-1 range for confidence badges // POST endpoint returns integer scores (sum of weights: model=3, mfr=2, content=1) // Frontend expects 0-1 for display as percentage if (hasComponentContext && results.length > 0) { const maxScore = Math.max(...results.map(r => r.score || 0), 1); results = results.map(r => ({ ...r, // Normalize to 0-1, with minimum 0.1 for any match score: r.score ? Math.max(0.1, r.score / maxScore) : 0 })); console.log('[CPS Portal] Normalized scores, max was:', maxScore); } cpsPortalState.searchResults = results; cpsPortalState.isSearching = false; console.log('[CPS Portal] Search results:', cpsPortalState.searchResults.length, hasComponentContext ? '(with scores)' : '(no scores)'); // Render results if (cpsPortalState.searchResults.length > 0) { renderCpsResults(componentIdx, cpsPortalState.searchResults); } else { const searchDesc = hasComponentContext ? model : customQuery; renderCpsEmptyState(componentIdx, `No cut sheets found for "${searchDesc}"`); } } catch (error) { console.error('[CPS Portal] Search error:', error); cpsPortalState.isSearching = false; renderCpsEmptyState(componentIdx, `Search failed: ${error.message}`); } } /** * Render CPS search results in the portal * @param {number} componentIdx - Component index * @param {Array} results - Search results array */ function renderCpsResults(componentIdx, results) { var portalId = 'cps-portal-' + componentIdx; var portal = document.getElementById(portalId); if (!portal) return; // v2.8.11: Group and sort results var grouped = groupResultsByCatalogue(results); var sortedGroups = sortGroupedResults(grouped, cpsPortalState.sortPreference); cpsPortalState.groupedResults = grouped; // Build grouped results HTML var resultsHtml = sortedGroups.map(function(group) { var isCollapsed = cpsPortalState.collapsedGroups[group.catalogueId]; var groupResultsHtml = group.results.map(function(result) { var idx = results.indexOf(result); var isSelected = cpsPortalState.selectedResults.some(function(r) { return r.catalogueId === result.catalogue_id && r.pageNum === result.page_num; }); var isFocused = idx === cpsPortalState.focusedIndex; var isPreviouslyAffirmed = isPagePreviouslyAffirmed(result); var confidence = result.score || result.confidence || 0; var confidenceLevel = getConfidenceLevel(confidence); var classes = 'cps-result-item'; if (isSelected) classes += ' selected'; if (isFocused) classes += ' focused'; if (isPreviouslyAffirmed) classes += ' previously-affirmed'; return '
' + '' + '
' + '
' + (result.model || result.title || 'Page ' + result.page_num) + (isPreviouslyAffirmed ? '✓ Affirmed' : '') + '
' + '
' + 'Page ' + result.page_num + '' + '' + Math.round(confidence * 100) + '%' + '
' + '
' + '
'; }).join(''); return '
' + '
' + '
' + '' + group.name + '' + '(' + group.results.length + ' pages)' + '
' + '' + '
' + '
' + groupResultsHtml + '
' + '
'; }).join(''); // Sort controls HTML var sortControlsHtml = '
' + 'Sort by:' + '' + '
'; // Selection bar (shows when items selected) var selectionBarHtml = ''; if (cpsPortalState.selectedResults.length > 0) { var pillsHtml = cpsPortalState.selectedResults.map(function(r, i) { return 'Page ' + r.pageNum + '' + ''; }).join(''); selectionBarHtml = '
' + '' + cpsPortalState.selectedResults.length + ' selected:' + '
' + pillsHtml + '
' + '
'; } var comp = sharedState.hardware.components[componentIdx]; var searchQuery = ((comp.model || '') + ' ' + (comp.manufacturer || '')).trim(); portal.innerHTML = '
' + '
' + '
' + '' + '
' + sortControlsHtml + '
' + resultsHtml + '
' + selectionBarHtml + '
' + '
' + '
' + '
' + '' + '
' + '
' + '' + '' + '
' + '
'; console.log('[CPS Portal] Results rendered:', results.length, 'in', sortedGroups.length, 'groups'); } /** * Render loading state in portal * @param {number} componentIdx - Component index */ function renderCpsLoadingState(componentIdx) { const portalId = `cps-portal-${componentIdx}`; const portal = document.getElementById(portalId); if (!portal) return; portal.innerHTML = `
`; } /** * Render empty state in portal * @param {number} componentIdx - Component index * @param {string} message - Message to display */ function renderCpsEmptyState(componentIdx, message) { const portalId = `cps-portal-${componentIdx}`; const portal = document.getElementById(portalId); if (!portal) return; const comp = sharedState.hardware.components[componentIdx]; const searchQuery = `${comp?.model || ''} ${comp?.manufacturer || ''}`.trim(); portal.innerHTML = `
🔍
${message}
Try adjusting your search terms
`; } /** * Get confidence level string from score * @param {number} score - Confidence score (0-1) * @returns {string} 'high', 'medium', or 'low' */ function getConfidenceLevel(score) { const percentage = score * 100; if (percentage >= 90) return 'high'; if (percentage >= 60) return 'medium'; return 'low'; } /** * Handle click on a CPS result item * Enhanced by BRAVO (Gate 3+4) to use loadCpsPreview and togglePageSelection * @param {number} componentIdx - Component index * @param {number} resultIdx - Result index */ function handleCpsResultClick(componentIdx, resultIdx) { const result = cpsPortalState.searchResults[resultIdx]; if (!result) return; // Use Gate 3+4 enhanced selection (30-page limit, per-component tracking) togglePageSelection(componentIdx, result.catalogue_id, result.page_num); // Set active indicator on clicked result (v2.8.10: click-to-render separation) const portalSelector = '#cps-portal-' + componentIdx + ' .cps-result-item'; const resultItems = document.querySelectorAll(portalSelector); resultItems.forEach(function(item, idx) { if (idx === resultIdx) { item.classList.add('active'); } else { item.classList.remove('active'); } }); // Use Gate 3+4 enhanced preview (skeleton loader, error handling, nav) loadCpsPreview(componentIdx, result.catalogue_id, result.page_num); } /** * Toggle selection of a CPS result * Enhanced by BRAVO (Gate 3+4) to use togglePageSelection with 30-page limit * @param {number} componentIdx - Component index * @param {number} resultIdx - Result index */ function toggleCpsResultSelection(componentIdx, resultIdx) { const result = cpsPortalState.searchResults[resultIdx]; if (!result) return; // Delegate to Gate 3+4 enhanced selection with 30-page limit togglePageSelection(componentIdx, result.catalogue_id, result.page_num); } /** * Remove a selection from the pills bar * Enhanced by BRAVO (Gate 3+4) to use removeSelection * @param {number} componentIdx - Component index * @param {number} selectionIdx - Index in selectedResults array */ function removeCpsSelection(componentIdx, selectionIdx) { // Get selection info from main state const selection = cpsPortalState.selectedResults[selectionIdx]; if (selection) { // Delegate to Gate 3+4 removeSelection removeSelection(componentIdx, selection.catalogueId, selection.pageNum); } } /** * Highlight a CPS result on hover — shows metadata without rendering * v2.8.10: Separated from click-to-render per CH-2026-0114-CPS-001 * @param {number} componentIdx - Component index * @param {number} resultIdx - Result index */ function highlightCpsResult(componentIdx, resultIdx) { const result = cpsPortalState.searchResults[resultIdx]; if (!result) return; // Update preview result for metadata display cpsPortalState.previewResult = result; // Add hover highlight to this result (CSS handles visual) var portalSelector = '#cps-portal-' + componentIdx + ' .cps-result-item'; var resultItems = document.querySelectorAll(portalSelector); resultItems.forEach(function(item, idx) { if (idx === resultIdx) { item.classList.add('hovered'); } else { item.classList.remove('hovered'); } }); // Update MATCH DETAILS panel with hovered result info (if panel exists) updateMatchDetailsPanel(result); console.log('[CPS Hover] Highlighting result:', result.model || result.title || 'Page ' + result.page_num); } /** * Remove hover highlight when mouse leaves a CPS result * @param {number} componentIdx - Component index * @param {number} resultIdx - Result index */ function unhighlightCpsResult(componentIdx, resultIdx) { var selector = '#cps-portal-' + componentIdx + ' .cps-result-item[data-result-idx="' + resultIdx + '"]'; var resultItem = document.querySelector(selector); if (resultItem) { resultItem.classList.remove('hovered'); } } /** * Update MATCH DETAILS panel with result info (hover metadata display) * v2.8.12: STUBBED — panel removed per QF-2026-0115-CPS-001 * Metadata display will be redesigned in WO-001 * @param {Object} result - CPS search result */ function updateMatchDetailsPanel(result) { // STUBBED: Panel removed — no-op } /** * Handle keyboard navigation in CPS Portal * @param {KeyboardEvent} event */ function handleCpsPortalKeyboard(event) { if (cpsPortalState.activeComponentIdx === null) return; const results = cpsPortalState.searchResults; if (!results || results.length === 0) return; switch (event.key) { case 'ArrowDown': event.preventDefault(); cpsPortalState.focusedIndex = Math.min( cpsPortalState.focusedIndex + 1, results.length - 1 ); updateCpsFocusedResult(); break; case 'ArrowUp': event.preventDefault(); cpsPortalState.focusedIndex = Math.max( cpsPortalState.focusedIndex - 1, 0 ); updateCpsFocusedResult(); break; case 'Enter': case ' ': event.preventDefault(); if (cpsPortalState.focusedIndex >= 0) { toggleCpsResultSelection( cpsPortalState.activeComponentIdx, cpsPortalState.focusedIndex ); } break; case 'Escape': event.preventDefault(); toggleCpsPortal(cpsPortalState.activeComponentIdx); break; } } /** * Update focused result visual state and preview */ function updateCpsFocusedResult() { const componentIdx = cpsPortalState.activeComponentIdx; if (componentIdx === null) return; // Update focused class const resultItems = document.querySelectorAll(`#cps-portal-${componentIdx} .cps-result-item`); resultItems.forEach((item, idx) => { item.classList.toggle('focused', idx === cpsPortalState.focusedIndex); }); // Scroll focused item into view const focusedItem = resultItems[cpsPortalState.focusedIndex]; if (focusedItem) { focusedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } // Update preview if (cpsPortalState.focusedIndex >= 0) { previewCpsResult(componentIdx, cpsPortalState.focusedIndex); } } /** * Affirm selected cut sheets from the portal * Gate 6 (CHARLIE): Updated to use batch affirm endpoint * @param {number} componentIdx - Component index */ async function affirmCpsPortalSelection(componentIdx) { if (cpsPortalState.selectedResults.length === 0) { alert('Please select at least one cut sheet page to affirm.'); return; } const comp = sharedState.hardware.components[componentIdx]; if (!comp) { console.error('[CPS Portal] Component not found:', componentIdx); return; } const selections = cpsPortalState.selectedResults; const componentId = CpsDraftService.getComponentId(componentIdx); console.log('[CPS Portal] Affirming selection:', { componentIdx, componentId, model: comp.model, manufacturer: comp.manufacturer, selectedPages: selections.length }); try { // Gate 6 (CHARLIE): Call batch affirm endpoint // Full component context from schedule table headers travels with the affirmation const result = await callAPI('/api/cps/mappings/affirm-batch', { method: 'POST', body: JSON.stringify({ component_id: componentId, component_index: componentIdx, group_number: sharedState.hardware.groupNumber, session_id: sharedState.session.id, pages: selections.map(s => ({ catalogueId: s.catalogueId, pageNum: s.pageNum, model: s.model || comp.model, manufacturer: s.manufacturer || comp.manufacturer, title: s.title })), manufacturer: comp.manufacturer, model: comp.model, component_context: { type: comp.type || '', manufacturer: comp.manufacturer || '', model: comp.model || '', finish: comp.finish || '', quantity: comp.quantity || '1', uom: comp.uom || 'EA', description: comp.description || '', notes: comp.notes || '', compliance: comp.compliance || '' } }) }); if (!result.success) { throw new Error(result.error || 'Batch affirm failed'); } console.log('[CPS Portal] Batch affirm success:', result); // Clear draft (already done server-side, but clear client state too) await CpsDraftService.deleteDraft(componentIdx); // Clear selections state cpsPortalExtendedState.componentSelections[componentIdx] = []; cpsPortalState.selectedResults = []; // Collapse portal toggleCpsPortal(componentIdx); // Mark component cut sheet complete in UI markComponentCutSheetComplete(componentIdx, result.affirmed); // Show success toast showCpsToast(`${result.affirmed} cut sheet page${result.affirmed > 1 ? 's' : ''} affirmed`, 'success'); } catch (error) { console.error('[CPS Portal] Affirm error:', error); showCpsToast('Failed to affirm selections: ' + error.message, 'error'); } } /** * Mark a component as having its cut sheet complete * @param {number} componentIdx - Component index * @param {number} pageCount - Number of pages affirmed */ function markComponentCutSheetComplete(componentIdx, pageCount = 1) { const component = document.querySelector(`[data-component-idx="${componentIdx}"]`); if (component) { component.classList.add('cut-sheet-complete'); const badge = component.querySelector('.cps-status-badge'); if (badge) { badge.textContent = `${pageCount} page${pageCount > 1 ? 's' : ''} affirmed`; badge.className = 'cps-status-badge affirmed'; } } console.log(`[CPS Portal] Component ${componentIdx} marked complete with ${pageCount} pages`); } /** * Generate CPS Portal HTML for a component * Used by renderComponents() to inject portal container * @param {number} componentIdx - Component index * @returns {string} HTML string for portal container */ function generateCpsPortalHtml(componentIdx) { return `
`; } // ============================================ // END CPS PORTAL JavaScript Functions // ============================================ /* ============================================ CPS PORTAL - Gate 5: D1 Draft State Service Agent: CHARLIE | Status: COMPLETE Deliverables: [x] CpsDraftService, [x] localStorage fallback, [x] debounced saves Issues: None ============================================ */ /** * CPS Draft Service - Persists selections to D1 for page refresh survival * Falls back to localStorage if D1 unavailable */ const CpsDraftService = { debounceTimers: new Map(), saveInProgress: new Map(), /** * Generate a component ID from component data * @param {number} componentIdx - Component index * @returns {string} Component ID for draft storage */ getComponentId(componentIdx) { const comp = sharedState.hardware?.components?.[componentIdx]; if (!comp) return `component_${componentIdx}`; // Create stable ID from manufacturer + model const mfr = (comp.manufacturer || '').toLowerCase().replace(/[^a-z0-9]/g, ''); const model = (comp.model || '').toLowerCase().replace(/[^a-z0-9]/g, ''); return `${mfr}_${model}` || `component_${componentIdx}`; }, /** * Save draft selection to D1 with debouncing * @param {number} componentIdx - Component index * @param {Array} selectedPages - Array of selected page objects * @param {Object} previewState - Current preview state * @param {number} debounceMs - Debounce delay (default 500ms) */ saveDraft(componentIdx, selectedPages, previewState, debounceMs = 500) { const componentId = this.getComponentId(componentIdx); // Clear existing debounce timer if (this.debounceTimers.has(componentId)) { clearTimeout(this.debounceTimers.get(componentId)); } // Set new debounce timer this.debounceTimers.set(componentId, setTimeout(async () => { this.debounceTimers.delete(componentId); // Skip if already saving if (this.saveInProgress.get(componentId)) { console.log('[CPS Draft] Save already in progress, skipping'); return; } this.saveInProgress.set(componentId, true); try { await callAPI('/api/cps/drafts', { method: 'PUT', body: JSON.stringify({ component_id: componentId, selected_pages: selectedPages, preview_state: previewState }) }); console.log(`[CPS Draft] Saved ${selectedPages.length} pages for ${componentId}`); // Clear localStorage backup on successful D1 save localStorage.removeItem(`cps_draft_${componentId}`); } catch (err) { console.warn('[CPS Draft] D1 save failed, using localStorage fallback:', err); // Fallback to localStorage try { localStorage.setItem(`cps_draft_${componentId}`, JSON.stringify({ selectedPages, previewState, timestamp: Date.now() })); } catch (localErr) { console.error('[CPS Draft] localStorage fallback failed:', localErr); } } finally { this.saveInProgress.set(componentId, false); } }, debounceMs)); }, /** * Load draft selection from D1 or localStorage * @param {number} componentIdx - Component index * @returns {Promise<{selectedPages: Array, previewState: Object}|null>} */ async loadDraft(componentIdx) { const componentId = this.getComponentId(componentIdx); try { const response = await callAPI(`/api/cps/drafts/${encodeURIComponent(componentId)}`); if (response.found && response.selected_pages) { const selectedPages = typeof response.selected_pages === 'string' ? JSON.parse(response.selected_pages) : response.selected_pages; const previewState = response.preview_state ? (typeof response.preview_state === 'string' ? JSON.parse(response.preview_state) : response.preview_state) : null; console.log(`[CPS Draft] Loaded ${selectedPages.length} pages from D1 for ${componentId}`); return { selectedPages, previewState }; } } catch (err) { console.warn('[CPS Draft] D1 load failed, checking localStorage:', err); } // Fallback to localStorage try { const localData = localStorage.getItem(`cps_draft_${componentId}`); if (localData) { const parsed = JSON.parse(localData); // Check if not expired (7 days) if (parsed.timestamp && Date.now() - parsed.timestamp < 7 * 24 * 60 * 60 * 1000) { console.log(`[CPS Draft] Loaded ${parsed.selectedPages?.length || 0} pages from localStorage for ${componentId}`); return { selectedPages: parsed.selectedPages || [], previewState: parsed.previewState }; } else { // Expired - clean up localStorage.removeItem(`cps_draft_${componentId}`); } } } catch (localErr) { console.error('[CPS Draft] localStorage load failed:', localErr); } return null; }, /** * Delete draft for a component * @param {number} componentIdx - Component index */ async deleteDraft(componentIdx) { const componentId = this.getComponentId(componentIdx); // Cancel any pending saves if (this.debounceTimers.has(componentId)) { clearTimeout(this.debounceTimers.get(componentId)); this.debounceTimers.delete(componentId); } try { await callAPI(`/api/cps/drafts/${encodeURIComponent(componentId)}`, { method: 'DELETE' }); console.log(`[CPS Draft] Deleted draft for ${componentId}`); } catch (err) { console.warn('[CPS Draft] D1 delete failed:', err); } // Always clean localStorage too localStorage.removeItem(`cps_draft_${componentId}`); }, /** * Get current selections for a component * @param {number} componentIdx - Component index * @returns {Array} Current selections */ getCurrentSelections(componentIdx) { return cpsPortalExtendedState.componentSelections[componentIdx] || []; }, /** * Get current preview state for a component * @param {number} componentIdx - Component index * @returns {Object} Current preview state */ getCurrentPreviewState(componentIdx) { return { focusedIndex: cpsPortalState.focusedIndex, previewResultIdx: cpsPortalExtendedState.previewResultIdx }; } }; /* ============================================ CPS PORTAL - Gate 3+4: PNG Preview + Multi-Select Agent: BRAVO | Status: COMPLETE Deliverables: [x] loadCpsPreview, [x] Page nav, [x] togglePageSelection, [x] Pills Issues: None ============================================ */ /** * CPS Portal Extended State for Gate 3+4 * Extends cpsPortalState with per-component selections and preview state */ const cpsPortalExtendedState = { // Per-component selections: { [componentIdx]: [{catalogueId, pageNum, title}] } componentSelections: {}, // Current preview state previewLoading: false, previewError: null, // Maximum selections per component maxSelections: 30, // Current preview result index for navigation previewResultIdx: -1 }; /** * Load a PNG preview from CPS extraction API * @param {number} componentIdx - Component index * @param {string} catalogueId - Catalogue ID * @param {number} pageNum - Page number * @param {number} dpi - DPI for rendering (default 150) */ async function loadCpsPreview(componentIdx, catalogueId, pageNum, dpi = 150) { // Update navigation state const totalResults = cpsPortalState.searchResults.length; const currentIdx = cpsPortalState.searchResults.findIndex( r => r.catalogue_id === catalogueId && r.page_num === pageNum ); cpsPortalExtendedState.previewResultIdx = currentIdx; // Display in MAIN viewer (the big left pane) for full screen real estate // Nested preview pane removed — main viewer is the sole render target await displayCutSheetInMainViewer(componentIdx, catalogueId, pageNum); console.log('[CPS Preview] Displayed in main viewer:', { catalogueId, pageNum }); } /** * Display cut sheet in the MAIN document viewer (left pane) * This swaps the schedule view for the cut sheet view, giving full screen real estate * @param {number} componentIdx - Component index for context * @param {string} catalogueId - Catalogue ID * @param {number} pageNum - Page number */ async function displayCutSheetInMainViewer(componentIdx, catalogueId, pageNum) { console.log('[CPS Main Viewer] Switching to cut sheet view:', { componentIdx, catalogueId, pageNum }); // Save current schedule page for restoration sharedState.cutsheetViewer.savedSchedulePage = sharedState.session.currentPage; sharedState.cutsheetViewer.catalogueId = catalogueId; sharedState.cutsheetViewer.pageNum = pageNum; sharedState.cutsheetViewer.componentIdx = componentIdx; sharedState.viewerMode = 'cutsheet'; // Get the main PDF viewer container const mainViewer = document.getElementById('pdf-viewer-sbs'); const mainCanvas = document.getElementById('pdf-canvas-sbs'); if (!mainViewer || !mainCanvas) { console.warn('[CPS Main Viewer] Main viewer elements not found'); return; } // Show loading state in main viewer mainViewer.style.position = 'relative'; const loadingOverlay = document.createElement('div'); loadingOverlay.id = 'cutsheet-loading-overlay'; loadingOverlay.innerHTML = `
📄
Loading cut sheet...
`; loadingOverlay.style.cssText = 'position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(10, 15, 26, 0.9); z-index: 100;'; mainViewer.appendChild(loadingOverlay); try { // Fetch PDF using render endpoint // CRITICAL: Must use API_BASE_URL, not relative URL (Pages serves HTML for relative paths) const renderUrl = `${API_BASE_URL}/api/cps/catalogues/${catalogueId}/pages/${pageNum}/render`; const token = authState.token; if (!token) { throw new Error('Not authenticated'); } console.log('[CPS Main Viewer] Fetching:', renderUrl); const response = await fetch(renderUrl, { headers: { 'Authorization': `Bearer ${token}` } }); const contentType = response.headers.get('content-type'); console.log('[CPS Main Viewer] Response:', response.status, contentType); if (!response.ok) { const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || `HTTP ${response.status}`); } // Render PDF to the MAIN canvas using PDF.js // IMPORTANT: Pass ArrayBuffer directly to PDF.js, NOT a blob URL // Blob URLs cause "Unexpected server response (0)" errors const pdfBlob = await response.blob(); console.log('[CPS Main Viewer] Blob size:', pdfBlob.size, 'bytes, type:', pdfBlob.type); if (pdfBlob.size === 0) { throw new Error('Empty PDF response from server - catalogue may not be uploaded'); } const arrayBuffer = await pdfBlob.arrayBuffer(); // Verify PDF header (should start with %PDF-) const header = new Uint8Array(arrayBuffer.slice(0, 8)); const headerStr = String.fromCharCode(...header); console.log('[CPS Main Viewer] PDF header:', headerStr); if (!headerStr.startsWith('%PDF-')) { throw new Error(`Invalid PDF: expected %PDF- header, got: ${headerStr}`); } const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; const page = await pdf.getPage(1); // Calculate scale to fit main viewer (larger than the embedded preview) const viewerWidth = mainViewer.clientWidth - 40; // Account for padding const viewport = page.getViewport({ scale: 1 }); const scale = Math.min(viewerWidth / viewport.width, 2.5); const scaledViewport = page.getViewport({ scale }); // Render to main canvas mainCanvas.width = scaledViewport.width; mainCanvas.height = scaledViewport.height; const ctx = mainCanvas.getContext('2d'); // Cancel any in-progress CPS render to prevent canvas conflict if (window.currentCpsRenderTask) { try { window.currentCpsRenderTask.cancel(); } catch (e) { /* already complete */ } window.currentCpsRenderTask = null; } // Start new render and track it window.currentCpsRenderTask = page.render({ canvasContext: ctx, viewport: scaledViewport }); await window.currentCpsRenderTask.promise; window.currentCpsRenderTask = null; // Remove loading overlay loadingOverlay.remove(); // v2.8.15: Reset zoom to 100% for cutsheet view // PDF is already rendered at fit-to-width scale, so CSS zoom should be 1:1 sharedState.zoom = 100; updateZoom(); console.log('[CPS Main Viewer] Reset zoom to 100% (PDF already fitted)'); // Add cut sheet mode indicator and back button addCutSheetModeControls(mainViewer, componentIdx); // Add CPS match details panel (scoring info) addCpsMatchDetailsPanel(mainViewer, componentIdx, catalogueId, pageNum); // Update page navigation to show cut sheet info const pageInput = document.getElementById('page-input'); if (pageInput) { pageInput.value = `CS-${pageNum}`; pageInput.style.background = '#4c1d95'; } console.log('[CPS Main Viewer] Cut sheet displayed successfully'); } catch (error) { // RenderingCancelledException is expected when user clicks new result before render completes if (error.name === 'RenderingCancelledException' || error.message?.includes('Rendering cancelled')) { console.log('[CPS Main Viewer] Render cancelled (new selection) - this is normal'); return; } console.error('[CPS Main Viewer] Failed to load cut sheet:', error); loadingOverlay.innerHTML = `
⚠️
${error.message}
`; } } /** * Add cut sheet mode indicator and navigation controls to main viewer * @param {HTMLElement} mainViewer - Main viewer container * @param {number} componentIdx - Component index */ function addCutSheetModeControls(mainViewer, componentIdx) { // Remove existing controls if any const existingControls = document.getElementById('cutsheet-mode-controls'); if (existingControls) existingControls.remove(); const controls = document.createElement('div'); controls.id = 'cutsheet-mode-controls'; controls.innerHTML = `
📋 CUT SHEET VIEW
`; controls.style.cssText = 'position: absolute; top: 8px; left: 8px; z-index: 150; color: white;'; mainViewer.appendChild(controls); } /** * Add CPS match details panel showing scoring info in the main viewer * v2.8.12: STUBBED — panel removed per QF-2026-0115-CPS-001 * Metadata display will be redesigned in WO-001 * @param {HTMLElement} mainViewer - Main viewer container * @param {number} componentIdx - Component index * @param {string} catalogueId - Current catalogue ID * @param {number} pageNum - Current page number */ function addCpsMatchDetailsPanel(mainViewer, componentIdx, catalogueId, pageNum) { // STUBBED: Panel removed — no-op // Remove any existing panel that may be left over var existingPanel = document.getElementById('cps-match-details-panel'); if (existingPanel) existingPanel.remove(); } /** * Determine match type based on result and query */ function getMatchType(result, query) { if (!result || !query) return 'Unknown'; const model = (result.model || '').toLowerCase(); const title = (result.title || '').toLowerCase(); const queryLower = query.toLowerCase(); const queryParts = queryLower.split(/\s+/).filter(p => p.length > 2); // Check for exact model match if (model && queryParts.some(p => model.includes(p))) { return 'Model Match'; } // Check for part number in content if (result.content && queryParts.some(p => result.content.toLowerCase().includes(p))) { return 'Content Match'; } // Check for manufacturer match if (result.manufacturer && queryLower.includes(result.manufacturer.toLowerCase())) { return 'Manufacturer Match'; } // Default return 'Fuzzy Match'; } window.addCpsMatchDetailsPanel = addCpsMatchDetailsPanel; /** * Restore the schedule viewer after viewing a cut sheet * Called when user clicks "Back to Schedule" or affirms a cut sheet */ async function restoreScheduleViewer() { console.log('[CPS Main Viewer] Restoring schedule view'); // Get saved page number const savedPage = sharedState.cutsheetViewer.savedSchedulePage || sharedState.session.currentPage || 1; // Reset viewer mode sharedState.viewerMode = 'schedule'; sharedState.cutsheetViewer = { catalogueId: null, pageNum: null, componentIdx: null, savedSchedulePage: null }; // Remove cut sheet controls and match details panel const controls = document.getElementById('cutsheet-mode-controls'); if (controls) controls.remove(); const matchPanel = document.getElementById('cps-match-details-panel'); if (matchPanel) matchPanel.remove(); // Restore page input styling const pageInput = document.getElementById('page-input'); if (pageInput) { pageInput.value = savedPage; pageInput.style.background = ''; } // Re-render the schedule page if (typeof renderPdfPage === 'function') { await renderPdfPage(savedPage); } console.log('[CPS Main Viewer] Schedule view restored to page', savedPage); } // Expose to window for inline onclick handlers window.restoreScheduleViewer = restoreScheduleViewer; /** * Render loading skeleton in preview pane * @param {number} componentIdx - Component index */ // NOTE: renderPreviewLoading, renderPreviewError, updatePreviewNavigation removed // Nested preview pane eliminated — main viewer handles all preview display function renderPreviewLoading(componentIdx) { /* no-op: nested pane removed */ } function renderPreviewError(componentIdx, errorMessage, catalogueId, pageNum, dpi) { /* no-op: nested pane removed */ } function updatePreviewNavigation(componentIdx, currentIdx, total) { /* no-op: nested pane removed */ } /** * Navigate to previous/next result in preview * @param {number} componentIdx - Component index * @param {number} direction - -1 for prev, 1 for next */ function navigateCpsPage(componentIdx, direction) { const results = cpsPortalState.searchResults; if (!results || results.length === 0) return; const currentIdx = cpsPortalExtendedState.previewResultIdx; const newIdx = currentIdx + direction; // Bounds check if (newIdx < 0 || newIdx >= results.length) { console.log('[CPS Preview] Navigation out of bounds:', { currentIdx, newIdx, total: results.length }); return; } const result = results[newIdx]; console.log('[CPS Preview] Navigating to result:', { newIdx, result }); // Update focused index in main state cpsPortalState.focusedIndex = newIdx; // Load the new preview loadCpsPreview(componentIdx, result.catalogue_id, result.page_num); // Update focused visual state in results list const resultItems = document.querySelectorAll(`#cps-portal-${componentIdx} .cps-result-item`); resultItems.forEach((item, idx) => { item.classList.toggle('focused', idx === newIdx); }); // Scroll the result into view const focusedItem = resultItems[newIdx]; if (focusedItem) { focusedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } /** * Toggle page selection with 30-page limit enforcement * @param {number} componentIdx - Component index * @param {string} catalogueId - Catalogue ID * @param {number} pageNum - Page number */ function togglePageSelection(componentIdx, catalogueId, pageNum) { // Initialize component selections if needed if (!cpsPortalExtendedState.componentSelections[componentIdx]) { cpsPortalExtendedState.componentSelections[componentIdx] = []; } const selections = cpsPortalExtendedState.componentSelections[componentIdx]; const existingIdx = selections.findIndex( s => s.catalogueId === catalogueId && s.pageNum === pageNum ); if (existingIdx >= 0) { // Remove selection selections.splice(existingIdx, 1); console.log('[CPS Selection] Removed:', { catalogueId, pageNum, remaining: selections.length }); } else { // Check 30-page limit if (selections.length >= cpsPortalExtendedState.maxSelections) { showCpsToast(`Maximum ${cpsPortalExtendedState.maxSelections} pages can be selected`, 'warning'); console.warn('[CPS Selection] Limit reached:', cpsPortalExtendedState.maxSelections); return; } // Find result info const result = cpsPortalState.searchResults.find( r => r.catalogue_id === catalogueId && r.page_num === pageNum ); // Add selection selections.push({ catalogueId, pageNum, model: result?.model || '', title: result?.model || result?.title || `Page ${pageNum}`, manufacturer: result?.manufacturer || '', score: result?.score || result?.confidence || 0 }); console.log('[CPS Selection] Added:', { catalogueId, pageNum, total: selections.length }); } // Sync with main portal state for backward compatibility syncSelectionState(componentIdx); // Re-render the selection pills renderSelectionPills(componentIdx); // Update result item visual state updateResultItemSelection(componentIdx, catalogueId, pageNum); // Gate 5 (CHARLIE): Save draft to D1 on selection change CpsDraftService.saveDraft( componentIdx, CpsDraftService.getCurrentSelections(componentIdx), CpsDraftService.getCurrentPreviewState(componentIdx) ); } /** * Sync extended selection state with main cpsPortalState * @param {number} componentIdx - Component index */ function syncSelectionState(componentIdx) { const selections = cpsPortalExtendedState.componentSelections[componentIdx] || []; cpsPortalState.selectedResults = selections.map(s => ({ catalogueId: s.catalogueId, pageNum: s.pageNum, model: s.title, manufacturer: s.manufacturer, score: s.score })); } /** * Update the visual selection state of a result item * @param {number} componentIdx - Component index * @param {string} catalogueId - Catalogue ID * @param {number} pageNum - Page number */ function updateResultItemSelection(componentIdx, catalogueId, pageNum) { const selections = cpsPortalExtendedState.componentSelections[componentIdx] || []; const isSelected = selections.some( s => s.catalogueId === catalogueId && s.pageNum === pageNum ); const resultItem = document.querySelector( `#cps-portal-${componentIdx} .cps-result-item[data-catalogue-id="${catalogueId}"][data-page-num="${pageNum}"]` ); if (resultItem) { resultItem.classList.toggle('selected', isSelected); const checkbox = resultItem.querySelector('.cps-result-checkbox'); if (checkbox) { checkbox.checked = isSelected; } } // Update the affirm button state updateAffirmButtonState(componentIdx); } /** * Update affirm button state based on selections * @param {number} componentIdx - Component index */ function updateAffirmButtonState(componentIdx) { const affirmBtn = document.getElementById(`cps-affirm-btn-${componentIdx}`); if (!affirmBtn) return; const selections = cpsPortalExtendedState.componentSelections[componentIdx] || []; affirmBtn.disabled = selections.length === 0; affirmBtn.textContent = `Affirm Selected (${selections.length})`; } /** * Render selection pills with horizontal scroll and counter * @param {number} componentIdx - Component index */ function renderSelectionPills(componentIdx) { const selections = cpsPortalExtendedState.componentSelections[componentIdx] || []; const selectionBarContainer = document.querySelector(`#cps-portal-${componentIdx} .cps-results-pane`); if (!selectionBarContainer) return; // Remove existing selection bar const existingBar = selectionBarContainer.querySelector('.cps-selection-bar'); if (existingBar) { existingBar.remove(); } // Don't render if no selections if (selections.length === 0) { updateAffirmButtonState(componentIdx); return; } // Determine counter state const max = cpsPortalExtendedState.maxSelections; let counterClass = ''; if (selections.length >= max) { counterClass = 'limit'; } else if (selections.length >= max * 0.8) { counterClass = 'warning'; } // Build pills HTML const pillsHtml = selections.map((s, idx) => ` p.${s.pageNum} `).join(''); const selectionBarHtml = `
${selections.length} selected:
${pillsHtml}
${selections.length} of ${max}
`; // Insert before the results list ends (after .cps-results-list) const resultsList = selectionBarContainer.querySelector('.cps-results-list'); if (resultsList) { resultsList.insertAdjacentHTML('afterend', selectionBarHtml); } else { selectionBarContainer.insertAdjacentHTML('beforeend', selectionBarHtml); } // Update affirm button updateAffirmButtonState(componentIdx); } /** * Remove a selection from the pills * @param {number} componentIdx - Component index * @param {string} catalogueId - Catalogue ID * @param {number} pageNum - Page number */ function removeSelection(componentIdx, catalogueId, pageNum) { const selections = cpsPortalExtendedState.componentSelections[componentIdx] || []; const idx = selections.findIndex( s => s.catalogueId === catalogueId && s.pageNum === pageNum ); if (idx >= 0) { selections.splice(idx, 1); console.log('[CPS Selection] Removed via pill:', { catalogueId, pageNum, remaining: selections.length }); // Sync with main state syncSelectionState(componentIdx); // Update UI renderSelectionPills(componentIdx); updateResultItemSelection(componentIdx, catalogueId, pageNum); } } // ═══════════════════════════════════════════════════════════════════ // T-800 HUD OVERLAY — The Terminator's Mind's Eye // // During extraction, the user sees what the AGI sees: // - Amber scan lines sweeping across the PDF // - Internal terminal streaming extracted values in real-time // - Confidence scores flickering // - The extraction IS the commercial // ═══════════════════════════════════════════════════════════════════ let _t800Interval = null; let _t800Lines = []; function showT800HUD(pageCount, pageNumbers) { // Remove existing const existing = document.getElementById('t800-hud'); if (existing) existing.remove(); _t800Lines = []; const hud = document.createElement('div'); hud.id = 't800-hud'; hud.style.cssText = ` position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9998; pointer-events: none; background: transparent; transition: opacity 0.8s ease; `; hud.innerHTML = `
EXTRACTION CONFIDENCE
0.000
${pageCount} page${pageCount !== 1 ? 's' : ''} queued
WEYLAND EXTRACTION SUBSYSTEM v3.1
`; document.body.appendChild(hud); // Start streaming terminal lines t800Log('info', 'INIT extraction pipeline'); t800Log('info', `TARGET: ${pageCount} schedule page${pageCount !== 1 ? 's' : ''}`); t800Log('data', `PAGES: [${pageNumbers.join(', ')}]`); t800Log('info', 'ENGAGING vision cortex...'); let cycle = 0; _t800Interval = setInterval(() => { cycle++; const conf = Math.min(0.95, 0.1 + cycle * 0.03 + Math.random() * 0.05); const confEl = document.getElementById('t800-conf-value'); if (confEl) confEl.textContent = conf.toFixed(3); // Simulated extraction telemetry const msgs = [ ['data', `SCAN region ${Math.floor(Math.random()*12)+1}/12 — ${(Math.random()*100).toFixed(0)}% coverage`], ['conf', `CONFIDENCE: ${conf.toFixed(4)} | ENTROPY: ${(1-conf).toFixed(4)}`], ['data', `DETECT: schedule_type=${['door_schedule','hardware_group','finish_schedule'][Math.floor(Math.random()*3)]}`], ['info', `OCR pass ${cycle} — ${Math.floor(Math.random()*200+50)} glyphs resolved`], ['data', `MATCH: mark_${String.fromCharCode(65+Math.floor(Math.random()*8))} → hardware_group[${Math.floor(Math.random()*15)}]`], ['conf', `AFFIRM threshold: ${(0.7+Math.random()*0.25).toFixed(3)} | current: ${conf.toFixed(3)}`], ['data', `EXTRACT: qty=${Math.floor(Math.random()*50+1)} | size=${['3070','3080','3680','2868'][Math.floor(Math.random()*4)]}`], ['info', `NEURAL activation: layer ${Math.floor(Math.random()*12)} | ${(Math.random()*2).toFixed(1)}ms`], ]; const [type, msg] = msgs[Math.floor(Math.random() * msgs.length)]; t800Log(type, msg); }, 800); } function t800Log(type, msg) { const body = document.getElementById('t800-terminal-body'); if (!body) return; const ts = new Date().toISOString().slice(11, 23); const line = document.createElement('div'); line.className = `t800-line ${type}`; line.textContent = `[${ts}] ${msg}`; body.appendChild(line); body.scrollTop = body.scrollHeight; _t800Lines.push({ type, msg, ts }); // Keep max 50 lines visible while (body.children.length > 50) { body.removeChild(body.firstChild); } } function hideT800HUD(resultsCount) { if (_t800Interval) { clearInterval(_t800Interval); _t800Interval = null; } // Final messages if (resultsCount > 0) { t800Log('info', `EXTRACTION COMPLETE: ${resultsCount} result${resultsCount !== 1 ? 's' : ''}`); t800Log('data', 'PIPELINE: vision → extract → affirm → DONE'); const confEl = document.getElementById('t800-conf-value'); if (confEl) { confEl.textContent = '1.000'; confEl.style.color = '#00ff41'; } } else { t800Log('warn', 'EXTRACTION FAILED — no results'); } // Fade out after 2 seconds setTimeout(() => { const hud = document.getElementById('t800-hud'); if (hud) { hud.style.opacity = '0'; setTimeout(() => hud.remove(), 800); } }, 2000); } /** * Show a toast notification * @param {string} message - Message to display * @param {string} type - 'info', 'warning', or 'error' * @param {number} duration - Duration in ms (default 3000) */ function showCpsToast(message, type = 'info', duration = 3000) { // Remove existing toast const existingToast = document.querySelector('.cps-toast'); if (existingToast) { existingToast.remove(); } // Create toast element const toast = document.createElement('div'); toast.className = `cps-toast ${type}`; toast.textContent = message; document.body.appendChild(toast); // Trigger animation requestAnimationFrame(() => { toast.classList.add('visible'); }); // Auto-hide setTimeout(() => { toast.classList.remove('visible'); setTimeout(() => toast.remove(), 300); }, duration); } /** * Initialize extended state when portal opens * Call this from toggleCpsPortal when expanding * @param {number} componentIdx - Component index */ function initCpsExtendedState(componentIdx) { // Initialize selections for this component if not exists if (!cpsPortalExtendedState.componentSelections[componentIdx]) { cpsPortalExtendedState.componentSelections[componentIdx] = []; } cpsPortalExtendedState.previewResultIdx = -1; cpsPortalExtendedState.previewLoading = false; cpsPortalExtendedState.previewError = null; } /** * Enhanced result click handler - integrates with ALPHA's handleCpsResultClick * Calls loadCpsPreview for proper PNG loading * @param {number} componentIdx - Component index * @param {number} resultIdx - Result index */ function handleCpsResultClickEnhanced(componentIdx, resultIdx) { const result = cpsPortalState.searchResults[resultIdx]; if (!result) return; // Toggle selection using enhanced method togglePageSelection(componentIdx, result.catalogue_id, result.page_num); // Load preview using enhanced method loadCpsPreview(componentIdx, result.catalogue_id, result.page_num); } /** * Enhanced selection toggle - integrates with ALPHA's toggleCpsResultSelection * Uses 30-page limit enforcement * @param {number} componentIdx - Component index * @param {number} resultIdx - Result index */ function toggleCpsResultSelectionEnhanced(componentIdx, resultIdx) { const result = cpsPortalState.searchResults[resultIdx]; if (!result) return; togglePageSelection(componentIdx, result.catalogue_id, result.page_num); } // ============================================ // END CPS PORTAL Gate 3+4 JavaScript Functions // ============================================ // ================================================================ // END CUT SHEET FUNCTIONS // ================================================================ function addComponent() { sharedState.hardware.components.push({ type: "", quantity: "1", manufacturer: "", model: "", finish: "", flagged: false, affirmed: false, // P0: Affirm system - new components start unaffirmed notes: "" }); // Adding a component unaffirms the group sharedState.hardware.affirmed = false; // Sync edits back to allGroups/allPageExtractions so data is consistent saveCurrentGroupEdits(); saveState(); // Mark page as modified in cache savePageData(sharedState.session.id, sharedState.session.currentPage, { extracted: true, modified: true, approved: false, hardwareData: sharedState.hardware }); renderComponents(); renderTocProgressBar(); console.log('[ADD] New component added'); } function deleteComponent(index) { const component = sharedState.hardware.components[index]; // Windows 95-style confirmation const confirmed = confirm( `Delete Component #${index + 1}?\n\n` + `Type: ${component.type || '(empty)'}\n` + `Manufacturer: ${component.manufacturer || '(empty)'}\n` + `Model: ${component.model || '(empty)'}\n\n` + `This action cannot be undone.` ); if (!confirmed) { console.log(`[DELETE] Cancelled deletion of Component ${index + 1}`); return; } // Remove from array sharedState.hardware.components.splice(index, 1); // Save state saveState(); // Re-render all layouts (renumbering happens automatically) renderComponents(); console.log(`[DELETE] Component ${index + 1} removed. Remaining: ${sharedState.hardware.components.length}`); } /** * Delete a component from the global extractions view * Used in the unified all-pages view */ function deleteGlobalComponent(pageNum, groupNumber, compIdx) { const pageData = sharedState.allPageExtractions[pageNum]; if (!pageData || !pageData.groups) { console.warn('[DELETE GLOBAL] Page data not found:', pageNum); return; } // Find the group const group = pageData.groups.find(g => (g.group_number || g.groupNumber) === groupNumber ); if (!group || !group.components) { console.warn('[DELETE GLOBAL] Group not found:', groupNumber); return; } const component = group.components[compIdx]; if (!component) { console.warn('[DELETE GLOBAL] Component not found at index:', compIdx); return; } // Confirm deletion const confirmed = confirm( `Delete Component from Group #${groupNumber} (Page ${pageNum})?\n\n` + `Type: ${component.type || component.component_type || '(empty)'}\n` + `Manufacturer: ${component.manufacturer || component.manufacturer_code || '(empty)'}\n\n` + `This action cannot be undone.` ); if (!confirmed) { console.log('[DELETE GLOBAL] Cancelled'); return; } // Remove component group.components.splice(compIdx, 1); // If this is the current page, update the active state too if (pageNum === sharedState.session.currentPage) { const currentGroup = sharedState.allGroups.find(g => g.group_number === groupNumber); if (currentGroup) { currentGroup.components = group.components; } // Re-render main view renderComponents(); } // renderAllExtractions DISABLED — overwrites renderComponents affirm view // if (typeof renderAllExtractions === 'function') { // renderAllExtractions(); // } saveState(); console.log(`[DELETE GLOBAL] Component removed from page ${pageNum}, group ${groupNumber}`); } // =========================== // AFFIRM & CONTINUE // =========================== async function affirmAndContinue() { // Save current group edits before approval saveCurrentGroupEdits(); // Count flagged components across ALL groups let totalFlagged = 0; let totalComponents = 0; sharedState.allGroups.forEach(group => { const components = group.components || []; totalComponents += components.length; totalFlagged += components.filter(c => c.flagged).length; }); console.log('=== AFFIRM & CONTINUE ==='); console.log('Session ID:', sharedState.session.id); console.log('Total Groups:', sharedState.allGroups.length); console.log('Total Components:', totalComponents); console.log('Flagged for Review:', totalFlagged); console.log('========================='); Telemetry.logAction('affirmStart', `Affirming page ${sharedState.session.currentPage}`, { sessionId: sharedState.session.id, pageNumber: sharedState.session.currentPage, groupCount: sharedState.allGroups.length, componentCount: totalComponents, flaggedCount: totalFlagged }); // BLOCK APPROVAL IF NO DATA EXTRACTED if (sharedState.allGroups.length === 0) { alert('No hardware data to approve.\n\nPlease extract the page first by clicking "Re-extract".'); console.log('[AFFIRM] BLOCKED - No groups to approve'); return; } // BLOCK APPROVAL IF ANY COMPONENTS ARE FLAGGED // Flagged items require resolution before data enters D1 database if (totalFlagged > 0) { const blockMessage = `⛔ APPROVAL BLOCKED\n\n` + `${totalFlagged} component(s) are flagged for review.\n\n` + `Flagged items must be resolved before approval:\n` + `• Review the flagged data against the PDF source\n` + `• Make corrections if needed\n` + `• Click the flag icon to unflag when resolved\n\n` + `No data will be saved to the database until all flags are cleared.`; alert(blockMessage); console.log('[AFFIRM] BLOCKED - Flagged items must be resolved first'); return; } let message = `Ready to approve this page and proceed?\n\n`; message += `Groups on this page: ${sharedState.allGroups.length}\n`; message += `Total components: ${totalComponents}\n`; message += `✓ No flagged items - ready for database`; if (!confirm(message)) { console.log('[AFFIRM] User cancelled'); return; } // Validate session exists if (!sharedState.session.id || sharedState.session.id.startsWith('session_')) { alert('Please upload a PDF first'); return; } // Check authentication if (!authState.isAuthenticated) { alert('Please log in first'); showAuthModal(); return; } const sessionId = sharedState.session.id; const currentPage = sharedState.session.currentPage; // Show loading indicator const loadingDiv = document.createElement('div'); loadingDiv.id = 'approve-loading'; loadingDiv.style.cssText = 'position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: white; padding: 30px; border-radius: 8px; box-shadow: 0 4px 12px rgba(0,0,0, 0.65); z-index: 10000; text-align: center; min-width: 400px;'; loadingDiv.innerHTML = `
Approving Page ${currentPage}...
Saving data and moving to next page
`; document.body.appendChild(loadingDiv); try { console.log('[AFFIRM] Approving page:', currentPage); // Call approve API with ALL groups from this page const response = await callAPI(`/api/hardware-schedule/session/${sessionId}/page/${currentPage}/approve`, { method: 'POST', body: JSON.stringify({ hardwareGroups: sharedState.allGroups.map(group => ({ group_number: group.group_number, group_name: group.group_name, components: (group.components || []).map(comp => ({ component_type: comp.component_type || comp.type, quantity: comp.quantity, manufacturer_code: comp.manufacturer_code || comp.manufacturer, model_number: comp.model_number || comp.model, finish_code: comp.finish_code || comp.finish, description: comp.description, flagged: comp.flagged, notes: comp.notes })) })) }) }); console.log('[AFFIRM] Approval response:', response); // Mark page as approved in localStorage markPageApproved(sessionId, currentPage); // Move to next page sharedState.session.currentPage++; // Check if we've reached the end if (sharedState.session.currentPage > sharedState.session.totalPages) { document.body.removeChild(loadingDiv); renderTocProgressBar(); // Update to show final approved state // Show export and enrichment buttons showExportButtons(); showEnrichButtons(); alert(`All pages completed! 🎉\n\nSession ${sessionId} has been fully processed.\n\nTotal pages: ${sharedState.session.totalPages}\n\nClick "Enrich Products" to auto-fill specifications, then "Export to Excel" to download.`); console.log('[AFFIRM] Session complete'); return; } // Update progress bar to show approved status renderTocProgressBar(); // Save state saveState(); // Remove loading indicator document.body.removeChild(loadingDiv); // Automatically extract next page console.log('[AFFIRM] Moving to page:', sharedState.session.currentPage); // Show success - all pages already extracted const nextPageMessage = `Page ${currentPage} approved! ✓\n\nNow on page ${sharedState.session.currentPage} of ${sharedState.session.totalPages}`; console.log(nextPageMessage); // Load next page's data if it exists in allPageExtractions const nextPage = sharedState.session.currentPage; const nextPageData = sharedState.allPageExtractions[nextPage]; if (nextPageData && nextPageData.groups && nextPageData.groups.length > 0) { // Load groups from extraction data (uses centralised normalizeGroup) sharedState.allGroups = nextPageData.groups.map(normalizeGroup).filter(Boolean); sharedState.currentGroupIndex = 0; // Load first group into active hardware state const firstGroup = sharedState.allGroups[0]; sharedState.hardware = { groupNumber: firstGroup.group_number, groupName: firstGroup.group_name, doorMarks: firstGroup.door_marks || [], components: firstGroup.components || [] }; console.log(`[AFFIRM] Loaded ${sharedState.allGroups.length} groups from page ${nextPage}`); } else { // No data for next page - clear state sharedState.hardware = { groupNumber: "", groupName: "", components: [] }; sharedState.allGroups = []; sharedState.currentGroupIndex = 0; console.log(`[AFFIRM] Page ${nextPage} has no extraction data - cleared state`); } renderComponents(); updateGroupNavigation(); saveState(); console.log('[AFFIRM] Ready for next page'); } catch (error) { console.error('[AFFIRM] Error:', error); // Remove loading indicator if (document.getElementById('approve-loading')) { document.body.removeChild(loadingDiv); } // Show error alert('Approval failed: ' + error.message); } } // =========================== // EXPORT TO EXCEL // =========================== function showExportButtons() { // Show all export buttons across all layouts const exportButtons = [ 'export-btn-sbs', 'export-btn-tb', 'export-btn-tabbed', 'export-btn-modal' ]; exportButtons.forEach(btnId => { const btn = document.getElementById(btnId); if (btn) btn.style.display = 'inline-block'; }); console.log('[EXPORT] Export buttons shown'); } // =========================== // SUBMITTAL PREVIEW & GENERATION // =========================== // Manufacturer code expansion map const MANUFACTURER_MAP = { 'SCH': 'Schlage', 'SCE': 'Schlage Electronics', 'VON': 'Von Duprin', 'LCN': 'LCN', 'IVE': 'Ives', 'GLY': 'Glynn-Johnson', 'ZER': 'Zero International', 'B/O': 'By Others' }; // Finish code descriptions const FINISH_MAP = { '613': 'Oil Rubbed Bronze', '626': 'Satin Chrome', '630': 'Satin Stainless Steel', '640': 'Satin Stainless Steel', '643e': 'Aged Bronze', '643E': 'Aged Bronze', '695': 'Dark Bronze', 'US26D': 'Satin Chrome', 'US32D': 'Satin Stainless Steel' }; /** * Show extraction confirmation dialog * User confirms: hardware group count and Door/Mark-to-Hardware mapping */ function showExtractionConfirmation(totalGroups, totalComponents, pagesWithData, totalPages) { console.log('[CONFIRM] Showing extraction confirmation dialog'); // Gather unique group numbers from extractions const groupNumbers = new Set(); const doorMarkMappings = []; // Will hold discovered Door-to-HW mappings Object.values(sharedState.allPageExtractions).forEach(pageData => { if (pageData.groups) { pageData.groups.forEach(group => { if (group.group_number) { groupNumbers.add(group.group_number); } // Check for door references in group description/notes const doorRefs = extractDoorReferences(group); if (doorRefs.length > 0) { doorMarkMappings.push({ groupNumber: group.group_number, doors: doorRefs }); } }); } }); const uniqueGroups = Array.from(groupNumbers).sort((a, b) => { const numA = parseInt(a) || 0; const numB = parseInt(b) || 0; return numA - numB; }); // Create confirmation modal const modal = document.createElement('div'); modal.id = 'extraction-confirmation-modal'; modal.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.85); z-index: 10000; display: flex; align-items: center; justify-content: center; overflow-y: auto; padding: 1rem;'; const hasDoorMappings = doorMarkMappings.length > 0; modal.innerHTML = `

✓ Extraction Complete

${totalGroups}
Hardware Groups
${totalComponents}
Components
${pagesWithData}
Pages with Data
${totalPages}
Total Pages
groups expected

Extracted groups: ${uniqueGroups.join(', ') || 'None identified'}

${hasDoorMappings ? `
${doorMarkMappings.map(m => `
Doors: ${m.doors.join(', ')} → HW Group ${m.groupNumber}
`).join('')}
` : `

No explicit Door-to-Hardware mappings found. You can add them manually:

`}
`; document.body.appendChild(modal); } /** * Extract door references from a hardware group */ function extractDoorReferences(group) { const doorRefs = []; const text = [ group.description || '', group.notes || '', group.doors_applied || '', group.door_marks || '' ].join(' '); // Look for door patterns: Door 101, DR-101, DOOR 101A, Mark 101, etc. const patterns = [ /(?:door|dr|mark)\s*[-:]?\s*(\d+[a-z]?)/gi, /(\d{3,4}[a-z]?)/g // 3-4 digit numbers likely door marks ]; patterns.forEach(pattern => { let match; while ((match = pattern.exec(text)) !== null) { const ref = match[1].toUpperCase(); if (!doorRefs.includes(ref)) { doorRefs.push(ref); } } }); return doorRefs.slice(0, 10); // Limit to first 10 } /** * Close extraction confirmation and proceed or re-extract */ function closeExtractionConfirmation(proceed) { const modal = document.getElementById('extraction-confirmation-modal'); if (modal) { if (proceed) { // Save confirmed group count const confirmedCount = parseInt(document.getElementById('confirm-group-count')?.value) || 0; sharedState.session.confirmedGroupCount = confirmedCount; // Save door mapping if entered const mappingInput = document.getElementById('door-hw-mapping-input'); if (mappingInput && mappingInput.value.trim()) { sharedState.session.doorHardwareMapping = mappingInput.value.trim(); } console.log(`[CONFIRM] User confirmed ${confirmedCount} groups`); } document.body.removeChild(modal); if (proceed) { // FX-2026-0121-UI-002: Wire HGSE detection into flow const sessionId = sharedState.session?.id; if (sessionId) { detectAndShowSchedules(sessionId); } else { console.warn('[CONFIRM] No session ID, falling back to submittal preview'); showSubmittalPreview(); } } else { // User wants to re-extract — use Path C (select pages and batch-extract) showCpsToast('Select pages in the sheet index and click Extract to re-extract via Path C.', 'info', 4000); } } } // View a cut sheet PDF page with authenticated fetch (opens in new tab) async function viewCutSheetPdf(renderUrl) { try { const token = authState.token; if (!token) { alert('Not authenticated'); return; } const response = await fetch(API_BASE_URL + renderUrl, { headers: { 'Authorization': 'Bearer ' + token } }); if (!response.ok) throw new Error('HTTP ' + response.status); const blob = await response.blob(); const blobUrl = URL.createObjectURL(blob); window.open(blobUrl, '_blank'); } catch (err) { console.error('[Cut Sheet View] Error:', err); alert('Failed to load cut sheet: ' + err.message); } } // =========================== // ONE-CLICK PRODUCT PREVIEW IN MAIN VIEWER // =========================== // Renders assembled submittal product (component data + embedded cut sheet images) // directly in the main viewer canvas area. No modals, no popups. async function showProductPreview(groupNumber, componentIndex) { console.log('[PREVIEW] Rendering product preview in main viewer', { groupNumber, componentIndex }); // Save current state for restoration if (sharedState.viewerMode !== 'preview') { sharedState.previewState.savedSchedulePage = sharedState.session.currentPage; } sharedState.viewerMode = 'preview'; sharedState.previewState.scope = { type: componentIndex !== undefined ? 'component' : 'product', groupNumber: groupNumber, componentIndex: componentIndex }; // Get main viewer container const mainViewer = document.getElementById('pdf-viewer-sbs'); if (!mainViewer) { console.warn('[PREVIEW] Main viewer not found'); return; } // Hide PDF canvas and text layer const pageContainer = mainViewer.querySelector('.pdf-page-container'); if (pageContainer) pageContainer.style.display = 'none'; // Remove any existing CPS mode controls var existingControls = document.getElementById('cutsheet-mode-controls'); if (existingControls) existingControls.remove(); var existingMatch = document.getElementById('cps-match-details-panel'); if (existingMatch) existingMatch.remove(); // Create or get preview container var previewDiv = document.getElementById('product-preview-container'); if (!previewDiv) { previewDiv = document.createElement('div'); previewDiv.id = 'product-preview-container'; previewDiv.style.cssText = 'width:100%;height:100%;overflow-y:auto;background:#1e293b;padding:1rem;'; mainViewer.appendChild(previewDiv); } // Show loading previewDiv.style.display = 'block'; previewDiv.innerHTML = '
' + '
' + '
Assembling preview...
' + '
'; const sessionId = sharedState.session?.id; if (!sessionId) { previewDiv.innerHTML = '
No Active Session
Start a session to preview your submittal.
'; addPreviewModeControls(mainViewer); return; } try { const endpoint = '/api/sessions/' + sessionId + '/preview' + (groupNumber ? '?group=' + encodeURIComponent(groupNumber) : ''); const data = await callAPI(endpoint); console.log('[PREVIEW] Data received:', data.groups.length, 'groups'); const projectName = data.session_meta.project_name || 'Hardware Submittal Package'; const dsaNumber = sharedState.dsaNumber || 'XX-XXXXXX'; const preparedFor = sharedState.preparedFor || 'Submittal Review'; const groups = data.groups || []; const summary = data.affirm_summary; const fetchTime = new Date().toLocaleTimeString(); // Collect all cut sheet render URLs for parallel fetch var cutSheetFetches = []; groups.forEach(function(group) { (group.components || []).forEach(function(comp) { (comp.cut_sheets || []).forEach(function(cs) { if (cs.render_url) { cutSheetFetches.push({ url: cs.render_url, key: cs.render_url, title: cs.catalogue_title || 'Cut Sheet', page: cs.page_start }); } }); }); }); // Fetch all cut sheet PDFs in parallel (auth fetch → ArrayBuffer for PDF.js) var cutSheetData = {}; if (cutSheetFetches.length > 0) { var token = authState.token; var fetchPromises = cutSheetFetches.map(function(cs) { return fetch(API_BASE_URL + cs.url, { headers: { 'Authorization': 'Bearer ' + token } }).then(function(resp) { if (!resp.ok) throw new Error('HTTP ' + resp.status); return resp.arrayBuffer(); }).then(function(buf) { cutSheetData[cs.key] = buf; }).catch(function(err) { console.warn('[PREVIEW] Cut sheet fetch failed:', cs.url, err.message); cutSheetData[cs.key] = null; }); }); await Promise.all(fetchPromises); console.log('[PREVIEW] Fetched', Object.keys(cutSheetData).length, 'cut sheet PDFs'); } // ── Build assembled preview document ── var html = ''; // Per-component focused view if (componentIndex !== undefined && componentIndex !== null) { var targetGroup = groups.find(function(g) { return String(g.group_number) === String(groupNumber); }); if (!targetGroup) { previewDiv.innerHTML = '
Group ' + groupNumber + ' not found.
'; addPreviewModeControls(mainViewer); return; } var comp = targetGroup.components[parseInt(componentIndex)]; if (!comp) { previewDiv.innerHTML = '
Component not found.
'; addPreviewModeControls(mainViewer); return; } html = buildComponentPreviewHtml(comp, groupNumber, componentIndex, fetchTime, cutSheetData); } else { // Full product preview html += '
'; html += '
'; html += '' + (data.scope === 'group' ? 'Group ' + data.group_filter + ' Preview' : 'Full Product Preview') + ''; html += '' + summary.affirmed_groups + ' of ' + summary.total_groups + ' groups affirmed · ' + summary.affirmed_components + ' of ' + summary.total_components + ' components affirmed'; html += '
'; if (summary.all_affirmed) { html += '
All components affirmed — ready for assembly
'; } html += '
Updated ' + fetchTime + '
'; html += '
'; groups.forEach(function(group, groupIndex) { html += buildGroupPreviewHtml(group, groupIndex, groups.length, projectName, dsaNumber, preparedFor, cutSheetData); }); if (groups.length === 0) { html = '
No Hardware Data
Upload a PDF and extract pages to generate preview.
'; } } previewDiv.innerHTML = '
' + html + '
'; addPreviewModeControls(mainViewer); // Render cut sheet PDFs to canvas elements using PDF.js var canvases = previewDiv.querySelectorAll('canvas[data-render-url]'); for (var ci = 0; ci < canvases.length; ci++) { (function(canvas) { var renderUrl = canvas.getAttribute('data-render-url'); var buf = cutSheetData[renderUrl]; if (!buf) return; pdfjsLib.getDocument({ data: buf }).promise.then(function(pdf) { return pdf.getPage(1); }).then(function(page) { var containerWidth = canvas.parentElement.clientWidth - 16; var vp = page.getViewport({ scale: 1 }); var scale = Math.min(containerWidth / vp.width, 2.5); var scaledVp = page.getViewport({ scale: scale }); canvas.width = scaledVp.width; canvas.height = scaledVp.height; page.render({ canvasContext: canvas.getContext('2d'), viewport: scaledVp }); }).catch(function(err) { console.warn('[PREVIEW] PDF.js render failed for', renderUrl, err.message); }); })(canvases[ci]); } console.log('[PREVIEW] Rendered in main viewer,', canvases.length, 'cut sheet canvases'); } catch (err) { console.error('[PREVIEW] Error:', err); previewDiv.innerHTML = '
Preview Unavailable
' + escapeHtml(err.message) + '
'; addPreviewModeControls(mainViewer); } } window.showProductPreview = showProductPreview; // Build HTML for a single component preview with embedded cut sheet images function buildComponentPreviewHtml(comp, groupNumber, componentIndex, fetchTime, cutSheetData) { var mfrCode = comp.manufacturer || ''; var mfrFull = MANUFACTURER_MAP[mfrCode] || mfrCode; var affirmBadge = comp.affirmed ? 'AFFIRMED' : 'PENDING'; var h = '
'; h += '' + escapeHtml(comp.type || 'Component') + ' — Set ' + escapeHtml(String(groupNumber)) + ', #' + (parseInt(componentIndex) + 1) + ''; h += affirmBadge + '
'; h += '
Updated ' + fetchTime + '
'; // Component details card h += '
'; h += '
' + escapeHtml(comp.type || 'Component') + '
'; h += '
'; h += '
Manufacturer
' + escapeHtml(mfrFull || mfrCode) + '
'; h += '
Model/Series
' + escapeHtml(comp.model || '') + '
'; h += '
Quantity
' + escapeHtml(String(comp.quantity || '1')) + ' ' + escapeHtml(comp.uom || 'EA') + '
'; h += '
Finish
' + escapeHtml(comp.finish || 'Not specified') + '
'; h += '
'; if (comp.notes) { h += '
Notes
' + escapeHtml(comp.notes) + '
'; } if (comp.affirmed && comp.affirmed_at) { h += '
Affirmed
' + new Date(comp.affirmed_at).toLocaleString() + '
'; } h += '
'; // Embedded cut sheet canvases (rendered by PDF.js after DOM insertion) if (comp.cut_sheets && comp.cut_sheets.length > 0) { comp.cut_sheets.forEach(function(cs, csIdx) { var hasData = cs.render_url && cutSheetData[cs.render_url]; var canvasId = 'cs-canvas-comp-' + componentIndex + '-' + csIdx; h += '
'; h += '
' + escapeHtml(cs.catalogue_title || 'Cut Sheet') + ' — p.' + cs.page_start + '
'; if (hasData) { h += '
'; h += ''; h += '
'; } else { h += '
Cut sheet page not available
'; } h += '
'; }); } else { h += '
No cut sheets attached. Use CPS Portal to search and link cut sheets.
'; } return h; } // Build HTML for a full hardware group with embedded cut sheets function buildGroupPreviewHtml(group, groupIndex, totalGroups, projectName, dsaNumber, preparedFor, cutSheetData) { var groupNumber = group.group_number; var components = group.components || []; var doorMarks = group.door_marks || []; var doorMarksDisplay = Array.isArray(doorMarks) && doorMarks.length > 0 ? doorMarks.join(', ') : (typeof doorMarks === 'string' && doorMarks ? doorMarks : null); var keyingSystem = group.keying_system || null; var fireRatings = components.map(function(c) { return c.fire_rating; }).filter(function(r) { return r && String(r) !== '0' && String(r).toLowerCase() !== 'non-rated' && String(r).toLowerCase() !== 'none'; }); var uniqueFireRatings = fireRatings.filter(function(v, i, a) { return a.indexOf(v) === i; }); var hasFireHardware = uniqueFireRatings.length > 0; var openingType = hasFireHardware ? 'Fire-Rated Door Assembly (' + uniqueFireRatings.join(', ') + ' min)' : 'Standard Door Assembly'; var finishCodes = []; components.forEach(function(c) { if (c.finish && finishCodes.indexOf(c.finish) === -1) finishCodes.push(c.finish); }); var primaryFinish = finishCodes.length > 0 ? finishCodes[0] : null; var finishDescription = primaryFinish ? (FINISH_MAP[primaryFinish] || primaryFinish) : null; var groupBadge = group.affirmed ? 'AFFIRMED' : '' + group.affirmed_component_count + '/' + group.component_count + ' affirmed'; var h = '
'; // Header h += '
'; h += '
' + escapeHtml(projectName) + '
'; h += '
'; h += '
DSA No.:
' + escapeHtml(dsaNumber) + '
'; h += '
Prepared For:
' + escapeHtml(preparedFor) + '
'; h += '
'; // Set Title h += '
Hardware Submittal Sheet - Hardware Set ' + escapeHtml(String(groupNumber)) + groupBadge + '
'; // Set Info h += '
Hardware Set Information
'; h += '
Hardware Set
#' + escapeHtml(String(groupNumber)) + '
'; h += '
Opening Type
' + openingType + '
'; if (primaryFinish) { h += '
Finish
BHMA ' + primaryFinish + ' (' + finishDescription + ')
'; } if (doorMarksDisplay) { h += '
Assigned Doors
' + escapeHtml(doorMarksDisplay) + '
'; } h += '
'; // Components Table h += '
Hardware Set #' + escapeHtml(String(groupNumber)) + ' Components
'; h += ''; h += ''; h += ''; if (components.length === 0) { h += ''; } else { components.forEach(function(comp) { var mfrCode = comp.manufacturer || ''; var mfrFull = MANUFACTURER_MAP[mfrCode] || mfrCode; var affirmIcon = comp.affirmed ? '' : ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; h += ''; }); } h += '
QtyUnitDescriptionModel/SeriesFinishMfr
No components extracted.
' + affirmIcon + '' + escapeHtml(String(comp.quantity || '1')) + '' + escapeHtml(comp.uom || 'EA') + '' + escapeHtml(comp.type || '') + '' + escapeHtml(comp.model || '') + '' + escapeHtml(comp.finish || '') + '' + escapeHtml(mfrCode) + '
'; // Embedded cut sheet images per component var compWithSheets = components.filter(function(c) { return c.cut_sheets && c.cut_sheets.length > 0; }); if (compWithSheets.length > 0) { h += '
Cut Sheet Attachments
'; compWithSheets.forEach(function(comp) { h += '
'; h += '
' + escapeHtml(comp.type || '') + ' — ' + escapeHtml(comp.manufacturer || '') + ' ' + escapeHtml(comp.model || '') + '
'; comp.cut_sheets.forEach(function(cs, csIdx) { var hasData = cs.render_url && cutSheetData[cs.render_url]; var canvasId = 'cs-canvas-grp-' + groupNumber + '-c' + compWithSheets.indexOf(comp) + '-' + csIdx; h += '
'; h += '
' + escapeHtml(cs.catalogue_title || 'Cut Sheet') + ' — p.' + cs.page_start + '
'; if (hasData) { h += '
'; h += ''; h += '
'; } else { h += '
Cut sheet page not available
'; } h += '
'; }); h += '
'; }); h += '
'; } // Keying Info h += '
Keying Info
'; h += '
Keying System
' + (keyingSystem ? escapeHtml(keyingSystem) : 'Not specified') + '
'; h += '
'; // Compliance (data-driven from extraction + enrichment) var complianceItems = components.filter(function(c) { return c.compliance; }).map(function(c) { return c.compliance; }); var uniqueCompliance = complianceItems.filter(function(v, i, a) { return a.indexOf(v) === i; }); var adaComponents = components.filter(function(c) { return c.ada_compliant; }); if (uniqueCompliance.length > 0 || hasFireHardware || adaComponents.length > 0) { h += '
Certifications & Compliance
'; uniqueCompliance.forEach(function(c) { h += '
' + escapeHtml(c) + '
'; }); if (hasFireHardware) { uniqueFireRatings.forEach(function(r) { h += '
Fire-Rated (' + escapeHtml(String(r)) + ' min)
'; }); } if (adaComponents.length > 0) { h += '
ADA Compliant
'; } h += '
'; } // Footer h += '
'; return h; } // Add preview mode controls overlay function addPreviewModeControls(mainViewer) { var existing = document.getElementById('preview-mode-controls'); if (existing) existing.remove(); var controls = document.createElement('div'); controls.id = 'preview-mode-controls'; controls.style.cssText = 'position:absolute;top:8px;left:8px;right:8px;z-index:150;display:flex;justify-content:space-between;align-items:center;'; controls.innerHTML = '
' + '' + 'PREVIEW' + '' + '
'; mainViewer.appendChild(controls); } window.addPreviewModeControls = addPreviewModeControls; // Exit product preview and restore schedule viewer function exitProductPreview() { console.log('[PREVIEW] Exiting product preview'); // Clean up blob URLs (sharedState.previewState.blobUrls || []).forEach(function(url) { try { URL.revokeObjectURL(url); } catch(e) {} }); sharedState.previewState.blobUrls = []; sharedState.previewState.scope = null; // Remove preview container and controls var previewDiv = document.getElementById('product-preview-container'); if (previewDiv) previewDiv.remove(); var controls = document.getElementById('preview-mode-controls'); if (controls) controls.remove(); // Show PDF canvas again var mainViewer = document.getElementById('pdf-viewer-sbs'); if (mainViewer) { var pageContainer = mainViewer.querySelector('.pdf-page-container'); if (pageContainer) pageContainer.style.display = ''; } // Restore schedule viewer var savedPage = sharedState.previewState.savedSchedulePage || sharedState.session.currentPage || 1; sharedState.viewerMode = 'schedule'; sharedState.previewState.savedSchedulePage = null; // Restore page input styling var pageInput = document.getElementById('page-input'); if (pageInput) { pageInput.value = savedPage; pageInput.style.background = ''; } // Re-render the schedule page if (typeof renderPdfPage === 'function' && pdfDocument) { renderPdfPage(savedPage); } console.log('[PREVIEW] Restored schedule view to page', savedPage); } window.exitProductPreview = exitProductPreview; function showSubmittalPreview(groupNumber, componentIndex) { // Route to inline product preview in main viewer showProductPreview(groupNumber, componentIndex); return; // Legacy modal code below preserved but not reached console.log('[SUBMITTAL] Opening submittal preview modal' + (componentIndex !== undefined ? ' for group ' + groupNumber + ' component ' + componentIndex : groupNumber ? ' for group ' + groupNumber : ' (full product)')); // Save current group edits before generating preview saveCurrentGroupEdits(); // Show the modal immediately with loading state const modal = document.getElementById('submittal-preview-modal'); if (modal) { modal.classList.add('visible'); } // Render the preview (async — fetches from backend) renderSubmittalPreview(groupNumber, componentIndex); } function closeSubmittalPreview() { const modal = document.getElementById('submittal-preview-modal'); if (modal) { modal.classList.remove('visible'); } console.log('[SUBMITTAL] Closed submittal preview modal'); } /** * Aggregate all hardware groups from all pages in allPageExtractions * Legacy helper — retained for exportToExcel and other consumers */ function getAllGroupsFromAllPages() { const allGroups = []; if (!sharedState.allPageExtractions || Object.keys(sharedState.allPageExtractions).length === 0) { if (sharedState.allGroups && sharedState.allGroups.length > 0) return sharedState.allGroups; if (sharedState.hardware && sharedState.hardware.components && sharedState.hardware.components.length > 0) { return [{ group_number: sharedState.hardware.groupNumber || '1', group_name: sharedState.hardware.groupName || 'Hardware Group 1', components: sharedState.hardware.components }]; } return []; } const pageNumbers = Object.keys(sharedState.allPageExtractions).map(n => parseInt(n)).sort((a, b) => a - b); for (const pageNum of pageNumbers) { const pageData = sharedState.allPageExtractions[pageNum]; if (pageData && pageData.groups && pageData.groups.length > 0) { pageData.groups.forEach(group => { allGroups.push({ ...group, _sourcePage: pageNum }); }); } } return allGroups; } /** * renderSubmittalPreview — 26F Wave 2A * Backend-driven preview from production data sources. * Data source precedence: affirm_audit_log.entity_snapshot for affirmed, * extraction tables for unaffirmed (CE_NOTE_2026-0202-PREVIEW-001). * * @param {string|number} [groupNumber] - Optional group filter for per-group scope * @param {number} [componentIndex] - Optional component index for per-component scope */ async function renderSubmittalPreview(groupNumber, componentIndex) { const container = document.getElementById('submittal-preview-content'); if (!container) return; // Track scope for live refresh _previewScope = { groupNumber: groupNumber, componentIndex: componentIndex }; const sessionId = sharedState.session?.id; if (!sessionId) { container.innerHTML = '

No Active Session

Start a session to preview your submittal.

'; return; } // Show loading state container.innerHTML = '

Loading preview...

'; try { const endpoint = '/api/sessions/' + sessionId + '/preview' + (groupNumber ? '?group=' + encodeURIComponent(groupNumber) : ''); const data = await callAPI(endpoint); console.log('[SUBMITTAL] Preview data:', data.groups.length, 'groups,', data.affirm_summary.affirmed_components + '/' + data.affirm_summary.total_components, 'components affirmed'); const projectName = data.session_meta.project_name || 'Hardware Submittal Package'; const dsaNumber = sharedState.dsaNumber || 'XX-XXXXXX'; const preparedFor = sharedState.preparedFor || 'Submittal Review'; const groups = data.groups || []; const cutSheets = data.cut_sheet_matches || []; const summary = data.affirm_summary; // Per-component focused view var fetchTime = new Date().toLocaleTimeString(); if (componentIndex !== undefined && componentIndex !== null) { const targetGroup = groups.find(function(g) { return String(g.group_number) === String(groupNumber); }); if (!targetGroup) { container.innerHTML = '
Group ' + groupNumber + ' not found in preview data.
'; return; } const comp = targetGroup.components[parseInt(componentIndex)]; if (!comp) { container.innerHTML = '
Component ' + componentIndex + ' not found in group ' + groupNumber + '.
'; return; } const mfrCode = comp.manufacturer || ''; const mfrFull = MANUFACTURER_MAP[mfrCode] || mfrCode; const affirmBadge = comp.affirmed ? 'AFFIRMED' : 'PENDING'; let compHtml = '
'; compHtml += '
'; compHtml += '' + escapeHtml(comp.type || 'Component') + ' — Set ' + escapeHtml(String(groupNumber)) + ', #' + (parseInt(componentIndex) + 1) + ''; compHtml += affirmBadge; compHtml += '
'; compHtml += '
Updated ' + fetchTime + '
'; // Component details card compHtml += '
'; compHtml += '
' + escapeHtml(comp.type || 'Component') + '
'; compHtml += '
'; compHtml += '
Manufacturer
' + escapeHtml(mfrFull || mfrCode) + (mfrFull !== mfrCode ? ' (' + escapeHtml(mfrCode) + ')' : '') + '
'; compHtml += '
Model/Series
' + escapeHtml(comp.model || '') + '
'; compHtml += '
Quantity
' + escapeHtml(String(comp.quantity || '1')) + ' ' + escapeHtml(comp.uom || 'EA') + '
'; compHtml += '
Finish
' + escapeHtml(comp.finish || 'Not specified') + '
'; compHtml += '
'; if (comp.notes) { compHtml += '
Notes
' + escapeHtml(comp.notes) + '
'; } if (comp.compliance) { compHtml += '
Compliance
' + escapeHtml(comp.compliance) + '
'; } if (comp.affirmed && comp.affirmed_at) { compHtml += '
Affirmed
' + new Date(comp.affirmed_at).toLocaleString() + '
'; } compHtml += '
'; // Cut sheets if (comp.cut_sheets && comp.cut_sheets.length > 0) { compHtml += '
'; compHtml += '
Cut Sheet Attachments
'; comp.cut_sheets.forEach(function(cs) { compHtml += '
'; compHtml += '
' + escapeHtml(cs.catalogue_title || 'Cut Sheet') + '
'; compHtml += '
p.' + cs.page_start + (cs.page_end !== cs.page_start ? '-' + cs.page_end : '') + '
'; if (cs.render_url) { compHtml += 'View PDF →'; } compHtml += '
'; }); compHtml += '
'; } else { compHtml += '
No cut sheets attached. Use CPS Portal to search and link cut sheets.
'; } compHtml += '
'; container.innerHTML = compHtml; console.log('[SUBMITTAL] Rendered per-component preview for ' + comp.type + ' (' + comp.manufacturer + ' ' + comp.model + ')'); return; } // Affirm progress header with live timestamp let progressHtml = '
'; progressHtml += '
'; progressHtml += '' + (data.scope === 'group' ? 'Group ' + data.group_filter + ' Preview' : 'Full Product Preview') + ''; progressHtml += '' + summary.affirmed_groups + ' of ' + summary.total_groups + ' groups affirmed · ' + summary.affirmed_components + ' of ' + summary.total_components + ' components affirmed'; progressHtml += '
'; if (summary.all_affirmed) { progressHtml += '
All components affirmed — ready for assembly
'; } progressHtml += '
Updated ' + fetchTime + ' — auto-refreshes on affirm changes
'; progressHtml += '
'; // Generate HTML for each hardware set let sheetsHtml = progressHtml; groups.forEach(function(group, groupIndex) { const groupNumber = group.group_number; const groupName = group.group_name; const components = group.components || []; const doorMarks = group.door_marks || []; const doorMarksDisplay = Array.isArray(doorMarks) && doorMarks.length > 0 ? doorMarks.join(', ') : (typeof doorMarks === 'string' && doorMarks ? doorMarks : null); const keyingSystem = group.keying_system || null; const fireRatings = components.map(function(c) { return c.fire_rating; }).filter(function(r) { return r && String(r) !== '0' && String(r).toLowerCase() !== 'non-rated' && String(r).toLowerCase() !== 'none'; }); const uniqueFireRatings = fireRatings.filter(function(v, i, a) { return a.indexOf(v) === i; }); const hasFireHardware = uniqueFireRatings.length > 0; const openingType = hasFireHardware ? 'Fire-Rated Door Assembly (' + uniqueFireRatings.join(', ') + ' min)' : 'Standard Door Assembly'; const finishCodes = []; components.forEach(function(c) { if (c.finish && finishCodes.indexOf(c.finish) === -1) finishCodes.push(c.finish); }); const primaryFinish = finishCodes.length > 0 ? finishCodes[0] : null; const finishDescription = primaryFinish ? (FINISH_MAP[primaryFinish] || primaryFinish) : null; // Group affirm badge const groupBadge = group.affirmed ? 'AFFIRMED' : '' + group.affirmed_component_count + '/' + group.component_count + ' affirmed'; sheetsHtml += '
'; // Header sheetsHtml += '
'; sheetsHtml += '
' + escapeHtml(projectName) + '
'; sheetsHtml += '
'; sheetsHtml += '
DSA No.:
' + escapeHtml(dsaNumber) + '
'; sheetsHtml += '
Prepared For:
' + escapeHtml(preparedFor) + '
'; sheetsHtml += '
'; // Set Title with affirm badge sheetsHtml += '
Hardware Submittal Sheet - Hardware Set ' + escapeHtml(String(groupNumber)) + groupBadge + '
'; // Set Info sheetsHtml += '
Hardware Set Information
'; sheetsHtml += '
Hardware Set
#' + escapeHtml(String(groupNumber)) + '
'; sheetsHtml += '
Opening Type
' + openingType + '
'; if (primaryFinish) { sheetsHtml += '
Finish
BHMA ' + primaryFinish + ' (' + finishDescription + ')
'; } if (doorMarksDisplay) { sheetsHtml += '
Assigned Doors
' + escapeHtml(doorMarksDisplay) + '
'; } sheetsHtml += '
'; // Components Table — with affirm status column sheetsHtml += '
Hardware Set #' + escapeHtml(String(groupNumber)) + ' Components
'; sheetsHtml += ''; sheetsHtml += ''; sheetsHtml += ''; if (components.length === 0) { sheetsHtml += ''; } else { components.forEach(function(comp) { const mfrCode = comp.manufacturer || ''; const mfrFull = MANUFACTURER_MAP[mfrCode] || mfrCode; const affirmIcon = comp.affirmed ? '' : ''; sheetsHtml += ''; sheetsHtml += ''; sheetsHtml += ''; sheetsHtml += ''; sheetsHtml += ''; sheetsHtml += ''; sheetsHtml += ''; sheetsHtml += ''; sheetsHtml += ''; }); } sheetsHtml += '
QtyUnitDescriptionModel/SeriesFinishMfr
No components extracted for this hardware set.
' + affirmIcon + '' + escapeHtml(String(comp.quantity || '1')) + '' + escapeHtml(comp.uom || 'EA') + '' + escapeHtml(comp.type || '') + '' + escapeHtml(comp.model || '') + '' + escapeHtml(comp.finish || '') + '' + escapeHtml(mfrCode) + '
'; // Cut sheet attachments per component (CPS-affirmed) var compWithSheets = components.filter(function(c) { return c.cut_sheets && c.cut_sheets.length > 0; }); if (compWithSheets.length > 0) { sheetsHtml += '
Cut Sheet Attachments
'; compWithSheets.forEach(function(comp) { sheetsHtml += '
'; sheetsHtml += '
' + escapeHtml(comp.type || '') + ' — ' + escapeHtml(comp.manufacturer || '') + ' ' + escapeHtml(comp.model || '') + '
'; sheetsHtml += '
'; comp.cut_sheets.forEach(function(cs) { sheetsHtml += '
'; sheetsHtml += '
' + escapeHtml(cs.catalogue_title || 'Cut Sheet') + '
'; sheetsHtml += '
p.' + cs.page_start + (cs.page_end !== cs.page_start ? '-' + cs.page_end : '') + '
'; if (cs.render_url) { sheetsHtml += 'View PDF →'; } sheetsHtml += '
'; }); sheetsHtml += '
'; }); sheetsHtml += '
'; } else if (cutSheets.length > 0) { // Legacy fallback: session-level cut sheet matches sheetsHtml += '
Matched Cut Sheets
'; sheetsHtml += '
'; cutSheets.forEach(function(cs) { sheetsHtml += '
'; sheetsHtml += '
' + escapeHtml(cs.document_title || 'Cut Sheet') + '
'; sheetsHtml += '
' + escapeHtml(cs.manufacturer || '') + ' ' + escapeHtml(cs.model || '') + '
'; if (cs.confidence) { sheetsHtml += '
Match: ' + Math.round(cs.confidence * 100) + '%
'; } if (cs.download_url) { sheetsHtml += 'View PDF'; } sheetsHtml += '
'; }); sheetsHtml += '
'; } // Keying Info sheetsHtml += '
Keying Info
'; sheetsHtml += '
Keying System
' + (keyingSystem ? escapeHtml(keyingSystem) : 'Not specified') + '
'; sheetsHtml += '
'; // Compliance (data-driven from extraction + enrichment) var complianceItems = components.filter(function(c) { return c.compliance; }).map(function(c) { return c.compliance; }); var uniqueCompliance = complianceItems.filter(function(v, i, a) { return a.indexOf(v) === i; }); var adaComponents = components.filter(function(c) { return c.ada_compliant; }); if (uniqueCompliance.length > 0 || hasFireHardware || adaComponents.length > 0) { sheetsHtml += '
Certifications & Compliance
'; uniqueCompliance.forEach(function(c) { sheetsHtml += '
' + escapeHtml(c) + '
'; }); if (hasFireHardware) { uniqueFireRatings.forEach(function(r) { sheetsHtml += '
Fire-Rated (' + escapeHtml(String(r)) + ' min)
'; }); } if (adaComponents.length > 0) { sheetsHtml += '
ADA Compliant
'; } sheetsHtml += '
'; } // Footer sheetsHtml += ''; sheetsHtml += '
'; }); if (groups.length === 0) { container.innerHTML = '

No Hardware Data Available

Upload a PDF and extract pages to generate submittal preview.

'; } else { container.innerHTML = sheetsHtml; } console.log('[SUBMITTAL] Rendered preview for ' + groups.length + ' hardware set(s)'); } catch (err) { console.error('[SUBMITTAL] Preview fetch error:', err); container.innerHTML = '

Preview Unavailable

' + escapeHtml(err.message || 'Failed to load preview data.') + '

'; } } function escapeHtml(text) { if (typeof text !== 'string') return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } async function generateSubmittalPdf() { console.log('[SUBMITTAL] Generating PDF...'); // For now, use browser print functionality // Future: Integrate with a proper PDF library like jsPDF or html2pdf const content = document.getElementById('submittal-preview-content'); if (!content) { alert('No content to generate PDF from.'); return; } // Create a new window for printing const printWindow = window.open('', '_blank'); printWindow.document.write(` Hardware Submittal Package ${content.innerHTML} `); printWindow.document.close(); // Wait for content to load then print setTimeout(() => { printWindow.print(); }, 500); console.log('[SUBMITTAL] PDF print dialog opened'); } // Track current preview scope for live refresh var _previewScope = { groupNumber: undefined, componentIndex: undefined }; // Update submittal preview when data changes (wire to data updates) function refreshSubmittalPreviewIfOpen() { // Refresh inline preview if active if (sharedState.viewerMode === 'preview' && sharedState.previewState.scope) { var scope = sharedState.previewState.scope; showProductPreview(scope.groupNumber, scope.componentIndex); return; } // Legacy modal fallback const modal = document.getElementById('submittal-preview-modal'); if (modal && modal.classList.contains('visible')) { renderSubmittalPreview(_previewScope.groupNumber, _previewScope.componentIndex); } } // =========================== // PRODUCT ENRICHMENT // =========================== function showEnrichButtons() { // Show all enrich buttons across all layouts const enrichButtons = [ 'enrich-btn-sbs', 'enrich-btn-tb', 'enrich-btn-tabbed', 'enrich-btn-modal' ]; enrichButtons.forEach(btnId => { const btn = document.getElementById(btnId); if (btn) btn.style.display = 'inline-block'; }); console.log('[ENRICHMENT] Enrich buttons shown'); } async function enrichProducts() { if (!sharedState.session || !sharedState.session.id) { alert('No active session. Please extract pages first.'); return; } // Confirm with user if (!confirm('Enrich all products with manufacturer specifications, compliance standards, and fire ratings?\n\nThis will automatically fill in missing product information using our database of 38+ hardware products.')) { return; } const sessionId = sharedState.session.id; console.log('[ENRICHMENT] Starting product enrichment for session:', sessionId); // Disable all enrich buttons and show loading state const enrichButtons = [ 'enrich-btn-sbs', 'enrich-btn-tb', 'enrich-btn-tabbed', 'enrich-btn-modal' ]; enrichButtons.forEach(btnId => { const btn = document.getElementById(btnId); if (btn) { btn.disabled = true; btn.textContent = '⏳ Enriching...'; } }); try { const token = authState.token; if (!token) { throw new Error('Not authenticated'); } const response = await fetch(`${API_BASE_URL}/api/hardware-schedule/session/${sessionId}/enrich`, { method: 'POST', headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }); if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || 'Enrichment failed'); } const result = await response.json(); console.log('[ENRICHMENT] Result:', result); // Show success message const enrichmentRate = result.enrichment_rate || 0; const message = `✅ Product Enrichment Complete!\n\n` + `Enriched: ${result.enriched} of ${result.total} components (${enrichmentRate}%)\n\n` + `${result.results && result.results.length > 0 ? 'Matched Products:\n' + result.results.slice(0, 5).map(r => `• Set ${r.set_number}: ${r.matched_product} (${r.confidence} confidence)` ).join('\n') + (result.results.length > 5 ? `\n...and ${result.results.length - 5} more` : '') : 'No products matched in database'}\n\n` + `Product specifications, compliance standards, and fire ratings have been automatically filled in.`; alert(message); // Reload current page to show enriched data if (sharedState.currentPageNumber) { await loadExtractedPage(sharedState.currentPageNumber); } } catch (error) { console.error('[ENRICHMENT] Error:', error); alert(`Enrichment failed: ${error.message}`); } finally { // Re-enable all enrich buttons enrichButtons.forEach(btnId => { const btn = document.getElementById(btnId); if (btn) { btn.disabled = false; btn.textContent = '✨ Enrich Products'; } }); } } // =========================== // EXCEL EXPORT HELPER FUNCTIONS (v2.1.1 Security Updates) // =========================== /** * Sanitize cell values to prevent Excel formula injection * Prevents malicious formulas from executing */ function sanitizeForExcel(value) { if (value === null || value === undefined) return ''; if (typeof value !== 'string') return value; // Prevent formula injection - escape leading special characters const dangerousChars = ['=', '+', '-', '@', '\t', '\r']; if (dangerousChars.some(char => value.startsWith(char))) { return "'" + value; // Prefix with apostrophe to make literal } return value; } /** * Validate session data structure before export * Returns array of validation errors (empty if valid) */ function validateExportData(data) { const errors = []; // Check session object if (!data.session) { errors.push('Missing session object in API response'); } else { if (!data.session.project_name) { errors.push('Session missing project_name'); } } // Check hardwareSets array if (!Array.isArray(data.hardwareSets)) { errors.push('hardwareSets is not an array'); return errors; // Can't continue validation } if (data.hardwareSets.length === 0) { errors.push('No hardware sets found in session'); } // Validate each hardware set data.hardwareSets.forEach((set, i) => { if (!set.set_number) { errors.push(`Hardware set ${i}: Missing set_number`); } if (!Array.isArray(set.components)) { errors.push(`Hardware set ${i}: components is not an array`); } else if (set.components.length === 0) { errors.push(`Hardware set ${i}: No components found`); } }); return errors; } async function exportToExcel() { const sessionId = sharedState.session.id; if (!sessionId) { alert('No session ID found. Please upload and process a PDF first.'); return; } console.log('[EXPORT] Starting professional Excel export for session:', sessionId); // Show loading const loadingDiv = document.createElement('div'); loadingDiv.id = 'export-loading'; loadingDiv.style.cssText = ` position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: rgba(30, 41, 59, 0.95); padding: 2rem; border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,0.5); border: 2px solid #3b82f6; z-index: 10000; text-align: center; `; loadingDiv.innerHTML = `
Generating Professional Excel...
Creating formatted workbook with multiple sheets
`; document.body.appendChild(loadingDiv); try { // Fetch session and hardware data const session = sharedState.session; const response = await fetch(API_BASE_URL + `/api/hardware-schedule/session/${sessionId}`, { method: 'GET', headers: { 'Authorization': `Bearer ${authState.token}`, 'Content-Type': 'application/json' } }); if (!response.ok) { throw new Error(`Failed to fetch hardware data: ${response.statusText}`); } const data = await response.json(); console.log('[EXPORT] Fetched hardware data:', data); // CRITICAL: Validate data structure before proceeding const validationErrors = validateExportData(data); if (validationErrors.length > 0) { throw new Error(`Data validation failed:\n${validationErrors.join('\n')}`); } // ═══════════════════════════════════════════════════════ // SOVEREIGN CSV EXPORT (native — no SheetJS/xlsx library) // Three sections in one CSV file, separated by blank rows. // Opens correctly in Excel, Numbers, LibreOffice. // ═══════════════════════════════════════════════════════ const hardwareSets = data.hardwareSets || []; // CSV cell encoder — escapes quotes, wraps in quotes if needed const csvCell = v => { const s = String(v === null || v === undefined ? '' : v); return (s.includes(',') || s.includes('"') || s.includes('\n')) ? '"' + s.replace(/"/g, '""') + '"' : s; }; const csvRow = cols => cols.map(csvCell).join(','); const rows = []; // ── Section 1: Hardware Schedule ── rows.push(csvRow(['PROJECT HARDWARE SCHEDULE'])); rows.push(''); rows.push(csvRow(['Project:', session.project_name || 'Unknown Project'])); rows.push(csvRow(['File:', session.file_name || 'N/A'])); rows.push(csvRow(['Extraction Date:', new Date(session.created_at).toLocaleDateString()])); rows.push(csvRow(['Extracted By:', 'Weyland AI Systems - SubX'])); rows.push(''); rows.push(csvRow(['Set #','Component Type','Qty','Mfr','Model','Description','Finish','Compliance','Fire Rating','ADA','Price','Notes'])); hardwareSets.forEach(set => { (set.components || []).forEach(comp => { rows.push(csvRow([ set.set_number || '', comp.component_type || '', comp.quantity !== undefined && comp.quantity !== null ? comp.quantity : '', comp.manufacturer_code || '', comp.model_number || '', comp.component_description || comp.description || '', comp.finish_description || '', comp.compliance || '', comp.fire_rating || '', comp.ada_compliant ? 'Yes' : 'No', comp.unit_price ? `$${comp.unit_price}` : '', comp.notes || '' ])); }); }); // ── Section 2: Summary by Category ── rows.push(''); rows.push(''); rows.push(csvRow(['HARDWARE SUMMARY BY CATEGORY'])); rows.push(''); rows.push(csvRow(['Category','Component Count','Total Quantity','Unique Manufacturers'])); const categoryStats = {}; hardwareSets.forEach(set => { (set.components || []).forEach(comp => { const type = comp.component_type || 'Unknown'; if (!categoryStats[type]) categoryStats[type] = { count: 0, totalQty: 0, manufacturers: new Set() }; categoryStats[type].count++; categoryStats[type].totalQty += parseInt(comp.quantity) || 0; if (comp.manufacturer_code) categoryStats[type].manufacturers.add(comp.manufacturer_code); }); }); Object.entries(categoryStats).sort().forEach(([category, stats]) => { rows.push(csvRow([category, stats.count, stats.totalQty, stats.manufacturers.size])); }); rows.push(''); rows.push(csvRow(['Total Components:', Object.values(categoryStats).reduce((s, v) => s + v.count, 0)])); rows.push(csvRow(['Total Quantity:', Object.values(categoryStats).reduce((s, v) => s + v.totalQty, 0)])); // ── Section 3: Compliance Matrix ── rows.push(''); rows.push(''); rows.push(csvRow(['COMPLIANCE & STANDARDS MATRIX'])); rows.push(''); rows.push(csvRow(['Set #','Component','Fire Rating','ADA','Standards','Category'])); hardwareSets.forEach(set => { (set.components || []).forEach(comp => { if (comp.compliance || comp.fire_rating || comp.ada_compliant) { rows.push(csvRow([ set.set_number || '', `${comp.manufacturer_code || ''} ${comp.model_number || ''}`.trim(), comp.fire_rating || 'Non-Rated', comp.ada_compliant ? 'Yes' : 'No', comp.compliance || 'N/A', comp.component_type || '' ])); } }); }); // ── Download ── const csvContent = rows.join('\r\n'); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.style.display = 'none'; a.href = url; a.download = `${session.project_name || 'Hardware_Schedule'}_${new Date().toISOString().split('T')[0]}.csv`; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); document.body.removeChild(a); // Remove loading document.body.removeChild(loadingDiv); // Show success const componentCount = Object.values(categoryStats).reduce((sum, s) => sum + s.count, 0); alert(`Export Complete!\n\n${componentCount} components exported (3 sections: Hardware Schedule, Summary, Compliance).\n\nFile opens in Excel, Numbers, or any spreadsheet app.`); console.log('[EXPORT] Professional Excel export completed successfully'); } catch (error) { console.error('[EXPORT] Error:', error); // Remove loading if (document.getElementById('export-loading')) { document.body.removeChild(loadingDiv); } // Show error alert('Export failed: ' + error.message + '\n\nPlease ensure you have completed the hardware extraction first.'); } } // =========================== // COMPONENT 1: ZOOM & CANVAS CONFIGURATION // =========================== const ZOOM_CONFIG = { // Zoom levels DEFAULT: 100, MIN: 25, MAX: 1000, STEP: 25, // Fit-to-width behavior FIT_ROUNDING: 'floor', // 'floor', 'round', 'ceil' // Transform origins per layout // 10F: Scrollable layouts use 'top left' for proper container sizing // Centering achieved via margin:auto on canvas, not transform-origin TRANSFORM_ORIGIN: { 'pdf-canvas-sbs': 'top left', // 10F: was 'top center', changed for scroll access 'pdf-canvas-tb': 'top left', // 10F: was 'top center', changed for scroll access 'pdf-canvas-tabbed': 'top left', // 10F: was 'top center', changed for scroll access 'pdf-canvas-floating': 'top left', 'pdf-canvas-modal': 'center center' // Modal keeps center-center for popup effect } }; const PDF_CONFIG = { // Rendering quality - using device pixel ratio for optimal quality QUALITY_MODE: 'auto', // 'auto', 'low', 'medium', 'high', 'custom' QUALITY_SCALE_MAP: { low: 1.0, medium: 1.5, high: 2.0, auto: window.devicePixelRatio || 1.5 }, // Get current scale based on mode getScale() { if (this.QUALITY_MODE === 'custom') { return this.CUSTOM_SCALE; } return this.QUALITY_SCALE_MAP[this.QUALITY_MODE]; }, CUSTOM_SCALE: 1.5 // Used only if QUALITY_MODE = 'custom' }; // FX-004: Hybrid zoom state (RT-001 L003) let viewerRenderDebounceTimer = null; let isViewerReRendering = false; const CANVAS_CONFIG = { // All canvas element IDs IDS: [ 'pdf-canvas-sbs', 'pdf-canvas-tb', 'pdf-canvas-tabbed', 'pdf-canvas-modal', 'pdf-canvas-floating' ], // Zoom display element IDs ZOOM_DISPLAYS: [ 'zoom-level-sbs', 'zoom-level-tb' ] }; function zoomIn() { sharedState.zoom = Math.min(ZOOM_CONFIG.MAX, sharedState.zoom + ZOOM_CONFIG.STEP); updateZoom(); saveState(); console.log('[ZOOM] In:', sharedState.zoom + '%'); } function zoomOut() { sharedState.zoom = Math.max(ZOOM_CONFIG.MIN, sharedState.zoom - ZOOM_CONFIG.STEP); updateZoom(); saveState(); console.log('[ZOOM] Out:', sharedState.zoom + '%'); } /** * Set zoom from direct input (editable zoom field) * Accepts formats: "500%", "500", "5x" */ function setZoomFromInput(inputEl) { let value = inputEl.value.trim(); let zoom; // Parse different formats if (value.endsWith('%')) { zoom = parseInt(value.replace('%', ''), 10); } else if (value.endsWith('x')) { zoom = parseInt(value.replace('x', ''), 10) * 100; } else { zoom = parseInt(value, 10); // If user typed a small number like "5", assume they meant 500% if (zoom > 0 && zoom <= 10) { zoom = zoom * 100; } } // Validate and clamp if (isNaN(zoom) || zoom < ZOOM_CONFIG.MIN) { zoom = ZOOM_CONFIG.MIN; } else if (zoom > ZOOM_CONFIG.MAX) { zoom = ZOOM_CONFIG.MAX; } sharedState.zoom = zoom; updateZoom(); saveState(); console.log('[ZOOM] Direct input:', sharedState.zoom + '%'); } /** * 10F: Rotate main viewer 90 degrees clockwise * Cycles through 0 → 90 → 180 → 270 → 0 */ function rotateViewer() { sharedState.rotation = (sharedState.rotation + 90) % 360; updateZoom(); // updateZoom handles both zoom and rotation saveState(); console.log('[ROTATE] Viewer:', sharedState.rotation + '°'); } /** * Helper: Get container's horizontal padding dynamically (Issue #2 fix) */ function getContainerPadding(container) { const styles = window.getComputedStyle(container); const paddingLeft = parseFloat(styles.paddingLeft) || 0; const paddingRight = parseFloat(styles.paddingRight) || 0; return paddingLeft + paddingRight; } function fitToWidth() { // Try to calculate actual fit-to-width based on container const container = document.querySelector('.pdf-viewer'); const canvas = document.getElementById('pdf-canvas-sbs'); if (container && canvas && canvas.width > 0) { // Calculate zoom to fit container width, accounting for dynamic padding (Issue #2 fix) const padding = getContainerPadding(container); const containerWidth = container.clientWidth - padding; // Apply configurable rounding mode (Issue #11 fix) const rawZoom = (containerWidth / canvas.width) * 100; let fitZoom; switch (ZOOM_CONFIG.FIT_ROUNDING) { case 'ceil': fitZoom = Math.ceil(rawZoom); break; case 'round': fitZoom = Math.round(rawZoom); break; default: fitZoom = Math.floor(rawZoom); } sharedState.zoom = Math.max(ZOOM_CONFIG.MIN, Math.min(ZOOM_CONFIG.MAX, fitZoom)); console.log('[ZOOM] Calculated fit to width:', sharedState.zoom + '% (padding:', padding + 'px, rounding:', ZOOM_CONFIG.FIT_ROUNDING + ')'); } else { // Fallback to default if calculation fails sharedState.zoom = ZOOM_CONFIG.DEFAULT; console.log('[ZOOM] Fit to width (fallback):', sharedState.zoom + '%'); } updateZoom(); saveState(); } function updateZoom(skipReRender = false) { // FX-004: Hybrid zoom — instant CSS feedback + debounced hi-res re-render (RT-001 L003) // 10F Fix: Update container dimensions so scroll area matches scaled content // 10F: Also handles rotation (0, 90, 180, 270 degrees) // FX-004 Fix: Compute CSS scale as ratio of user zoom / rendered scale const baseScale = PDF_CONFIG.getScale(); const renderedScale = sharedState.viewerRenderedScale || baseScale; const userZoomFactor = sharedState.zoom / 100; // CSS scale = what user wants / what we already rendered const zoomFactor = userZoomFactor / (renderedScale / baseScale); const rotation = sharedState.rotation || 0; const isRotated90or270 = (rotation === 90 || rotation === 270); CANVAS_CONFIG.IDS.forEach(id => { const canvas = document.getElementById(id); if (canvas) { // 10F: Combined transform for zoom and rotation // Order matters: scale first, then rotate canvas.style.transform = `scale(${zoomFactor}) rotate(${rotation}deg)`; canvas.style.transformOrigin = ZOOM_CONFIG.TRANSFORM_ORIGIN[id] || 'top left'; // 10F Fix: Update container dimensions to match scaled (and rotated) canvas // When rotated 90° or 270°, width and height swap const container = canvas.parentElement; if (container && container.classList.contains('pdf-page-container')) { const scaledWidth = canvas.width * zoomFactor; const scaledHeight = canvas.height * zoomFactor; // Swap dimensions for 90/270 rotation const containerWidth = isRotated90or270 ? scaledHeight : scaledWidth; const containerHeight = isRotated90or270 ? scaledWidth : scaledHeight; container.style.width = containerWidth + 'px'; container.style.height = containerHeight + 'px'; // 10F: Adjust canvas position for rotation (centering within swapped container) // 10F: ALL rotations use absolute positioning for consistent selection overlay alignment canvas.style.position = 'absolute'; if (rotation === 90) { canvas.style.top = '0'; canvas.style.left = scaledHeight + 'px'; } else if (rotation === 180) { canvas.style.top = scaledHeight + 'px'; canvas.style.left = scaledWidth + 'px'; } else if (rotation === 270) { canvas.style.top = scaledWidth + 'px'; canvas.style.left = '0'; } else { // 0° rotation: absolute at origin for selection overlay alignment canvas.style.top = '0'; canvas.style.left = '0'; } } } }); // Update zoom level displays (Issue #12 fix - iterate all displays) CANVAS_CONFIG.ZOOM_DISPLAYS.forEach(id => { const display = document.getElementById(id); if (display) { // Support both span (.textContent) and input (.value) elements if (display.tagName === 'INPUT') { display.value = sharedState.zoom + '%'; } else { display.textContent = sharedState.zoom + '%'; } } }); // FX-004 Fix: Debounced hi-res re-render (RT-001 L003) // Re-enabled after fixing zoom reset bug (viewerRenderedScale separation) // Skip during extraction to avoid race condition with canvas capture if (!skipReRender && sharedState.zoom > 110 && !sharedState.extractionInProgress) { clearTimeout(viewerRenderDebounceTimer); viewerRenderDebounceTimer = setTimeout(() => { rerenderViewerAtZoom(); }, 300); } } /** * FX-004: Re-render PDF at current zoom level for crisp text (RT-001 L003) * Hybrid zoom pattern: instant CSS feedback + debounced high-res re-render */ async function rerenderViewerAtZoom() { if (isViewerReRendering) return; if (!pdfDocument && !sharedState.session.imageDocument) return; const baseScale = PDF_CONFIG.getScale(); const effectiveScale = baseScale * (sharedState.zoom / 100); // Memory safety cap (RT-001 L008) const maxScale = 10; const targetScale = Math.min(effectiveScale, maxScale); // Skip if at/near optimal scale (within 10%) if (targetScale <= baseScale * 1.1) { console.log('[VIEWER] Skip re-render — at/near optimal scale'); return; } isViewerReRendering = true; try { const canvas = document.getElementById('pdf-canvas-sbs'); if (!canvas) { isViewerReRendering = false; return; } const context = canvas.getContext('2d'); const maxDim = 8192; let finalScale = targetScale; if (pdfDocument) { // ── PDF path ── console.log(`[VIEWER] Re-rendering PDF at ${targetScale.toFixed(1)}x...`); const pageNumber = sharedState.session.currentPage || 1; const page = await pdfDocument.getPage(pageNumber); const viewport = page.getViewport({ scale: targetScale }); if (viewport.width > maxDim || viewport.height > maxDim) { const scaleFactor = maxDim / Math.max(viewport.width, viewport.height); finalScale = targetScale * scaleFactor; console.log(`[VIEWER] Capped scale from ${targetScale.toFixed(1)}x to ${finalScale.toFixed(1)}x for memory safety`); } const finalViewport = page.getViewport({ scale: finalScale }); canvas.width = finalViewport.width; canvas.height = finalViewport.height; if (window.pdfRenderTasks && window.pdfRenderTasks['pdf-canvas-sbs']) { try { window.pdfRenderTasks['pdf-canvas-sbs'].cancel(); } catch (e) { /* ignore */ } } const renderTask = page.render({ canvasContext: context, viewport: finalViewport }); if (!window.pdfRenderTasks) window.pdfRenderTasks = {}; window.pdfRenderTasks['pdf-canvas-sbs'] = renderTask; await renderTask.promise; } else { // ── Image path (QF-2026-0131-RENDER-001) ── // Mirror PDF hi-res re-render: resize canvas buffer to match zoom, // draw image at larger size via drawImage (browser bilinear/bicubic), // then let updateZoom reduce CSS transform toward 1.0. // Source resolution is fixed, but canvas drawImage upscaling is // higher quality than CSS transform upscaling. const img = sharedState.session.imageDocument; console.log(`[IMAGE] Re-rendering at ${targetScale.toFixed(1)}x...`); let canvasWidth = Math.round(img.width * targetScale); let canvasHeight = Math.round(img.height * targetScale); if (canvasWidth > maxDim || canvasHeight > maxDim) { const scaleFactor = maxDim / Math.max(canvasWidth, canvasHeight); finalScale = targetScale * scaleFactor; canvasWidth = Math.round(img.width * finalScale); canvasHeight = Math.round(img.height * finalScale); console.log(`[IMAGE] Capped scale from ${targetScale.toFixed(1)}x to ${finalScale.toFixed(1)}x for memory safety`); } canvas.width = canvasWidth; canvas.height = canvasHeight; context.drawImage(img, 0, 0, canvasWidth, canvasHeight); } // FX-004 Fix: Track rendered scale, don't reset user's zoom intent sharedState.viewerRenderedScale = finalScale; // Recalculate CSS transform (now zoomFactor = userZoom / renderedScale ratio) updateZoom(true); // skipReRender=true to avoid infinite loop const effectivePercent = Math.round((finalScale / baseScale) * 100); console.log(`[VIEWER] Hi-res rendered at ${finalScale.toFixed(1)}x (${canvas.width}x${canvas.height}), user zoom ${sharedState.zoom}%, effective ${effectivePercent}%`); isViewerReRendering = false; } catch (error) { isViewerReRendering = false; if (error.name !== 'RenderingCancelledException') { console.error('[VIEWER] Re-render failed:', error); } } } /** * FX-004: Wheel navigation for main PDF viewer (RT-001 L001, L009) * Ctrl+wheel = zoom, Alt+wheel = horizontal scroll, plain wheel = browser handles */ function initViewerWheelNavigation() { const viewer = document.getElementById('pdf-viewer-sbs'); if (!viewer) { console.log('[VIEWER] Wheel nav: #pdf-viewer-sbs not found'); return; } viewer.addEventListener('wheel', (e) => { // Alt+wheel = horizontal scroll (FX-004 control schema) if (e.altKey) { e.preventDefault(); viewer.scrollLeft += e.deltaY; return; } // Ctrl+wheel or pinch = zoom (RT-001 L001: touchpad pinch sends ctrlKey) if (e.ctrlKey) { e.preventDefault(); const zoomDelta = e.deltaY > 0 ? -ZOOM_CONFIG.STEP : ZOOM_CONFIG.STEP; sharedState.zoom = Math.max( ZOOM_CONFIG.MIN, Math.min(ZOOM_CONFIG.MAX, sharedState.zoom + zoomDelta) ); updateZoom(); saveState(); console.log('[VIEWER] Wheel zoom:', sharedState.zoom + '%'); return; } // Plain wheel: let browser handle naturally (RT-001 L009, BUG-006) // Do NOT preventDefault, do NOT manually scroll }, { passive: false }); console.log('[VIEWER] Wheel navigation initialized (Ctrl+wheel=zoom, Alt+wheel=horiz)'); } // =========================== // TOC PROGRESS BAR RENDERING // =========================== // CH-2026-0212-QUEUE-002: Track TOC expanded/collapsed state let tocExpandedByUser = false; // User explicitly expanded function toggleTocExpand() { tocExpandedByUser = !tocExpandedByUser; renderTocProgressBar(); } function renderTocProgressBar() { // Support multiple TOC containers across layouts const primaryContainer = document.getElementById('toc-progress-container'); const secondaryTargets = document.querySelectorAll('.toc-progress-target'); if (!primaryContainer && secondaryTargets.length === 0) return; // Hide TOC bar for door_schedule sessions — door marks have their own nav if (sharedState.session && sharedState.session.documentType === 'door_schedule') { if (primaryContainer) primaryContainer.style.display = 'none'; secondaryTargets.forEach(target => target.style.display = 'none'); return; } // Ensure visible for hardware_schedule sessions if (primaryContainer) primaryContainer.style.display = ''; secondaryTargets.forEach(target => target.style.display = ''); const sessionId = sharedState.session.id; const currentPage = sharedState.session.currentPage; const totalPages = pdfDocument ? pdfDocument.numPages : (sharedState.session?.totalPages || 0); // CH-2026-0212-QUEUE-002: Count extraction stats for collapse decision let extractedCount = 0; let totalSectionPages = 0; documentStructure.forEach(section => { for (let p = section.startPage; p <= section.endPage; p++) { totalSectionPages++; const ed = sharedState.allPageExtractions ? sharedState.allPageExtractions[p] : null; if (ed && ed.extracted) extractedCount++; } }); const isPreExtraction = extractedCount === 0; const isLargeDoc = totalPages > 30; const shouldCollapse = (isLargeDoc || isPreExtraction) && !tocExpandedByUser; // Render collapsed summary for large docs / pre-extraction if (shouldCollapse) { let collapsedHtml = `
`; documentStructure.forEach(section => { collapsedHtml += `${section.title} (p.${section.startPage}-${section.endPage})`; }); collapsedHtml += `
${totalSectionPages} pages`; if (extractedCount > 0) { collapsedHtml += `${extractedCount} extracted`; } else { collapsedHtml += `No extractions yet`; } collapsedHtml += `
`; if (primaryContainer) primaryContainer.innerHTML = collapsedHtml; secondaryTargets.forEach(target => target.innerHTML = collapsedHtml); return; } // Full expanded TOC rendering let html = '
'; // Add collapse button if user expanded manually if (tocExpandedByUser && (isLargeDoc || isPreExtraction)) { html += `
`; } documentStructure.forEach(section => { html += `
${section.title}
Pages ${section.startPage}-${section.endPage}
`; for (let page = section.startPage; page <= section.endPage; page++) { const pageData = getPageData(sessionId, page); // Also check allPageExtractions for extraction status const extractionData = sharedState.allPageExtractions ? sharedState.allPageExtractions[page] : null; let statusClass = 'pending'; let statusTitle = 'Pending'; // Check extraction state first (from allPageExtractions), then localStorage cache if (extractionData && extractionData.extracted) { if (extractionData.status === 'approved' || (pageData && pageData.approved)) { statusClass = 'completed'; statusTitle = 'Approved'; } else if (pageData && pageData.modified) { statusClass = 'modified'; statusTitle = 'Modified'; } else { statusClass = 'extracted'; statusTitle = 'Extracted'; } } else if (pageData && pageData.approved) { statusClass = 'completed'; statusTitle = 'Approved'; } else if (pageData && pageData.modified) { statusClass = 'modified'; statusTitle = 'Modified'; } if (page === currentPage) { statusClass += ' current'; } html += `
`; } html += `
`; }); html += '
'; // Populate all TOC containers if (primaryContainer) primaryContainer.innerHTML = html; secondaryTargets.forEach(target => target.innerHTML = html); console.log('[TOC] Progress bar rendered for session:', sessionId); } function navigateToSection(startPage) { console.log('[TOC] Navigate to section starting at page:', startPage); navigateToPage(startPage); } function navigateToPage(pageNumber) { const currentPage = sharedState.session.currentPage; const sessionId = sharedState.session.id; // Skip if already on this page if (currentPage === pageNumber) { console.log('[NAV] Already on page', pageNumber); return; } // Validate page number const totalPages = sharedState.session.totalPages || (pdfDocument ? pdfDocument.numPages : 1); if (pageNumber < 1 || pageNumber > totalPages) { console.warn(`[NAV] Invalid page ${pageNumber}. Valid range: 1-${totalPages}`); return; } // STEP 1: Save current page state before leaving if (currentPage && sharedState.allGroups && sharedState.allGroups.length > 0) { // Sync current hardware edits back to allGroups (using saveCurrentGroupEdits for consistency) saveCurrentGroupEdits(); // Save allGroups back to allPageExtractions if (!sharedState.allPageExtractions[currentPage]) { sharedState.allPageExtractions[currentPage] = { groups: [], extracted: true, pageNumber: currentPage }; } sharedState.allPageExtractions[currentPage].groups = [...sharedState.allGroups]; console.log(`[NAV] Saved ${sharedState.allGroups.length} groups from page ${currentPage}`); } // Check for unsaved changes (modified but not approved) if (currentPage !== pageNumber && checkUnsavedChanges(sessionId, currentPage)) { const shouldContinue = confirm( `Page ${currentPage} has unapproved changes.\n\n` + `Click OK to navigate (changes preserved), Cancel to stay.` ); if (!shouldContinue) { return; } } // STEP 2: Update current page sharedState.session.currentPage = pageNumber; // STEP 3: Load target page data from allPageExtractions (primary source) const targetPageData = sharedState.allPageExtractions[pageNumber]; if (targetPageData && targetPageData.groups && targetPageData.groups.length > 0) { // Load groups from extraction data (uses centralised normalizeGroup) sharedState.allGroups = targetPageData.groups.map(normalizeGroup).filter(Boolean); sharedState.currentGroupIndex = 0; // Load first group into active hardware state const firstGroup = sharedState.allGroups[0]; sharedState.hardware = { groupNumber: firstGroup.group_number, groupName: firstGroup.group_name, doorMarks: firstGroup.door_marks || [], components: firstGroup.components || [] }; console.log(`[NAV] Loaded ${sharedState.allGroups.length} groups from page ${pageNumber}`); } else { // No extraction data for this page - check localStorage cache as fallback const cachedData = getPageData(sessionId, pageNumber); if (cachedData && cachedData.allGroups && cachedData.allGroups.length > 0) { // Normalize cached groups (uses centralised normalizeGroup) sharedState.allGroups = cachedData.allGroups.map(normalizeGroup).filter(Boolean); sharedState.currentGroupIndex = 0; const firstGroup = sharedState.allGroups[0]; sharedState.hardware = { groupNumber: firstGroup.group_number, groupName: firstGroup.group_name, doorMarks: firstGroup.door_marks || [], components: firstGroup.components || [] }; console.log(`[NAV] Loaded ${sharedState.allGroups.length} groups from cache for page ${pageNumber}`); } else { // Truly empty page - clear state sharedState.allGroups = []; sharedState.currentGroupIndex = 0; sharedState.hardware = { groupNumber: "", groupName: "", components: [] }; console.log(`[NAV] Page ${pageNumber} has no extraction data`); } } // STEP 4: Update all UI elements renderTocProgressBar(); renderComponents(); updateGroupNavigation(); saveState(); // Save position to server (debounced, non-blocking) saveSessionPosition(); // Render PDF page if (pdfDocument) { renderPdfPage(pageNumber); } // Update page displays updatePageDisplays(); console.log(`[NAV] Navigated to page ${pageNumber}, ${sharedState.allGroups.length} groups loaded`); } // =========================== // PROGRESS BAR UPDATE (Legacy support) // =========================== function updateProgressBar() { // New TOC-based progress bar renderTocProgressBar(); // Update page input const pageInput = document.getElementById('pageInput'); const currentPage = sharedState.session.currentPage || 1; const totalPages = sharedState.session.totalPages || (pdfDocument ? pdfDocument.numPages : 1); if (pageInput) { pageInput.value = pdfDocument ? currentPage : ''; pageInput.max = pdfDocument ? totalPages : 1; pageInput.disabled = !pdfDocument; } // Update side-by-side layout page counter const currentPageDisplaySBS = document.getElementById('currentPageDisplay-sbs'); const totalPagesDisplaySBS = document.getElementById('totalPagesDisplay-sbs'); if (currentPageDisplaySBS) currentPageDisplaySBS.textContent = pdfDocument ? currentPage : '-'; if (totalPagesDisplaySBS) totalPagesDisplaySBS.textContent = pdfDocument ? totalPages : '-'; } /** * Update all page number displays across the UI * Called when changing pages to keep displays in sync */ function updatePageDisplays() { const currentPage = sharedState.session.currentPage || 1; const totalPages = sharedState.session.totalPages || (pdfDocument ? pdfDocument.numPages : 1); // Update page input field const pageInput = document.getElementById('pageInput'); if (pageInput) { pageInput.value = currentPage; pageInput.max = totalPages; } // Update side-by-side page counter const currentPageDisplaySBS = document.getElementById('currentPageDisplay-sbs'); const totalPagesDisplaySBS = document.getElementById('totalPagesDisplay-sbs'); if (currentPageDisplaySBS) currentPageDisplaySBS.textContent = currentPage; if (totalPagesDisplaySBS) totalPagesDisplaySBS.textContent = totalPages; // Update any page number spans document.querySelectorAll('.current-page-display').forEach(el => { el.textContent = currentPage; }); document.querySelectorAll('.total-pages-display').forEach(el => { el.textContent = totalPages; }); // Update progress bar and TOC renderTocProgressBar(); console.log(`[PAGE] Display updated: page ${currentPage} of ${totalPages}`); } // =========================== // PAGE NAVIGATION (Legacy UI controls) // =========================== function jumpToPage() { const pageInput = document.getElementById('pageInput'); const requestedPage = parseInt(pageInput.value); // Validate if (isNaN(requestedPage)) { alert('Please enter a valid page number'); return; } // Use the new navigateToPage function navigateToPage(requestedPage); // Notify user (page already extracted during auto-extraction) const pageData = sharedState.allPageExtractions[requestedPage]; if (pageData && pageData.extracted) { const groupCount = pageData.groups?.length || 0; if (groupCount > 0) { console.log(`Page ${requestedPage}: ${groupCount} hardware groups already extracted`); } } } function previousPage() { // Use page input value as source of truth (in case user typed a different page) const pageInput = document.getElementById('pageInput'); const displayedPage = pageInput ? parseInt(pageInput.value) : sharedState.session.currentPage; const currentPage = !isNaN(displayedPage) ? displayedPage : sharedState.session.currentPage; if (currentPage <= 1) { alert('Already on first page'); return; } const prevPage = currentPage - 1; navigateToPage(prevPage); } function nextPage() { const totalPages = sharedState.session.totalPages || (pdfDocument ? pdfDocument.numPages : 1); // Use page input value as source of truth (in case user typed a different page) const pageInput = document.getElementById('pageInput'); const displayedPage = pageInput ? parseInt(pageInput.value) : sharedState.session.currentPage; const currentPage = !isNaN(displayedPage) ? displayedPage : sharedState.session.currentPage; if (currentPage >= totalPages) { alert('Already on last page'); return; } const nextPageNum = currentPage + 1; navigateToPage(nextPageNum); } // =========================== // TABBED LAYOUT SPECIFIC // =========================== function switchTab(tabName) { console.log('[TAB] Switching to:', tabName); document.querySelectorAll('.tab-button').forEach(btn => { btn.classList.remove('active'); }); document.getElementById(`tab-${tabName}`).classList.add('active'); document.querySelectorAll('.tab-content').forEach(content => { content.classList.remove('active'); }); document.getElementById(`${tabName}-view`).classList.add('active'); } function hideFloatingPdf() { document.getElementById('floating-pdf').style.display = 'none'; console.log('[FLOATING PDF] Hidden'); } // =========================== // MODAL LAYOUT SPECIFIC // =========================== function showPdfModal() { document.getElementById('pdf-modal').classList.add('visible'); document.getElementById('modal-backdrop').classList.add('visible'); console.log('[MODAL] PDF modal opened'); } function hidePdfModal() { document.getElementById('pdf-modal').classList.remove('visible'); document.getElementById('modal-backdrop').classList.remove('visible'); console.log('[MODAL] PDF modal closed'); } // =========================== // ACTIVITY TRACKING FOR INACTIVITY TIMEOUT // =========================== // Track user activity for inactivity timeout ['click', 'keydown', 'mousemove', 'scroll'].forEach(eventType => { document.addEventListener(eventType, trackActivity, { passive: true }); }); // =========================== // KEYBOARD SHORTCUTS // =========================== document.addEventListener('keydown', function(e) { // Alt+1, Alt+2, Alt+3, Alt+4 for layout switching if (e.altKey && e.key >= '1' && e.key <= '4') { e.preventDefault(); const layouts = ['sidebyside', 'topbottom', 'tabbed', 'modal']; switchLayout(layouts[parseInt(e.key) - 1]); } // Ctrl+S for save if (e.ctrlKey && e.key === 's') { e.preventDefault(); saveState(); alert('Progress saved!'); console.log('[SHORTCUT] Ctrl+S - Progress saved'); } // Ctrl+N for new component if (e.ctrlKey && e.key === 'n') { e.preventDefault(); addComponent(); console.log('[SHORTCUT] Ctrl+N - Component added'); } // Ctrl+O for file upload if (e.ctrlKey && e.key === 'o') { e.preventDefault(); document.getElementById('pdf-file-input').click(); console.log('[SHORTCUT] Ctrl+O - File upload triggered'); } // Ctrl++ for zoom in if (e.ctrlKey && (e.key === '+' || e.key === '=')) { e.preventDefault(); zoomIn(); } // Ctrl+- for zoom out if (e.ctrlKey && e.key === '-') { e.preventDefault(); zoomOut(); } // Ctrl+0 for reset zoom if (e.ctrlKey && e.key === '0') { e.preventDefault(); fitToWidth(); } // Escape to close modals and menus if (e.key === 'Escape') { hidePdfModal(); hideFloatingPdf(); closeAllMenus(); } }); // =========================== // LOGOUT // =========================== function logout() { if (confirm('Are you sure you want to logout?')) { console.log('[LOGOUT] User logged out'); // Clear authentication data authState.token = null; authState.user = null; authState.isAuthenticated = false; // Clear localStorage localStorage.removeItem('jwt_token'); localStorage.removeItem('user_data'); // Show auth modal showAuthModal(); } } // =========================== // INITIALIZATION // =========================== document.addEventListener('DOMContentLoaded', function() { console.log('=== WEYLAND AI SYSTEMS - SUBX v2.0 ==='); console.log('Hardware Schedule Intelligence'); console.log('Powered by Weyland AI Vision'); console.log('API Base URL:', API_BASE_URL); console.log('Session ID:', sharedState.session.id); console.log('Building Better Worlds'); // Check for password reset token in URL checkForResetToken(); // Check authentication first const isAuthenticated = checkAuthentication(); if (isAuthenticated) { loadState(); // 17H: Load user's tenants on page load if already authenticated loadUserTenants(); // Always update zoom displays after loadState (Issue #5 fix) updateZoom(true); // skipReRender on init — no PDF loaded yet if (sharedState.zoom && sharedState.zoom !== ZOOM_CONFIG.DEFAULT) { console.log('[INIT] Restored zoom level:', sharedState.zoom + '%'); } else { console.log('[INIT] Using default zoom level:', sharedState.zoom + '%'); } // FX-004: Initialize wheel navigation for main PDF viewer initViewerWheelNavigation(); // Switch to saved layout switchLayout(sharedState.session.layout); // Update menu checkmarks updateViewMenuCheckmarks(); // P0 FIX: Auto-restore last session on page reload tryRestoreLastSession().then(restored => { if (restored) { console.log('[INIT] Last session restored successfully'); } else { console.log('[INIT] No session to restore, ready for new upload'); } }); // Initialize progress bar (will show "No document loaded" if no PDF) updateProgressBar(); // Show first-time tooltip const firstVisit = !localStorage.getItem('weylandai.hardware.visited'); if (firstVisit) { localStorage.setItem('weylandai.hardware.visited', 'true'); setTimeout(() => { alert('Welcome to SubX!\n\nSubmittal Automation System\n\nAPI Integration Active:\n- User authentication working\n- Ready for PDF upload and extraction\n\nTry the View menu to switch layouts!\nKeyboard: Alt+1/2/3/4 for layouts'); }, AUTH_CONFIG.UI.MODAL_FADE_DURATION_MS); } console.log('[INIT] System ready'); } else { console.log('[INIT] Waiting for authentication'); } }); // =========================== // WORKER INTEGRATION GUIDE // =========================== /* To integrate with Cloudflare Worker: 1. Export functions for API: - GET /api/hardware/session/:id - Load session state - POST /api/hardware/review - Submit reviewed set - PUT /api/hardware/component/:id - Update component 2. Replace localStorage with Worker KV: await HARDWARE_KV.put(`session:${sessionId}`, JSON.stringify(sharedState)); 3. Add PDF fetching: const pdfUrl = await HARDWARE_KV.get(`pdf:page:${pageNum}`); fetch(pdfUrl).then(blob => renderPdf(blob)); 4. WebSocket for real-time collaboration: const ws = new WebSocket('wss://hardware.weylandai.com/ws'); ws.onmessage = (msg) => syncStateFromServer(msg.data); 5. Event Bus pattern already implemented via sharedState */ // =========================== // CHAT INTERFACE // =========================== // Voice recognition state let voiceRecognition = null; let isListening = false; /** * CH-2026-0125-UI-001: Toggle Ask Weyland dropdown */ function toggleWeylandChat(event) { if (event) event.stopPropagation(); const dropdown = document.getElementById('weyland-dropdown'); if (dropdown) { const isOpen = dropdown.classList.toggle('open'); if (isOpen) { // Focus input when opening const input = document.getElementById('chat-input'); if (input) input.focus(); // Add click-outside listener setTimeout(() => { document.addEventListener('click', closeWeylandOnClickOutside); }, 0); } else { document.removeEventListener('click', closeWeylandOnClickOutside); } } } /** * CH-2026-0125-UI-001: Close dropdown when clicking outside */ function closeWeylandOnClickOutside(e) { const tab = document.getElementById('weyland-tab'); const dropdown = document.getElementById('weyland-dropdown'); if (tab && dropdown && !tab.contains(e.target)) { dropdown.classList.remove('open'); document.removeEventListener('click', closeWeylandOnClickOutside); } } function handleChatKeypress(event) { if (event.key === 'Enter') { sendChatMessage(); } } /** * Close the chat response panel */ function closeChatPanel() { document.getElementById('chat-response-panel').classList.remove('active'); } /** * Show the chat response panel with content */ function showChatResponse(content, isThinking = false) { const panel = document.getElementById('chat-response-panel'); const contentDiv = document.getElementById('chat-response-content'); if (isThinking) { contentDiv.innerHTML = `
Thinking...
`; } else { contentDiv.innerHTML = content; } panel.classList.add('active'); } /** * Get current project context for the chat */ function getProjectContext() { const context = { projectName: sharedState.session.projectName || 'Unknown Project', totalPages: sharedState.session.totalPages || 0, currentPage: sharedState.session.currentPage || 1, extractedPagesCount: Object.keys(sharedState.allPageExtractions || {}).length, hardwareGroups: [], totalComponents: 0 }; // Collect all hardware groups from extractions Object.values(sharedState.allPageExtractions || {}).forEach(pageData => { if (pageData.groups && pageData.groups.length > 0) { pageData.groups.forEach(group => { context.hardwareGroups.push({ setNumber: group.set_number || group.setNumber, description: group.description || group.groupName, componentCount: group.components?.length || 0 }); context.totalComponents += group.components?.length || 0; }); } }); return context; } /** * Send chat message to AI with context */ async function sendChatMessage() { const input = document.getElementById('chat-input'); const userMessage = input.value.trim(); if (!userMessage) return; // Clear input input.value = ''; // Show thinking state showChatResponse('', true); // Get project context const context = getProjectContext(); // Build system prompt with context const systemPrompt = `You are the SubX Assistant, an AI helper for the SubX Hardware Submittal Automation system by WeylandAI. Current Project Context: - Project Name: ${context.projectName} - Total PDF Pages: ${context.totalPages} - Current Page: ${context.currentPage} - Extracted Pages: ${context.extractedPagesCount} - Total Hardware Groups: ${context.hardwareGroups.length} - Total Components: ${context.totalComponents} Hardware Groups Summary: ${context.hardwareGroups.map(g => ` - ${g.setNumber}: ${g.description} (${g.componentCount} components)`).join('\n') || ' No hardware groups extracted yet.'} You can help users: 1. Understand their submittal data 2. Suggest edits to hardware components 3. Explain compliance requirements (DSA, OSHPD) 4. Answer questions about door hardware 5. Provide guidance on the SubX workflow When users ask for edits, provide specific instructions they can follow in the UI. Keep responses concise but helpful. Use bullet points for clarity.`; try { // Call the chat API const response = await callAPI('/api/chat', { method: 'POST', body: JSON.stringify({ message: userMessage, systemPrompt: systemPrompt, context: context }) }); // Format and display response let formattedResponse = formatChatResponse(response.response || response.message || 'I apologize, I could not process that request.'); if (response.directiveLogged) { formattedResponse += '

✓ Directive logged

'; } showChatResponse(formattedResponse); } catch (error) { console.error('[CHAT] Error:', error); // Try to handle locally for common queries const localResponse = handleLocalQuery(userMessage, context); if (localResponse) { showChatResponse(localResponse); } else { showChatResponse(`

Sorry, I encountered an error: ${error.message}

Please try again or check your connection.

`); } } } /** * Handle common queries locally without API call */ function handleLocalQuery(message, context) { const lowerMsg = message.toLowerCase(); // Status queries if (lowerMsg.includes('how many') && lowerMsg.includes('page')) { return `

Your project has ${context.totalPages} pages total.

${context.extractedPagesCount} pages have been extracted for hardware data.

`; } if (lowerMsg.includes('how many') && (lowerMsg.includes('component') || lowerMsg.includes('hardware'))) { return `

Your submittal contains ${context.totalComponents} components across ${context.hardwareGroups.length} hardware sets.

`; } if (lowerMsg.includes('summary') || lowerMsg.includes('overview')) { let html = `

Project: ${context.projectName}

`; html += ``; if (context.hardwareGroups.length > 0) { html += `

Hardware Sets:

`; } return html; } // Help queries if (lowerMsg.includes('help') || lowerMsg === '?') { return `

SubX Assistant Help

Try asking:

You can also use voice input by clicking the microphone icon.

`; } // How-to queries if (lowerMsg.includes('how') && lowerMsg.includes('add')) { return `

To add a component:

  1. Navigate to the page with the hardware set
  2. Click the "+ Add Component" button at the bottom of the extracted components
  3. Or use Edit → Add Component (Ctrl+N)
`; } if (lowerMsg.includes('how') && lowerMsg.includes('export')) { return `

To export your submittal:

  1. Click 📋 Submittal in the navigation bar
  2. Use File → Export to Excel for a spreadsheet
  3. Or use File → Generate Submittal for the formatted document
`; } return null; // No local response, will try API } /** * Format chat response for display */ function formatChatResponse(text) { // Convert markdown-like formatting to HTML let html = text .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*(.*?)\*/g, '$1') .replace(/^- (.*$)/gm, '
  • $1
  • ') .replace(/^(\d+)\. (.*$)/gm, '
  • $2
  • ') .replace(/\n\n/g, '

    ') .replace(/\n/g, '
    '); // Wrap in paragraphs if not already if (!html.startsWith('<')) { html = '

    ' + html + '

    '; } // Fix list formatting html = html.replace(/(
  • .*<\/li>)+/g, ''); return html; } /** * Toggle voice input using Web Speech API */ function toggleVoiceInput() { const voiceBtn = document.getElementById('voice-btn'); // Check if Speech Recognition is supported const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!SpeechRecognition) { alert('Voice input is not supported in this browser. Please try Chrome or Edge.'); return; } if (isListening) { // Stop listening if (voiceRecognition) { voiceRecognition.stop(); } isListening = false; voiceBtn.classList.remove('listening'); return; } // Start listening voiceRecognition = new SpeechRecognition(); voiceRecognition.continuous = false; voiceRecognition.interimResults = true; voiceRecognition.lang = 'en-US'; voiceRecognition.onstart = () => { isListening = true; voiceBtn.classList.add('listening'); document.getElementById('chat-input').placeholder = 'Listening...'; }; voiceRecognition.onresult = (event) => { const input = document.getElementById('chat-input'); let transcript = ''; for (let i = event.resultIndex; i < event.results.length; i++) { transcript += event.results[i][0].transcript; } input.value = transcript; // If it's a final result, stop listening if (event.results[event.results.length - 1].isFinal) { voiceRecognition.stop(); } }; voiceRecognition.onerror = (event) => { console.error('[VOICE] Error:', event.error); isListening = false; voiceBtn.classList.remove('listening'); document.getElementById('chat-input').placeholder = 'Ask about your submittal, request edits, or get help...'; if (event.error === 'not-allowed') { alert('Microphone access denied. Please enable microphone permissions for this site.'); } }; voiceRecognition.onend = () => { isListening = false; voiceBtn.classList.remove('listening'); document.getElementById('chat-input').placeholder = 'Ask about your submittal, request edits, or get help...'; }; voiceRecognition.start(); } /* CH-2026-0125-UI-001: DEPRECATED — chat bar relocated to tab dropdown document.addEventListener('DOMContentLoaded', () => { const mainContents = document.querySelectorAll('.main-content, .app-container'); mainContents.forEach(el => el.classList.add('main-content-with-chat')); }); */ // =========================== // HGSE REGION SELECTOR (CH-2026-0120-REGION-001) // Human-Guided Schedule Extraction - Region Drawing // =========================== // Region drawing state let regionDrawingState = { isDrawing: false, startX: 0, startY: 0, currentRect: null, selectedPageNumber: null, userRegions: [] // Store user-defined regions }; /** * Open the region selector modal * CH-2026-0122-REGION-002: Now uses PDF viewer overlay instead of server-side previews */ function openRegionSelector() { Telemetry.logAction('openRegionSelector', 'Opening HGSE region selector (PDF viewer overlay)'); // CH-2026-0122-REGION-002: Use new PDF viewer overlay instead of legacy modal openRegionSelectorOverlay(); } /** * [LEGACY] Open the old region selector modal with server-side previews * Kept for reference but no longer called */ function openRegionSelectorLegacy() { const modal = document.getElementById('region-selector-modal'); if (modal) { modal.classList.add('visible'); loadPageGalleryForRegions(); } } /** * Close the region selector modal */ function closeRegionSelector() { const modal = document.getElementById('region-selector-modal'); if (modal) { modal.classList.remove('visible'); } // Reset drawing area const drawingArea = document.getElementById('drawing-area'); if (drawingArea) { drawingArea.classList.remove('visible'); } regionDrawingState.selectedPageNumber = null; regionDrawingState.currentRect = null; } /** * Load page thumbnails into the gallery for region selection */ async function loadPageGalleryForRegions() { const gallery = document.getElementById('page-gallery'); if (!gallery) return; const sessionId = sharedState.session.id || sharedState.session.sessionId; if (!sessionId || sessionId.startsWith('session_')) { gallery.innerHTML = '
    Please upload a PDF first.
    '; return; } // QF-2026-0121-UI-001: Fetch fresh page count from API instead of stale localStorage // QF-2026-0121-UI-002: Fix getAuthHeaders undefined — use authState.token pattern // QF-2026-0121-UI-003: Use sharedState.session.totalPages as primary source for restored sessions let totalPages = sharedState.session.totalPages || 0; // If sharedState doesn't have page count, try API if (!totalPages && authState.token) { try { const statusResponse = await fetch(`${API_BASE_URL}/api/hardware-schedule/session/${sessionId}/status`, { headers: { 'Authorization': `Bearer ${authState.token}` } }); if (statusResponse.ok) { const statusData = await statusResponse.json(); totalPages = statusData.total_pages || statusData.page_count || 0; } } catch (e) { console.error('[REGION] Failed to fetch session status:', e); } } if (!totalPages) { gallery.innerHTML = '
    No pages found. Please upload a PDF or load a session first.
    '; return; } console.log(`[REGION] Loading gallery for ${totalPages} pages (session: ${sessionId})`) gallery.innerHTML = '
    Loading pages...
    '; // FX-2026-0121-AUTH-001: Fetch signed URLs for all pages let signedUrls = {}; try { const signedUrlResponse = await fetch(`${API_BASE_URL}/api/sessions/${sessionId}/signed-urls`, { method: 'POST', headers: { 'Authorization': `Bearer ${authState.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ all: true }) }); if (signedUrlResponse.ok) { const signedUrlData = await signedUrlResponse.json(); signedUrls = signedUrlData.urls || {}; // Cache signed URLs for selectPageForRegionDrawing regionDrawingState.signedUrls = signedUrls; console.log(`[REGION] Got ${Object.keys(signedUrls).length} signed URLs`); } else { console.error('[REGION] Failed to get signed URLs:', signedUrlResponse.status); } } catch (e) { console.error('[REGION] Error fetching signed URLs:', e); } let thumbnailsHtml = ''; for (let i = 1; i <= totalPages; i++) { // Check if this page already has user-defined regions const hasRegions = regionDrawingState.userRegions.some(r => r.page_number === i); // Use signed URL if available, fallback to direct URL (will show error state) const imgSrc = signedUrls[i] || `${API_BASE_URL}/api/hardware-schedule/session/${sessionId}/page/${i}/preview?with_overlay=false`; // QF-2026-0121-UI-004: Remove loading="lazy" — Edge intervention blocks lazy images in modals thumbnailsHtml += `
    Page ${i} Page ${i}
    `; } gallery.innerHTML = thumbnailsHtml; } /** * Select a page for region drawing */ async function selectPageForRegionDrawing(pageNumber) { regionDrawingState.selectedPageNumber = pageNumber; // Highlight selected thumbnail document.querySelectorAll('.page-thumbnail').forEach(thumb => { thumb.classList.remove('selected'); if (parseInt(thumb.dataset.page) === pageNumber) { thumb.classList.add('selected'); } }); // Show drawing area const drawingArea = document.getElementById('drawing-area'); if (drawingArea) { drawingArea.classList.add('visible'); } // Load full-resolution page image using signed URL // FX-2026-0121-AUTH-001: Use cached signed URL from loadPageGalleryForRegions const sessionId = sharedState.session.id || sharedState.session.sessionId; const img = document.getElementById('region-page-image'); if (img) { // Use signed URL if available (cached from gallery load) const signedUrl = regionDrawingState.signedUrls?.[pageNumber]; img.src = signedUrl || `${API_BASE_URL}/api/hardware-schedule/session/${sessionId}/page/${pageNumber}/preview?with_overlay=false`; img.onload = () => { initRegionDrawingCanvas(img); }; } Telemetry.logAction('selectPageForRegion', `Selected page ${pageNumber} for region drawing`); } /** * Initialize the drawing canvas with proper dimensions */ function initRegionDrawingCanvas(img) { const canvas = document.getElementById('region-drawing-canvas'); if (!canvas || !img) return; const container = document.querySelector('.drawing-container'); if (!container) return; // Get the actual rendered dimensions of the image const imgRect = img.getBoundingClientRect(); const containerRect = container.getBoundingClientRect(); // Position canvas over the image canvas.style.left = `${img.offsetLeft}px`; canvas.style.top = `${img.offsetTop}px`; canvas.width = img.clientWidth; canvas.height = img.clientHeight; const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); // Draw any existing regions for this page drawExistingRegions(ctx); // Set up drawing handlers setupRegionDrawingHandlers(canvas, img); } /** * Set up mouse event handlers for region drawing */ function setupRegionDrawingHandlers(canvas, img) { const ctx = canvas.getContext('2d'); canvas.onmousedown = (e) => { regionDrawingState.isDrawing = true; const rect = canvas.getBoundingClientRect(); regionDrawingState.startX = e.clientX - rect.left; regionDrawingState.startY = e.clientY - rect.top; }; canvas.onmousemove = (e) => { if (!regionDrawingState.isDrawing) return; const rect = canvas.getBoundingClientRect(); const currentX = e.clientX - rect.left; const currentY = e.clientY - rect.top; // Clear and redraw ctx.clearRect(0, 0, canvas.width, canvas.height); drawExistingRegions(ctx); // Draw current selection ctx.strokeStyle = '#3B82F6'; ctx.lineWidth = 3; ctx.setLineDash([8, 4]); ctx.strokeRect( regionDrawingState.startX, regionDrawingState.startY, currentX - regionDrawingState.startX, currentY - regionDrawingState.startY ); ctx.fillStyle = 'rgba(59, 130, 246, 0.15)'; ctx.fillRect( regionDrawingState.startX, regionDrawingState.startY, currentX - regionDrawingState.startX, currentY - regionDrawingState.startY ); // Store current rect (normalized to positive width/height) regionDrawingState.currentRect = { x: Math.min(regionDrawingState.startX, currentX), y: Math.min(regionDrawingState.startY, currentY), width: Math.abs(currentX - regionDrawingState.startX), height: Math.abs(currentY - regionDrawingState.startY), // Store scale factors for converting to original PDF coordinates scaleX: img.naturalWidth / canvas.width, scaleY: img.naturalHeight / canvas.height }; }; canvas.onmouseup = () => { if (!regionDrawingState.isDrawing) return; regionDrawingState.isDrawing = false; // Only show classification if rectangle is large enough (min 50x50 pixels) if (regionDrawingState.currentRect && regionDrawingState.currentRect.width > 50 && regionDrawingState.currentRect.height > 50) { showRegionClassificationDialog(); } }; canvas.onmouseleave = () => { if (regionDrawingState.isDrawing) { regionDrawingState.isDrawing = false; } }; } /** * Draw existing regions on the canvas */ function drawExistingRegions(ctx) { const pageRegions = regionDrawingState.userRegions.filter( r => r.page_number === regionDrawingState.selectedPageNumber ); pageRegions.forEach(region => { ctx.strokeStyle = '#22c55e'; ctx.lineWidth = 2; ctx.setLineDash([]); ctx.strokeRect(region.display_x, region.display_y, region.display_width, region.display_height); ctx.fillStyle = 'rgba(34, 197, 94, 0.1)'; ctx.fillRect(region.display_x, region.display_y, region.display_width, region.display_height); // Draw label ctx.fillStyle = '#22c55e'; ctx.font = '12px sans-serif'; ctx.fillText(region.schedule_type.replace('_', ' '), region.display_x + 4, region.display_y + 14); }); } /** * Show the classification dialog after drawing a region */ function showRegionClassificationDialog() { const dialog = document.getElementById('classification-dialog'); if (dialog) { dialog.classList.add('visible'); } } /** * Hide the classification dialog */ function hideRegionClassificationDialog() { const dialog = document.getElementById('classification-dialog'); if (dialog) { dialog.classList.remove('visible'); } // Clear the current rect if not saved regionDrawingState.currentRect = null; // Redraw canvas const canvas = document.getElementById('region-drawing-canvas'); if (canvas) { const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); drawExistingRegions(ctx); } } /** * Save a user-drawn region with the selected schedule type */ async function saveUserRegion(scheduleType) { if (!regionDrawingState.currentRect || !regionDrawingState.selectedPageNumber) { hideRegionClassificationDialog(); return; } const sessionId = sharedState.session.id || sharedState.session.sessionId; const rect = regionDrawingState.currentRect; // Convert display coordinates to PDF coordinates const boundingBox = { x: Math.round(rect.x * rect.scaleX), y: Math.round(rect.y * rect.scaleY), width: Math.round(rect.width * rect.scaleX), height: Math.round(rect.height * rect.scaleY) }; // Store locally for display const localRegion = { page_number: regionDrawingState.selectedPageNumber, schedule_type: scheduleType, bounding_box: boundingBox, display_x: rect.x, display_y: rect.y, display_width: rect.width, display_height: rect.height, detection_method: 'user_identified', created_at: new Date().toISOString() }; regionDrawingState.userRegions.push(localRegion); // Try to save to API try { const response = await fetch(`${API_BASE_URL}/api/hardware-schedule/session/${sessionId}/candidates`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authState.token}` }, body: JSON.stringify({ page_number: regionDrawingState.selectedPageNumber, schedule_type: scheduleType, bounding_box: boundingBox, detection_method: 'user_identified' }) }); if (response.ok) { Telemetry.logAction('saveUserRegion', `Saved ${scheduleType} region on page ${regionDrawingState.selectedPageNumber}`); } else { console.warn('[HGSE] API save failed, region stored locally'); } } catch (error) { console.warn('[HGSE] API save error, region stored locally:', error.message); } // Hide dialog hideRegionClassificationDialog(); // Redraw canvas with new region const canvas = document.getElementById('region-drawing-canvas'); if (canvas) { const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); drawExistingRegions(ctx); } // Update thumbnail badge const thumb = document.querySelector(`.page-thumbnail[data-page="${regionDrawingState.selectedPageNumber}"]`); if (thumb) { thumb.classList.add('has-regions'); } // Clear current rect regionDrawingState.currentRect = null; } /** * Select entire page as region (quick action) */ function selectEntirePage() { const canvas = document.getElementById('region-drawing-canvas'); const img = document.getElementById('region-page-image'); if (!canvas || !img) return; regionDrawingState.currentRect = { x: 0, y: 0, width: canvas.width, height: canvas.height, scaleX: img.naturalWidth / canvas.width, scaleY: img.naturalHeight / canvas.height }; // Draw the full-page selection const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); drawExistingRegions(ctx); ctx.strokeStyle = '#3B82F6'; ctx.lineWidth = 3; ctx.setLineDash([8, 4]); ctx.strokeRect(0, 0, canvas.width, canvas.height); ctx.fillStyle = 'rgba(59, 130, 246, 0.15)'; ctx.fillRect(0, 0, canvas.width, canvas.height); showRegionClassificationDialog(); } /** * Get user-defined regions for a specific page */ function getUserRegionsForPage(pageNumber) { return regionDrawingState.userRegions.filter(r => r.page_number === pageNumber); } /** * Get all user-defined regions */ function getAllUserRegions() { return regionDrawingState.userRegions; } // =========================== // CH-2026-0122-REGION-002: PDF Viewer Region Selection Overlay // Eliminates server-side preview rendering by using existing PDF viewer canvas // =========================== // Overlay-specific state const regionOverlayState = { active: false, drawing: false, startX: 0, startY: 0, currentRect: null, pageNumber: 1, totalPages: 1, drawingCanvas: null, imageElement: null, // FX-2026-0122-REGION-003: High-res zoom/pan state baseScale: 3.0, // High-res render scale (3x = 216 DPI equivalent) viewportZoom: 1.0, // Additional viewport zoom on top of base minViewportZoom: 0.33, // Minimum viewport zoom (allows zooming out from 3x) maxViewportZoom: 2.0, // Maximum viewport zoom (3x * 2 = 600% effective) zoomStep: 0.25, // Zoom increment per button click isPanMode: false, // Spacebar pan mode active isPanning: false, // Currently dragging to pan panStartX: 0, panStartY: 0, scrollStartX: 0, scrollStartY: 0, // Canvas dimensions for coordinate normalization sourceCanvasWidth: 0, sourceCanvasHeight: 0, // FX-003 Enhancement: Rotation state rotation: 0 // 0, 90, 180, 270 degrees }; // 10E_FXB: Debounce timer for hi-res re-render after zoom settles let renderDebounceTimer = null; /** * FX-2026-0122-REGION-003: Render PDF page at high resolution for region selection * Implements memory safety cap at 4096×4096 pixels * @param {number} pageNumber - Page to render * @param {number} scale - Render scale (default 3.0 = 216 DPI equivalent) * @returns {Promise<{canvas: HTMLCanvasElement, actualScale: number}>} */ async function renderPdfPageHighRes(pageNumber, scale = 3.0) { const MAX_DIMENSION = 8192; // Memory safety cap (raised for 600% zoom) if (!pdfDocument) { throw new Error('PDF document not loaded'); } const page = await pdfDocument.getPage(pageNumber); let viewport = page.getViewport({ scale }); // Calculate actual scale to fit within memory cap let actualScale = scale; if (viewport.width > MAX_DIMENSION || viewport.height > MAX_DIMENSION) { const widthScale = MAX_DIMENSION / (viewport.width / scale); const heightScale = MAX_DIMENSION / (viewport.height / scale); actualScale = Math.min(widthScale, heightScale, scale); viewport = page.getViewport({ scale: actualScale }); console.log(`[Region Hi-Res] Capped scale from ${scale}x to ${actualScale.toFixed(2)}x for memory safety`); } // Create offscreen canvas const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); canvas.width = Math.floor(viewport.width); canvas.height = Math.floor(viewport.height); // Render PDF page to canvas await page.render({ canvasContext: context, viewport: viewport }).promise; console.log(`[Region Hi-Res] Rendered page ${pageNumber} at ${actualScale}x (${canvas.width}×${canvas.height})`); return { canvas, actualScale }; } /** * FX-2026-0122-REGION-003: Update zoom indicator display * Shows effective scale (baseScale × viewportZoom) */ function updateRegionZoomIndicator() { const indicator = document.getElementById('region-zoom-indicator'); if (indicator) { const effectiveZoom = Math.round(regionOverlayState.baseScale * regionOverlayState.viewportZoom * 100); indicator.textContent = `${effectiveZoom}%`; } } /** * 10E_FXB: Hybrid zoom — instant CSS dimensions + debounced hi-res re-render * Replaces transform:scale approach for proper scroll behavior at all zoom levels */ function applyRegionViewportZoom(skipReRender = false) { const img = document.getElementById('region-overlay-page-image'); const canvas = document.getElementById('region-draw-canvas'); const container = document.querySelector('#region-drawing-layer .page-container'); if (!container || !img) return; const zoom = regionOverlayState.viewportZoom; const rotation = regionOverlayState.rotation; const naturalWidth = img.naturalWidth || regionOverlayState.sourceCanvasWidth; const naturalHeight = img.naturalHeight || regionOverlayState.sourceCanvasHeight; // ZOOM affects image size (no rotation swap — image stays undistorted) const scaledWidth = naturalWidth * zoom; const scaledHeight = naturalHeight * zoom; // ROTATION determines container dimensions (for scroll bounds) const isRotated90or270 = (rotation === 90 || rotation === 270); const containerWidth = isRotated90or270 ? scaledHeight : scaledWidth; const containerHeight = isRotated90or270 ? scaledWidth : scaledHeight; // Container gets EXPLICIT dimensions (not min-) to prevent content override container.style.width = containerWidth + 'px'; container.style.height = containerHeight + 'px'; container.style.position = 'relative'; // Image/canvas are positioned absolutely and centered, then rotated // This keeps image undistorted while allowing container to have rotated bounds const imgStyle = ` position: absolute; left: 50%; top: 50%; width: ${scaledWidth}px; height: ${scaledHeight}px; transform: translate(-50%, -50%) rotate(${rotation}deg); transform-origin: center center; `; img.style.cssText = imgStyle; if (canvas) { canvas.style.cssText = imgStyle; } // Clear the CSS variable (no longer using container rotation) container.style.setProperty('--rotation', '0deg'); updateRegionZoomIndicator(); // SETTLED: Debounced re-render for quality (only if zoom changed, not rotation) if (!skipReRender) { clearTimeout(renderDebounceTimer); renderDebounceTimer = setTimeout(() => reRenderAtCurrentZoom(), 400); } } /** * 10E_FXB WS1-T2: Re-render at current effective scale for hi-res quality * Only triggers if zoomed beyond base scale */ async function reRenderAtCurrentZoom() { const effectiveScale = regionOverlayState.baseScale * regionOverlayState.viewportZoom; const maxScale = 10; // Cap at 720 DPI equivalent const targetScale = Math.min(effectiveScale, maxScale); // Skip re-render if at or near optimal scale (avoid unnecessary work) if (targetScale <= regionOverlayState.baseScale * 1.1) { console.log('[Region Hi-Res] Skip re-render — at/near optimal scale'); return; } showReRenderIndicator(); regionOverlayState.isReRendering = true; // Prevent onload from re-initializing console.log(`[Region Hi-Res] Re-rendering at ${targetScale.toFixed(1)}x...`); try { const { canvas: hiResCanvas, actualScale } = await renderPdfPageHighRes( regionOverlayState.pageNumber, targetScale ); const img = document.getElementById('region-overlay-page-image'); const drawCanvas = document.getElementById('region-draw-canvas'); // Preserve scroll position as percentage before dimension change const drawingLayer = document.getElementById('region-drawing-layer'); const scrollPctX = drawingLayer.scrollWidth > 0 ? drawingLayer.scrollLeft / drawingLayer.scrollWidth : 0; const scrollPctY = drawingLayer.scrollHeight > 0 ? drawingLayer.scrollTop / drawingLayer.scrollHeight : 0; // Update image with hi-res render — wait for load before continuing // This ensures original onload handler sees isReRendering=true and skips img.src = hiResCanvas.toDataURL('image/png'); await new Promise(resolve => { const handler = () => { img.removeEventListener('load', handler); resolve(); }; img.addEventListener('load', handler); }); // Now safe to update state — original onload already fired and skipped regionOverlayState.baseScale = actualScale; regionOverlayState.viewportZoom = 1.0; regionOverlayState.sourceCanvasWidth = hiResCanvas.width; regionOverlayState.sourceCanvasHeight = hiResCanvas.height; // Update drawing canvas buffer dimensions (not CSS — that's handled by applyRegionViewportZoom) if (drawCanvas) { drawCanvas.width = hiResCanvas.width; drawCanvas.height = hiResCanvas.height; } // Let applyRegionViewportZoom handle all CSS dimensions (rotation-aware) // Clear debounce first to prevent re-triggering re-render clearTimeout(renderDebounceTimer); applyRegionViewportZoom(); // Cancel any debounced re-render it may have triggered (we just finished one) clearTimeout(renderDebounceTimer); // Restore scroll position drawingLayer.scrollLeft = scrollPctX * drawingLayer.scrollWidth; drawingLayer.scrollTop = scrollPctY * drawingLayer.scrollHeight; regionOverlayState.isReRendering = false; // Clear flag after onload handled hideReRenderIndicator(); console.log(`[Region Hi-Res] Re-rendered at ${actualScale.toFixed(1)}x (${hiResCanvas.width}×${hiResCanvas.height})`); } catch (e) { regionOverlayState.isReRendering = false; // Clear flag on error console.error('[Region Hi-Res] Re-render failed:', e); hideReRenderIndicator(); } } /** * 10E_FXB WS1-T3: Show loading indicator during hi-res re-render */ function showReRenderIndicator() { let indicator = document.querySelector('.rerender-indicator'); if (!indicator) { indicator = document.createElement('div'); indicator.className = 'rerender-indicator'; indicator.innerHTML = ' Enhancing...'; const overlay = document.getElementById('region-selector-overlay'); if (overlay) overlay.appendChild(indicator); } indicator.classList.add('visible'); } /** * 10E_FXB WS1-T3: Hide loading indicator after re-render */ function hideReRenderIndicator() { const indicator = document.querySelector('.rerender-indicator'); if (indicator) { indicator.classList.remove('visible'); } } /** * FX-003 Enhancement: Rotate view clockwise 90° */ function regionRotateCW() { console.log('[Region Rotate] CW clicked, current:', regionOverlayState.rotation); regionOverlayState.rotation = (regionOverlayState.rotation + 90) % 360; applyRegionViewportZoom(true); // skipReRender — rotation is visual only // QF-001: Re-fit after rotation since container dimensions swap setTimeout(() => regionFitToView(true), 50); // Also skip re-render in fit console.log('[Region Rotate] CW applied, new:', regionOverlayState.rotation); } /** * FX-003 Enhancement: Rotate view counter-clockwise 90° */ function regionRotateCCW() { console.log('[Region Rotate] CCW clicked, current:', regionOverlayState.rotation); regionOverlayState.rotation = (regionOverlayState.rotation - 90 + 360) % 360; applyRegionViewportZoom(true); // skipReRender — rotation is visual only // QF-001: Re-fit after rotation since container dimensions swap setTimeout(() => regionFitToView(true), 50); // Also skip re-render in fit console.log('[Region Rotate] CCW applied, new:', regionOverlayState.rotation); } /** * FX-003 Enhancement: Transform coordinates from rotated view back to original PDF orientation */ function transformToOriginalOrientation(rect, canvasWidth, canvasHeight, rotation) { if (rotation === 0) return { ...rect, refWidth: canvasWidth, refHeight: canvasHeight }; const r = rect; switch (rotation) { case 90: return { x: r.y, y: canvasWidth - r.x - r.width, width: r.height, height: r.width, refWidth: canvasHeight, refHeight: canvasWidth }; case 180: return { x: canvasWidth - r.x - r.width, y: canvasHeight - r.y - r.height, width: r.width, height: r.height, refWidth: canvasWidth, refHeight: canvasHeight }; case 270: return { x: canvasHeight - r.y - r.height, y: r.x, width: r.height, height: r.width, refWidth: canvasHeight, refHeight: canvasWidth }; default: return { ...rect, refWidth: canvasWidth, refHeight: canvasHeight }; } } /** * FX-2026-0122-REGION-003: Zoom in region selector */ function regionZoomIn() { regionOverlayState.viewportZoom = Math.min( regionOverlayState.maxViewportZoom, regionOverlayState.viewportZoom + regionOverlayState.zoomStep ); applyRegionViewportZoom(); console.log(`[Region Zoom] In: ${Math.round(regionOverlayState.viewportZoom * 100)}% viewport, ${Math.round(regionOverlayState.baseScale * regionOverlayState.viewportZoom * 100)}% effective`); } /** * FX-2026-0122-REGION-003: Zoom out region selector */ function regionZoomOut() { regionOverlayState.viewportZoom = Math.max( regionOverlayState.minViewportZoom, regionOverlayState.viewportZoom - regionOverlayState.zoomStep ); applyRegionViewportZoom(); console.log(`[Region Zoom] Out: ${Math.round(regionOverlayState.viewportZoom * 100)}% viewport, ${Math.round(regionOverlayState.baseScale * regionOverlayState.viewportZoom * 100)}% effective`); } /** * FX-2026-0122-REGION-003: Reset zoom to base scale (100% viewport = 300% effective) */ function regionResetZoom() { regionOverlayState.viewportZoom = 1.0; applyRegionViewportZoom(); // Reset scroll position const drawingLayer = document.getElementById('region-drawing-layer'); if (drawingLayer) { drawingLayer.scrollTop = 0; drawingLayer.scrollLeft = 0; } console.log('[Region Zoom] Reset to 100% viewport (300% effective)'); } /** * FX-2026-0122-REGION-003: Fit to view (calculate zoom to fit container) * @param {boolean} skipReRender - If true, don't trigger hi-res re-render (for rotation) */ function regionFitToView(skipReRender = false) { const drawingLayer = document.getElementById('region-drawing-layer'); const container = document.querySelector('#region-drawing-layer .page-container'); if (!drawingLayer || !container) return; // Get container's natural dimensions (at 1.0 viewport zoom) const tempZoom = regionOverlayState.viewportZoom; container.style.transform = 'scale(1)'; const naturalWidth = container.offsetWidth; const naturalHeight = container.offsetHeight; // QF-2026-0123-REGION-001: Clear inline transform so CSS custom properties apply container.style.transform = ''; // Calculate zoom to fit const availableWidth = drawingLayer.clientWidth - 40; // padding const availableHeight = drawingLayer.clientHeight - 40; const fitZoom = Math.min( availableWidth / naturalWidth, availableHeight / naturalHeight, regionOverlayState.maxViewportZoom ); regionOverlayState.viewportZoom = Math.max(regionOverlayState.minViewportZoom, fitZoom); applyRegionViewportZoom(skipReRender); // Pass through skipReRender console.log(`[Region Zoom] Fit to view: ${Math.round(regionOverlayState.viewportZoom * 100)}% viewport`); } /** * FX-2026-0122-REGION-003: Initialize wheel event navigation for touchpad support * 10E_FXB WS3: Alt+wheel horizontal scroll, scroll-during-selection support * Windows Precision Touchpad: Pinch = wheel + ctrlKey, Two-finger scroll = wheel deltaX/deltaY */ function initRegionWheelNavigation(container) { container.addEventListener('wheel', (e) => { // WS3-T1: Alt + Wheel → Horizontal scroll if (e.altKey) { e.preventDefault(); container.scrollLeft += e.deltaY; return; } // Ctrl + Wheel → Zoom (even during selection per WS3-T2) if (e.ctrlKey) { e.preventDefault(); const zoomDelta = e.deltaY > 0 ? -0.1 : 0.1; regionOverlayState.viewportZoom = Math.max( regionOverlayState.minViewportZoom, Math.min(regionOverlayState.maxViewportZoom, regionOverlayState.viewportZoom + zoomDelta) ); applyRegionViewportZoom(); return; } // Plain wheel → Let browser handle naturally (enables scroll-during-selection WS3-T2) // No preventDefault, no manual handling — browser does the right thing }, { passive: false }); console.log('[Region Wheel] Initialized wheel navigation (Alt+wheel=horiz, Ctrl+wheel=zoom)'); } /** * FX-2026-0122-REGION-003: Initialize pan mode (spacebar + drag) */ function initRegionPanMode() { const drawingLayer = document.getElementById('region-drawing-layer'); if (!drawingLayer) return; // Spacebar toggle for pan mode document.addEventListener('keydown', (e) => { if (e.code === 'Space' && regionOverlayState.active && !regionOverlayState.isPanMode) { e.preventDefault(); regionOverlayState.isPanMode = true; drawingLayer.classList.add('pan-mode'); showPanHint('Pan mode - drag to move view'); } }); document.addEventListener('keyup', (e) => { if (e.code === 'Space' && regionOverlayState.active) { regionOverlayState.isPanMode = false; regionOverlayState.isPanning = false; drawingLayer.classList.remove('pan-mode'); hidePanHint(); } }); // Mouse drag for panning when in pan mode drawingLayer.addEventListener('mousedown', (e) => { if (regionOverlayState.isPanMode) { e.preventDefault(); regionOverlayState.isPanning = true; regionOverlayState.panStartX = e.clientX; regionOverlayState.panStartY = e.clientY; regionOverlayState.scrollStartX = drawingLayer.scrollLeft; regionOverlayState.scrollStartY = drawingLayer.scrollTop; } }); drawingLayer.addEventListener('mousemove', (e) => { if (regionOverlayState.isPanning && regionOverlayState.isPanMode) { const dx = e.clientX - regionOverlayState.panStartX; const dy = e.clientY - regionOverlayState.panStartY; drawingLayer.scrollLeft = regionOverlayState.scrollStartX - dx; drawingLayer.scrollTop = regionOverlayState.scrollStartY - dy; } }); drawingLayer.addEventListener('mouseup', () => { regionOverlayState.isPanning = false; }); drawingLayer.addEventListener('mouseleave', () => { regionOverlayState.isPanning = false; }); console.log('[Region Pan] Initialized spacebar + drag pan mode'); } /** * FX-2026-0122-REGION-003: Show pan hint tooltip */ function showPanHint(message) { let hint = document.querySelector('.pan-hint'); if (!hint) { hint = document.createElement('div'); hint.className = 'pan-hint'; document.getElementById('region-selector-overlay').appendChild(hint); } hint.textContent = message; hint.classList.add('visible'); } /** * FX-2026-0122-REGION-003: Hide pan hint tooltip */ function hidePanHint() { const hint = document.querySelector('.pan-hint'); if (hint) { hint.classList.remove('visible'); } } /** * 10E_FXB WS5: Keyboard shortcuts for accessibility * +/- zoom, PageUp/Down nav, Enter confirm, Escape clear/close, R rotate */ function initRegionKeyboardShortcuts() { const handler = (e) => { if (!regionOverlayState.active) return; // Don't interfere with spacebar (pan mode handled elsewhere) if (e.code === 'Space') return; switch (e.key) { case '+': case '=': e.preventDefault(); regionZoomIn(); break; case '-': e.preventDefault(); regionZoomOut(); break; case 'PageUp': e.preventDefault(); regionNavPrevPage(); break; case 'PageDown': e.preventDefault(); regionNavNextPage(); break; case 'Enter': if (regionOverlayState.currentRect) { e.preventDefault(); confirmRegionSelectionOverlay(); } break; case 'Escape': e.preventDefault(); if (regionOverlayState.currentRect) { // First Escape: clear selection clearRegionSelection(); } else { // Second Escape: close overlay closeRegionSelectorOverlay(); } break; case 'r': if (!e.shiftKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); regionRotateCW(); } break; case 'R': if (e.shiftKey && !e.ctrlKey && !e.altKey) { e.preventDefault(); regionRotateCCW(); } break; } }; document.addEventListener('keydown', handler); // Store handler reference for cleanup regionOverlayState.keyboardHandler = handler; console.log('[Region Keyboard] Initialized shortcuts (+/- zoom, PgUp/Dn nav, Enter/Esc, R rotate)'); } /** * 10E_FXB WS5: Remove keyboard shortcuts when overlay closes */ function removeRegionKeyboardShortcuts() { if (regionOverlayState.keyboardHandler) { document.removeEventListener('keydown', regionOverlayState.keyboardHandler); regionOverlayState.keyboardHandler = null; } } /** * FX-2026-0122-REGION-003: Normalize coordinates to percentages (0.0-1.0) * @param {Object} rect - Rectangle {x, y, width, height} in canvas pixels * @param {number} canvasWidth - Source canvas width * @param {number} canvasHeight - Source canvas height * @returns {Object} Normalized coordinates {x_percent, y_percent, width_percent, height_percent} */ function normalizeCoordinates(rect, canvasWidth, canvasHeight) { return { x_percent: parseFloat((rect.x / canvasWidth).toFixed(6)), y_percent: parseFloat((rect.y / canvasHeight).toFixed(6)), width_percent: parseFloat((rect.width / canvasWidth).toFixed(6)), height_percent: parseFloat((rect.height / canvasHeight).toFixed(6)) }; } /** * Open the PDF viewer region selector overlay * CH-2026-0122-REGION-002: Uses existing PDF canvas instead of server-side previews * FX-2026-0122-REGION-003: Now renders at high resolution with zoom/pan support */ async function openRegionSelectorOverlay() { const sessionId = sharedState.session?.id || sharedState.session?.sessionId; if (!sessionId || sessionId.startsWith('session_')) { showCpsToast('Please upload a PDF first', 'error'); return; } // FX-2026-0122-REGION-003: Check if PDF document is loaded for high-res render if (!pdfDocument) { showCpsToast('PDF document not ready. Please wait for PDF to load.', 'error'); return; } // Initialize state regionOverlayState.pageNumber = sharedState.session?.currentPage || 1; regionOverlayState.totalPages = sharedState.session?.totalPages || 1; regionOverlayState.currentRect = null; regionOverlayState.active = true; regionOverlayState.viewportZoom = 1.0; // Reset zoom regionOverlayState.isPanMode = false; regionOverlayState.isPanning = false; regionOverlayState.rotation = sharedState.rotation || 0; // Sync with main viewer rotation // 27A: Auto-focus on first detected schedule page (once per session) if (sharedState.session?.detectedSchedulePages && sharedState.session.detectedSchedulePages.length > 0 && !sharedState.session.hasAutoFocused) { const firstDetected = sharedState.session.detectedSchedulePages[0]; if (firstDetected && firstDetected.page) { regionOverlayState.pageNumber = firstDetected.page; console.log(`[27A] Auto-focusing region selector on detected schedule page ${firstDetected.page} (${firstDetected.title})`); } sharedState.session.hasAutoFocused = true; } // Build the drawing layer content const drawingLayer = document.getElementById('region-drawing-layer'); if (!drawingLayer) return; drawingLayer.innerHTML = ''; drawingLayer.classList.add('highres-mode'); // Enable high-res scrollable mode // Show loading state showCpsToast('Rendering high-resolution view...', 'info'); try { // FX-2026-0122-REGION-003: Render PDF at high resolution const { canvas: hiResCanvas, actualScale } = await renderPdfPageHighRes( regionOverlayState.pageNumber, regionOverlayState.baseScale ); regionOverlayState.baseScale = actualScale; // Store actual scale (may be capped) regionOverlayState.sourceCanvasWidth = hiResCanvas.width; regionOverlayState.sourceCanvasHeight = hiResCanvas.height; const container = document.createElement('div'); container.className = 'page-container'; // Create image from high-res canvas const img = document.createElement('img'); img.id = 'region-overlay-page-image'; img.src = hiResCanvas.toDataURL('image/png'); // Create drawing canvas overlay const drawCanvas = document.createElement('canvas'); drawCanvas.id = 'region-draw-canvas'; img.onload = () => { // 10E_FXB: Skip re-init if this is a hi-res re-render (not initial load) if (regionOverlayState.isReRendering) { console.log('[Region Hi-Res] Skipping re-init (re-render in progress)'); return; } // Size drawing canvas to match image (at full resolution) drawCanvas.width = img.naturalWidth; drawCanvas.height = img.naturalHeight; drawCanvas.style.width = img.naturalWidth + 'px'; drawCanvas.style.height = img.naturalHeight + 'px'; regionOverlayState.imageElement = img; regionOverlayState.drawingCanvas = drawCanvas; initOverlayDrawingHandlers(drawCanvas, img); // FX-2026-0122-REGION-003: Initialize wheel navigation for touchpad initRegionWheelNavigation(drawingLayer); // Update zoom indicator updateRegionZoomIndicator(); // Apply initial fit-to-view zoom setTimeout(() => regionFitToView(), 100); console.log(`[Region Hi-Res] Initialized on page ${regionOverlayState.pageNumber} at ${actualScale}x (${hiResCanvas.width}×${hiResCanvas.height})`); }; container.appendChild(img); container.appendChild(drawCanvas); drawingLayer.appendChild(container); } catch (error) { console.error('[Region Hi-Res] Failed to render:', error); showCpsToast('Failed to render high-resolution view: ' + error.message, 'error'); return; } // Update page indicator updateRegionOverlayPageIndicator(); // Reset confirm button document.getElementById('region-confirm-btn').disabled = true; // Show overlay document.getElementById('region-selector-overlay').classList.add('visible'); // FX-2026-0122-REGION-003: Initialize pan mode initRegionPanMode(); // 10E_FXB WS5: Initialize keyboard shortcuts initRegionKeyboardShortcuts(); Telemetry.logAction('openRegionSelectorOverlay', `Opened on page ${regionOverlayState.pageNumber} at ${regionOverlayState.baseScale}x`); } /** * Close the region selector overlay */ function closeRegionSelectorOverlay() { const overlay = document.getElementById('region-selector-overlay'); if (overlay) { overlay.classList.remove('visible'); } // FX-2026-0122-REGION-003: Reset high-res mode and zoom state const drawingLayer = document.getElementById('region-drawing-layer'); if (drawingLayer) { drawingLayer.classList.remove('highres-mode'); drawingLayer.classList.remove('pan-mode'); } regionOverlayState.active = false; regionOverlayState.currentRect = null; regionOverlayState.drawing = false; regionOverlayState.isPanMode = false; regionOverlayState.isPanning = false; regionOverlayState.viewportZoom = 1.0; // 10E_FXB WS5: Remove keyboard shortcuts removeRegionKeyboardShortcuts(); // Reset confirm button const confirmBtn = document.getElementById('region-confirm-btn'); if (confirmBtn) confirmBtn.disabled = true; // 26M: Reset targeted mode if active if (reExtractState.active && !reExtractState.pendingNewGroup) { // Only reset if we haven't received extraction results yet // (if we have results, the comparison UI is showing) resetRegionSelectorFromTargetedMode(); reExtractState.active = false; } // CH-2026-0212-QUEUE-001: Reset queue mode if active if (extractionQueueState.returnFromOverlay) { resetRegionSelectorFromQueueMode(); extractionQueueState.returnFromOverlay = false; extractionQueueState.currentConfigIndex = null; } Telemetry.logAction('closeRegionSelectorOverlay', 'Closed region selector overlay'); } /** * Initialize drawing handlers for the overlay canvas * FX-2026-0122-REGION-003: Skip drawing when in pan mode */ /** * 10E_FXB: Convert screen coordinates to canvas buffer coordinates * Accounts for CSS rotation transform — getBoundingClientRect() returns * rotated bounds but canvas buffer is unrotated */ function getCanvasCoordinates(e, canvas) { const rect = canvas.getBoundingClientRect(); const rotation = regionOverlayState.rotation; const bufferWidth = canvas.width; const bufferHeight = canvas.height; // Mouse position relative to rotated bounding box const relX = e.clientX - rect.left; const relY = e.clientY - rect.top; // Un-rotate coordinates to buffer space let bufferX, bufferY; switch (rotation) { case 0: bufferX = (relX / rect.width) * bufferWidth; bufferY = (relY / rect.height) * bufferHeight; break; case 90: // Rotated 90° CW: visual X maps to buffer Y (inverted), visual Y maps to buffer X bufferX = (relY / rect.height) * bufferWidth; bufferY = ((rect.width - relX) / rect.width) * bufferHeight; break; case 180: // Rotated 180°: both axes inverted bufferX = ((rect.width - relX) / rect.width) * bufferWidth; bufferY = ((rect.height - relY) / rect.height) * bufferHeight; break; case 270: // Rotated 270° CW (90° CCW): visual Y maps to buffer X (inverted), visual X maps to buffer Y bufferX = ((rect.height - relY) / rect.height) * bufferWidth; bufferY = (relX / rect.width) * bufferHeight; break; default: bufferX = (relX / rect.width) * bufferWidth; bufferY = (relY / rect.height) * bufferHeight; } return { x: bufferX, y: bufferY }; } function initOverlayDrawingHandlers(canvas, img) { const ctx = canvas.getContext('2d'); canvas.onmousedown = (e) => { // FX-2026-0122-REGION-003: Skip drawing when in pan mode if (regionOverlayState.isPanMode) return; regionOverlayState.drawing = true; const coords = getCanvasCoordinates(e, canvas); regionOverlayState.startX = coords.x; regionOverlayState.startY = coords.y; }; canvas.onmousemove = (e) => { if (!regionOverlayState.drawing || regionOverlayState.isPanMode) return; // FX-003 Enhancement: Auto-pan when drawing near viewport edges const container = document.getElementById('region-drawing-layer'); if (container && regionOverlayState.drawing) { const containerRect = container.getBoundingClientRect(); const edgeThreshold = 50; const scrollSpeed = 15; if (e.clientY - containerRect.top < edgeThreshold) { container.scrollTop -= scrollSpeed; } if (containerRect.bottom - e.clientY < edgeThreshold) { container.scrollTop += scrollSpeed; } if (e.clientX - containerRect.left < edgeThreshold) { container.scrollLeft -= scrollSpeed; } if (containerRect.right - e.clientX < edgeThreshold) { container.scrollLeft += scrollSpeed; } } const coords = getCanvasCoordinates(e, canvas); const currentX = coords.x; const currentY = coords.y; // Clear and redraw ctx.clearRect(0, 0, canvas.width, canvas.height); // Calculate normalized rectangle const x = Math.min(regionOverlayState.startX, currentX); const y = Math.min(regionOverlayState.startY, currentY); const w = Math.abs(currentX - regionOverlayState.startX); const h = Math.abs(currentY - regionOverlayState.startY); // Draw selection rectangle ctx.strokeStyle = '#3B82F6'; ctx.lineWidth = 3; ctx.setLineDash([8, 4]); ctx.strokeRect(x, y, w, h); // Semi-transparent fill ctx.fillStyle = 'rgba(59, 130, 246, 0.15)'; ctx.fillRect(x, y, w, h); regionOverlayState.currentRect = { x, y, width: w, height: h }; }; canvas.onmouseup = () => { regionOverlayState.drawing = false; const rect = regionOverlayState.currentRect; // Enable confirm button if selection is large enough (min 20x20 pixels) const confirmBtn = document.getElementById('region-confirm-btn'); if (rect && rect.width > 20 && rect.height > 20) { confirmBtn.disabled = false; console.log('[Region Overlay] Selection:', rect); } else { confirmBtn.disabled = true; } }; // FX-003 Fix: Don't abort drawing on mouseleave - allow re-entry canvas.onmouseleave = () => { // Keep drawing state - user can re-enter canvas to continue // Only finalize on mouseup }; } /** * FX-003 Enhancement: Clear current selection */ function clearRegionSelection() { const canvas = document.getElementById('region-draw-canvas'); if (canvas) { const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); } regionOverlayState.currentRect = null; regionOverlayState.drawing = false; const confirmBtn = document.getElementById('region-confirm-btn'); if (confirmBtn) confirmBtn.disabled = true; } // FX-003 Fix: Global mouseup to finalize drawing even when released outside canvas document.addEventListener('mouseup', () => { if (regionOverlayState.active && regionOverlayState.drawing) { regionOverlayState.drawing = false; const rect = regionOverlayState.currentRect; const confirmBtn = document.getElementById('region-confirm-btn'); if (confirmBtn) { confirmBtn.disabled = !(rect && rect.width > 20 && rect.height > 20); } } }); /** * Update the page indicator in the overlay toolbar */ function updateRegionOverlayPageIndicator() { const indicator = document.getElementById('region-page-indicator'); if (indicator) { indicator.textContent = `Page ${regionOverlayState.pageNumber} of ${regionOverlayState.totalPages}`; indicator.style.display = ''; // 27A: Ensure indicator visible (reset from page input mode) } // 27A: Hide page input if it was open during navigation const pageInput = document.getElementById('region-page-input'); if (pageInput) pageInput.style.display = 'none'; const prevBtn = document.getElementById('region-prev-page'); const nextBtn = document.getElementById('region-next-page'); if (prevBtn) prevBtn.disabled = regionOverlayState.pageNumber <= 1; if (nextBtn) nextBtn.disabled = regionOverlayState.pageNumber >= regionOverlayState.totalPages; } /** * Navigate to previous page in region selector */ async function regionNavPrevPage() { if (regionOverlayState.pageNumber > 1) { regionOverlayState.pageNumber--; await navigateAndRefreshOverlay(); } } /** * Navigate to next page in region selector */ async function regionNavNextPage() { if (regionOverlayState.pageNumber < regionOverlayState.totalPages) { regionOverlayState.pageNumber++; await navigateAndRefreshOverlay(); } } /** * Navigate PDF viewer to new page and refresh the overlay */ async function navigateAndRefreshOverlay() { // Update main PDF viewer if (typeof goToPage === 'function') { await goToPage(regionOverlayState.pageNumber); } // Wait for PDF render to complete, then refresh overlay setTimeout(() => { refreshOverlayCanvas(); }, 400); } /** * Refresh the overlay canvas with current PDF page * FX-2026-0122-REGION-003: Now uses high-res render */ async function refreshOverlayCanvas() { if (!pdfDocument) return; const img = document.getElementById('region-overlay-page-image'); const drawCanvas = document.getElementById('region-draw-canvas'); const container = document.querySelector('#region-drawing-layer .page-container'); if (img && drawCanvas) { try { // FX-2026-0122-REGION-003: Render at high resolution const { canvas: hiResCanvas, actualScale } = await renderPdfPageHighRes( regionOverlayState.pageNumber, regionOverlayState.baseScale ); regionOverlayState.baseScale = actualScale; regionOverlayState.sourceCanvasWidth = hiResCanvas.width; regionOverlayState.sourceCanvasHeight = hiResCanvas.height; img.src = hiResCanvas.toDataURL('image/png'); img.onload = () => { drawCanvas.width = img.naturalWidth; drawCanvas.height = img.naturalHeight; drawCanvas.style.width = img.naturalWidth + 'px'; drawCanvas.style.height = img.naturalHeight + 'px'; // Clear any existing selection const ctx = drawCanvas.getContext('2d'); ctx.clearRect(0, 0, drawCanvas.width, drawCanvas.height); regionOverlayState.currentRect = null; document.getElementById('region-confirm-btn').disabled = true; initOverlayDrawingHandlers(drawCanvas, img); // Reset viewport zoom and rotation, then fit to view regionOverlayState.viewportZoom = 1.0; regionOverlayState.rotation = 0; // FX-003: Reset rotation on page change applyRegionViewportZoom(); setTimeout(() => regionFitToView(), 100); console.log(`[Region Hi-Res] Refreshed page ${regionOverlayState.pageNumber}`); }; } catch (e) { console.error('[Region Hi-Res] Failed to refresh canvas:', e); } } updateRegionOverlayPageIndicator(); } /** * Select entire page as region in overlay */ function selectEntirePageOverlay() { const canvas = document.getElementById('region-draw-canvas'); if (!canvas) return; regionOverlayState.currentRect = { x: 0, y: 0, width: canvas.width, height: canvas.height }; // Draw full-page selection const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.strokeStyle = '#3B82F6'; ctx.lineWidth = 3; ctx.setLineDash([8, 4]); ctx.strokeRect(2, 2, canvas.width - 4, canvas.height - 4); ctx.fillStyle = 'rgba(59, 130, 246, 0.15)'; ctx.fillRect(2, 2, canvas.width - 4, canvas.height - 4); document.getElementById('region-confirm-btn').disabled = false; console.log('[Region Overlay] Entire page selected'); } /** * Confirm the region selection and save to API * FX-2026-0122-REGION-003: Now includes normalized coordinates (bounding_box_percent) */ async function confirmRegionSelectionOverlay() { // CH-2026-0212-QUEUE-001: If in queue mode, delegate to queue confirm handler if (extractionQueueState.returnFromOverlay && extractionQueueState.currentConfigIndex !== null) { return confirmQueueRegionConfig(); } const rect = regionOverlayState.currentRect; const scheduleType = document.getElementById('region-schedule-type-select').value; const sessionId = sharedState.session?.id || sharedState.session?.sessionId; if (!rect || !sessionId) { showCpsToast('Invalid selection', 'error'); return; } // FX-2026-0122-REGION-003: Calculate normalized coordinates (0.0-1.0) const canvasWidth = regionOverlayState.sourceCanvasWidth || regionOverlayState.drawingCanvas?.width || 1; const canvasHeight = regionOverlayState.sourceCanvasHeight || regionOverlayState.drawingCanvas?.height || 1; // FX-003 Enhancement: Transform from rotated view back to original PDF orientation let rectForNormalization = rect; let normWidth = canvasWidth; let normHeight = canvasHeight; if (regionOverlayState.rotation !== 0) { const transformed = transformToOriginalOrientation( rect, canvasWidth, canvasHeight, regionOverlayState.rotation ); rectForNormalization = { x: transformed.x, y: transformed.y, width: transformed.width, height: transformed.height }; normWidth = transformed.refWidth; normHeight = transformed.refHeight; } const normalizedCoords = normalizeCoordinates(rectForNormalization, normWidth, normHeight); // Disable button to prevent double-submit const confirmBtn = document.getElementById('region-confirm-btn'); confirmBtn.disabled = true; confirmBtn.textContent = 'Saving...'; try { const response = await fetch(`${API_BASE_URL}/api/hardware-schedule/session/${sessionId}/candidates`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${authState.token}` }, body: JSON.stringify({ page_number: regionOverlayState.pageNumber, schedule_type: scheduleType, bounding_box: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) }, // FX-2026-0122-REGION-003: DPI-independent normalized coordinates bounding_box_percent: normalizedCoords, detection_method: 'user_drawn', render_scale: regionOverlayState.baseScale // Store the render scale used }) }); if (!response.ok) { const errorText = await response.text(); throw new Error(`Failed to save region: ${response.status} ${errorText}`); } const result = await response.json(); console.log('[Region Overlay] Candidate created:', result); // Also store locally for display regionDrawingState.userRegions.push({ page_number: regionOverlayState.pageNumber, schedule_type: scheduleType, bounding_box: rect, detection_method: 'user_drawn', created_at: new Date().toISOString() }); closeRegionSelectorOverlay(); showCpsToast('Region saved successfully!', 'success'); // Refresh candidates list if the function exists if (typeof loadCandidates === 'function') { loadCandidates(sessionId); } Telemetry.logAction('confirmRegionSelection', `Saved ${scheduleType} region on page ${regionOverlayState.pageNumber}`); } catch (error) { console.error('[Region Overlay] Save error:', error); showCpsToast('Failed to save region: ' + error.message, 'error'); confirmBtn.disabled = false; } finally { confirmBtn.textContent = 'Confirm Region'; } } // END CH-2026-0122-REGION-002 // =========================== // Close region selector on Escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { // CH-2026-0122-REGION-002: Check new overlay first const regionOverlay = document.getElementById('region-selector-overlay'); if (regionOverlay && regionOverlay.classList.contains('visible')) { closeRegionSelectorOverlay(); return; } const classificationDialog = document.getElementById('classification-dialog'); if (classificationDialog && classificationDialog.classList.contains('visible')) { hideRegionClassificationDialog(); } else { const regionModal = document.getElementById('region-selector-modal'); if (regionModal && regionModal.classList.contains('visible')) { closeRegionSelector(); } } } });
    [SubX Assistant]

    Select Schedule Region

    Or drag to draw a rectangle around the schedule region
    Page preview

    What type of schedule is this?

    Select Schedule Region
    300%

    Cut Sheet Review

    Loading cut sheet...