Spaces:
Configuration error
Configuration error
| /* ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| ParcelPilot Intelligence β Application Controller | |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */ | |
| ; | |
| /* βββ State βββ */ | |
| let pendingAction = null; | |
| let evalScenarios = []; | |
| let currentUser = {}; | |
| let matrixLoaded = false; | |
| /* βββ Boot βββ */ | |
| document.addEventListener('DOMContentLoaded', () => { | |
| initContextSwitcher(); | |
| initTabs(); | |
| initComposer(); | |
| initSubtabs(); | |
| prefetchScenarios(); | |
| initExportLog(); | |
| // Pre-fetch proactive insights so badge shows immediately | |
| loadProactiveInsights(); | |
| }); | |
| /* ββββββββββββββββββββββββββββββββββββββββ | |
| CONTEXT SWITCHER | |
| ββββββββββββββββββββββββββββββββββββββββ */ | |
| function initContextSwitcher() { | |
| const modeEl = document.getElementById('context-mode-select'); | |
| const accountEl = document.getElementById('account-select'); | |
| const roleEl = document.getElementById('role-select'); | |
| const update = () => { | |
| currentUser = buildContext(); | |
| updateContextIndicator(); | |
| // Re-fetch proactive when context changes (if on that tab) | |
| if (document.getElementById('tab-proactive').classList.contains('active')) { | |
| loadProactiveInsights(); | |
| } | |
| }; | |
| modeEl.addEventListener('change', update); | |
| accountEl.addEventListener('change', update); | |
| roleEl.addEventListener('change', update); | |
| update(); | |
| } | |
| function buildContext() { | |
| const mode = document.getElementById('context-mode-select').value; | |
| const accountId = document.getElementById('account-select').value; | |
| const role = document.getElementById('role-select').value; | |
| const is_internal = mode === 'internal'; | |
| const accountNames = { | |
| 'ACCT-001': 'Northstar Logistics', | |
| 'ACCT-002': 'LumenWorks', | |
| 'ACCT-003': 'Beacon Retail', | |
| 'ACCT-004': 'Axis Labs', | |
| }; | |
| return { | |
| account_id: accountId, | |
| account_name: accountNames[accountId] || accountId, | |
| is_internal, | |
| role: is_internal ? role : 'customer', | |
| user_id: is_internal ? 'USR-STAFF-99' : `USR-${accountId}`, | |
| }; | |
| } | |
| function updateContextIndicator() { | |
| const el = document.getElementById('active-context-indicator'); | |
| if (!el) return; | |
| const ctx = currentUser; | |
| if (ctx.is_internal) { | |
| el.textContent = `Internal Β· ${ctx.role.replace(/_/g, ' ')}`; | |
| el.style.color = '#22d3ee'; | |
| } else { | |
| el.textContent = `${ctx.account_name} Β· Customer`; | |
| el.style.color = '#34d399'; | |
| } | |
| } | |
| /* ββββββββββββββββββββββββββββββββββββββββ | |
| TAB ROUTING | |
| ββββββββββββββββββββββββββββββββββββββββ */ | |
| function initTabs() { | |
| document.querySelectorAll('.navtab[data-tab]').forEach(btn => { | |
| btn.addEventListener('click', () => activateTab(btn.dataset.tab)); | |
| }); | |
| } | |
| function activateTab(tabId) { | |
| document.querySelectorAll('.navtab').forEach(b => b.classList.remove('active')); | |
| document.querySelectorAll('.stage-pane').forEach(p => p.classList.remove('active')); | |
| const btn = document.querySelector(`.navtab[data-tab="${tabId}"]`); | |
| const pane = document.getElementById(tabId); | |
| if (btn) btn.classList.add('active'); | |
| if (pane) pane.classList.add('active'); | |
| if (tabId === 'tab-proactive') loadProactiveInsights(); | |
| if (tabId === 'tab-matrix') loadContractMatrix(); | |
| if (tabId === 'tab-data') loadExplorer('subtab-documents'); | |
| } | |
| /* ββββββββββββββββββββββββββββββββββββββββ | |
| EVALUATOR PRESETS | |
| ββββββββββββββββββββββββββββββββββββββββ */ | |
| async function prefetchScenarios() { | |
| try { | |
| const res = await fetch('/api/evaluator/scenarios'); | |
| evalScenarios = await res.json(); | |
| } catch (e) { | |
| console.warn('Could not load scenarios:', e); | |
| } | |
| } | |
| function onPresetSelectChange(sel) { | |
| const id = sel.value; | |
| if (!id) return; | |
| sel.value = ''; | |
| runScenario(id); | |
| } | |
| function runScenario(id) { | |
| const s = evalScenarios.find(x => x.id === id); | |
| if (!s) return; | |
| document.getElementById('context-mode-select').value = s.is_internal ? 'internal' : 'customer'; | |
| document.getElementById('account-select').value = s.account_id; | |
| document.getElementById('role-select').value = s.role; | |
| document.getElementById('context-mode-select').dispatchEvent(new Event('change')); | |
| activateTab('tab-chat'); | |
| document.getElementById('chat-input').value = s.prompt; | |
| sendMessage(); | |
| } | |
| /* ββββββββββββββββββββββββββββββββββββββββ | |
| CHAT COMPOSER | |
| ββββββββββββββββββββββββββββββββββββββββ */ | |
| function initComposer() { | |
| const input = document.getElementById('chat-input'); | |
| const sendBtn = document.getElementById('send-btn'); | |
| sendBtn.addEventListener('click', sendMessage); | |
| input.addEventListener('keydown', e => { | |
| const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0; | |
| const modKey = isMac ? e.metaKey : e.ctrlKey; | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } else if (e.key === 'Enter' && modKey) { | |
| e.preventDefault(); | |
| sendMessage(); | |
| } | |
| }); | |
| input.addEventListener('input', () => autoResizeTextarea(input)); | |
| } | |
| function autoResizeTextarea(el) { | |
| el.style.height = 'auto'; | |
| el.style.height = Math.min(el.scrollHeight, 140) + 'px'; | |
| } | |
| async function sendMessage() { | |
| const input = document.getElementById('chat-input'); | |
| const text = input.value.trim(); | |
| if (!text) return; | |
| input.value = ''; | |
| input.style.height = 'auto'; | |
| appendMsg('user', text); | |
| const loadingId = appendTyping(); | |
| try { | |
| const res = await fetch('/api/chat', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ prompt: text, ...currentUser }), | |
| }); | |
| const data = await res.json(); | |
| removeMsg(loadingId); | |
| if (!res.ok) { | |
| appendMsg('assistant', `Error ${res.status}: ${data.detail || 'Request failed.'}`); | |
| return; | |
| } | |
| // Update latency/confidence display | |
| if (data.metrics) { | |
| const ms = data.metrics.total_duration_ms; | |
| const pct = Math.round(data.metrics.confidence_score * 100); | |
| document.getElementById('metrics-summary-text').textContent = | |
| `${ms}ms Β· ${pct}% confidence`; | |
| } | |
| appendMsg('assistant', data.answer, data.citations, data.widget_data); | |
| updateEvidencePanel(data.citations, data.conflict_matrix, data.trace_steps); | |
| if (data.pending_action) { | |
| pendingAction = data.pending_action; | |
| openActionModal(data.pending_action); | |
| } | |
| } catch (err) { | |
| removeMsg(loadingId); | |
| appendMsg('assistant', 'Network error β could not reach the ParcelPilot backend. Is the server running?'); | |
| console.error(err); | |
| } | |
| } | |
| /* ββββββββββββββββββββββββββββββββββββββββ | |
| MESSAGE RENDERING | |
| ββββββββββββββββββββββββββββββββββββββββ */ | |
| function appendMsg(role, text, citations = [], widgetData = null) { | |
| const area = document.getElementById('chat-messages'); | |
| const row = document.createElement('div'); | |
| row.className = `msg-row msg-${role}`; | |
| const avatar = document.createElement('div'); | |
| avatar.className = 'msg-avatar'; | |
| avatar.setAttribute('aria-hidden', 'true'); | |
| avatar.innerHTML = role === 'user' | |
| ? `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="6" r="3" stroke="rgba(148,163,184,0.8)" stroke-width="1.2"/><path d="M2 14c0-3.3 2.7-5 6-5s6 1.7 6 5" stroke="rgba(148,163,184,0.8)" stroke-width="1.2"/></svg>` | |
| : `<svg width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" fill="rgba(56,189,248,0.2)" stroke="rgba(56,189,248,0.55)" stroke-width="1"/><circle cx="8" cy="8" r="2.5" fill="#38bdf8"/></svg>`; | |
| const bubble = document.createElement('div'); | |
| bubble.className = 'msg-bubble'; | |
| if (role === 'assistant') { | |
| const nameEl = document.createElement('p'); | |
| nameEl.className = 'msg-name'; | |
| nameEl.textContent = 'ParcelPilot Intelligence'; | |
| bubble.appendChild(nameEl); | |
| } | |
| const textNode = document.createElement('div'); | |
| textNode.innerHTML = renderMarkdown(text); | |
| bubble.appendChild(textNode); | |
| // Widget card | |
| if (widgetData && widgetData.type !== 'action_pending') { | |
| const wc = renderWidgetCard(widgetData); | |
| if (wc) bubble.appendChild(wc); | |
| } | |
| // Citations | |
| if (citations && citations.length > 0) { | |
| const citeBlock = document.createElement('div'); | |
| citeBlock.className = 'chat-citations'; | |
| const label = document.createElement('div'); | |
| label.className = 'chat-citations-label'; | |
| label.textContent = 'Source Citations'; | |
| citeBlock.appendChild(label); | |
| citations.forEach(c => { | |
| const cr = document.createElement('div'); | |
| cr.className = 'chat-citation-row'; | |
| cr.innerHTML = `<span class="citation-dot"></span><span>${escHtml(c.source)} <span class="text-muted">(${escHtml(c.authority_level)})</span></span>`; | |
| citeBlock.appendChild(cr); | |
| }); | |
| bubble.appendChild(citeBlock); | |
| } | |
| row.appendChild(avatar); | |
| row.appendChild(bubble); | |
| area.appendChild(row); | |
| area.scrollTop = area.scrollHeight; | |
| } | |
| function renderWidgetCard(data) { | |
| if (!data) return null; | |
| const card = document.createElement('div'); | |
| card.className = 'widget-card'; | |
| if (data.type === 'order_cancellation_widget') { | |
| card.innerHTML = ` | |
| <div class="widget-header"> | |
| <span class="widget-header-left">Order Cancellation Analysis Β· ${escHtml(data.order_id)}</span> | |
| <span class="pill pill-gray">${escHtml(data.account_name)}</span> | |
| </div> | |
| <div class="widget-grid"> | |
| <div class="widget-cell"> | |
| <span class="widget-cell-label">Order Status</span> | |
| <span class="widget-cell-value">${escHtml(data.order_status)}</span> | |
| </div> | |
| <div class="widget-cell"> | |
| <span class="widget-cell-label">Time Since Booking</span> | |
| <span class="widget-cell-value">${data.elapsed_minutes} min</span> | |
| </div> | |
| <div class="widget-cell"> | |
| <span class="widget-cell-label">SOP v4 Default Fee</span> | |
| <span class="widget-cell-value strikethrough">INR ${data.standard_fee_inr}</span> | |
| </div> | |
| <div class="widget-cell"> | |
| <span class="widget-cell-label">Final Fee</span> | |
| <span class="widget-cell-value ${data.fee_waived ? 'positive' : 'negative'}">INR ${data.final_fee_inr}${data.fee_waived ? ' β Waived' : ''}</span> | |
| </div> | |
| </div> | |
| <div class="widget-footer">${escHtml(data.governing_document)}</div> | |
| `; | |
| } else if (data.type === 'service_credit_widget') { | |
| const delayVal = data.delay_hours !== null && data.delay_hours !== undefined | |
| ? `${data.delay_hours}h` : 'N/A'; | |
| card.innerHTML = ` | |
| <div class="widget-header"> | |
| <span class="widget-header-left">Service Credit Evaluation Β· ${escHtml(data.order_id)}</span> | |
| <span class="pill pill-gray">${escHtml(data.account_name)}</span> | |
| </div> | |
| <div class="widget-grid"> | |
| <div class="widget-cell"> | |
| <span class="widget-cell-label">Pickup Delay</span> | |
| <span class="widget-cell-value">${delayVal}</span> | |
| </div> | |
| <div class="widget-cell"> | |
| <span class="widget-cell-label">Required Threshold</span> | |
| <span class="widget-cell-value">> ${data.required_threshold_hours}h</span> | |
| </div> | |
| <div class="widget-cell"> | |
| <span class="widget-cell-label">Eligible</span> | |
| <span class="widget-cell-value ${data.eligible ? 'positive' : 'negative'}">${data.eligible ? 'Yes' : 'No β Below Threshold'}</span> | |
| </div> | |
| <div class="widget-cell"> | |
| <span class="widget-cell-label">Credit Amount</span> | |
| <span class="widget-cell-value ${data.eligible ? 'positive' : ''}">INR ${data.credit_amount_inr}</span> | |
| </div> | |
| </div> | |
| <div class="widget-footer">${escHtml(data.governing_document)}</div> | |
| `; | |
| } | |
| return card.innerHTML ? card : null; | |
| } | |
| function appendTyping() { | |
| const id = `typing-${Date.now()}`; | |
| const area = document.getElementById('chat-messages'); | |
| const row = document.createElement('div'); | |
| row.id = id; | |
| row.className = 'msg-row msg-assistant msg-typing'; | |
| row.innerHTML = ` | |
| <div class="msg-avatar" aria-hidden="true"> | |
| <svg width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="8" cy="8" r="6" fill="rgba(56,189,248,0.2)" stroke="rgba(56,189,248,0.55)" stroke-width="1"/><circle cx="8" cy="8" r="2.5" fill="#38bdf8"/></svg> | |
| </div> | |
| <div class="msg-bubble"> | |
| <span class="typing-dot"></span> | |
| <span class="typing-dot"></span> | |
| <span class="typing-dot"></span> | |
| </div> | |
| `; | |
| area.appendChild(row); | |
| area.scrollTop = area.scrollHeight; | |
| return id; | |
| } | |
| function removeMsg(id) { | |
| const el = document.getElementById(id); | |
| if (el) el.remove(); | |
| } | |
| /* ββββββββββββββββββββββββββββββββββββββββ | |
| EVIDENCE PANEL | |
| ββββββββββββββββββββββββββββββββββββββββ */ | |
| function updateEvidencePanel(citations, conflictMatrix, traceSteps) { | |
| // Show anchor card filled state | |
| const anchorCard = document.getElementById('anchor-card'); | |
| const anchorEmpty = document.getElementById('anchor-empty'); | |
| const anchorFilled = document.getElementById('anchor-filled'); | |
| if (citations && citations.length > 0) { | |
| const top = citations[0]; | |
| if (anchorEmpty) anchorEmpty.style.display = 'none'; | |
| if (anchorFilled) anchorFilled.style.display = ''; | |
| if (anchorCard) anchorCard.classList.remove('anchor-card--empty'); | |
| const badgeEl = document.getElementById('anchor-badge'); | |
| const filenameEl = document.getElementById('anchor-filename'); | |
| const quoteEl = document.getElementById('anchor-quote'); | |
| const metaEl = document.getElementById('anchor-meta'); | |
| if (badgeEl) badgeEl.textContent = top.authority_level || 'Level 4 Β· Signed Contract'; | |
| if (filenameEl) filenameEl.textContent = shortDocName(top.source); | |
| if (quoteEl) quoteEl.textContent = top.relevance || 'Primary governing clause evaluated.'; | |
| if (metaEl) metaEl.textContent = `Source: ${top.source}`; | |
| } | |
| // Precedence matrix | |
| const matrixEl = document.getElementById('conflict-matrix-list'); | |
| if (matrixEl && conflictMatrix && conflictMatrix.length > 0) { | |
| matrixEl.innerHTML = ''; | |
| conflictMatrix.forEach(m => { | |
| const statusClass = resolveStatusClass(m.status); | |
| const statusLabel = formatStatus(m.status); | |
| const row = document.createElement('div'); | |
| row.className = `matrix-row ${statusClass}`; | |
| row.innerHTML = ` | |
| <div class="matrix-level-bar"></div> | |
| <div class="matrix-row-content"> | |
| <div class="matrix-row-header"> | |
| <span class="matrix-source-name">${escHtml(shortDocName(m.source_name))}</span> | |
| <span class="matrix-authority">${escHtml(m.authority_level)}</span> | |
| </div> | |
| <div class="matrix-rule">${escHtml(m.rule_stated)}</div> | |
| <div class="matrix-status-chip">${escHtml(statusLabel)}</div> | |
| </div> | |
| `; | |
| matrixEl.appendChild(row); | |
| }); | |
| } | |
| // Execution trace | |
| const traceBody = document.getElementById('dag-body'); | |
| const traceCount = document.getElementById('trace-count'); | |
| if (traceBody && traceSteps && traceSteps.length > 0) { | |
| traceCount.textContent = `${traceSteps.length} steps`; | |
| traceBody.innerHTML = ''; | |
| traceSteps.forEach(s => { | |
| const step = document.createElement('div'); | |
| step.className = 'trace-step'; | |
| step.innerHTML = ` | |
| <div class="trace-step-header"> | |
| <span class="trace-step-name">Step ${s.step_id}: ${escHtml(s.name || '')}</span> | |
| <span class="trace-step-ms">${s.duration_ms ?? 'β'}ms</span> | |
| </div> | |
| <div class="trace-step-detail">${escHtml(s.details || s.status || '')}</div> | |
| `; | |
| traceBody.appendChild(step); | |
| }); | |
| } | |
| } | |
| function resolveStatusClass(status = '') { | |
| const s = status.toUpperCase(); | |
| if (s.includes('OVERRIDING_WINNER') || s.includes('WINNER')) return 'status-winner'; | |
| if (s.includes('APPLIED') || s.includes('CONTRACT')) return 'status-applied'; | |
| if (s.includes('OVERRIDDEN') || s.includes('REPLACED') || s.includes('DEFAULT')) return 'status-overridden'; | |
| if (s.includes('ERROR') || s.includes('DISREGARD')) return 'status-error'; | |
| if (s.includes('EXCLUDED') || s.includes('NOT_APPLICABLE')) return 'status-excluded'; | |
| return 'status-applied'; | |
| } | |
| function formatStatus(status = '') { | |
| return status | |
| .replace(/_/g, ' ') | |
| .toLowerCase() | |
| .replace(/\b\w/g, c => c.toUpperCase()); | |
| } | |
| /* ββββββββββββββββββββββββββββββββββββββββ | |
| ACTION MODAL | |
| ββββββββββββββββββββββββββββββββββββββββ */ | |
| function openActionModal(action) { | |
| document.getElementById('modal-action-title').textContent = action.action_title || 'System Action'; | |
| document.getElementById('modal-action-details').textContent = JSON.stringify(action.parameters || {}, null, 2); | |
| document.getElementById('action-modal').classList.remove('hidden'); | |
| } | |
| function closeActionModal() { | |
| document.getElementById('action-modal').classList.add('hidden'); | |
| } | |
| async function confirmAction(confirmed) { | |
| closeActionModal(); | |
| if (!pendingAction) return; | |
| const loadingId = appendTyping(); | |
| try { | |
| const res = await fetch('/api/confirm', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| action_id: pendingAction.action_id, | |
| confirmed, | |
| ...currentUser, | |
| }), | |
| }); | |
| const data = await res.json(); | |
| removeMsg(loadingId); | |
| appendMsg('assistant', confirmed | |
| ? `**Confirmed.** ${data.message || 'Action executed successfully.'}` | |
| : 'Action declined β no changes were applied to the system.' | |
| ); | |
| } catch (err) { | |
| removeMsg(loadingId); | |
| appendMsg('assistant', 'Failed to record confirmation response.'); | |
| } | |
| pendingAction = null; | |
| } | |
| /* ββββββββββββββββββββββββββββββββββββββββ | |
| PROACTIVE OPS RADAR | |
| ββββββββββββββββββββββββββββββββββββββββ */ | |
| async function loadProactiveInsights() { | |
| const ctx = currentUser; | |
| const badge = document.getElementById('proactive-alert-count'); | |
| if (badge) badge.classList.add('loading'); | |
| const url = `/api/proactive/insights?account_id=${ctx.account_id}&is_internal=${ctx.is_internal}&role=${ctx.role}`; | |
| try { | |
| const res = await fetch(url); | |
| const data = await res.json(); | |
| if (badge) { | |
| badge.classList.remove('loading'); | |
| badge.textContent = data.total_alerts || '0'; | |
| } | |
| if (data.access_restricted) { | |
| ['list-sla-breaches','list-security','list-clusters','list-carrier'].forEach(id => { | |
| const el = document.getElementById(id); | |
| if (el) el.innerHTML = `<div class="radar-empty">${escHtml(data.message || 'Access restricted.')}</div>`; | |
| }); | |
| ['count-sla-breaches','count-security','count-clusters','count-carrier'].forEach(id => { | |
| const el = document.getElementById(id); | |
| if (el) el.textContent = '0'; | |
| }); | |
| return; | |
| } | |
| // SLA Breaches | |
| setStat('count-sla-breaches', data.sla_breaches.length); | |
| renderList('list-sla-breaches', data.sla_breaches.map(b => ({ | |
| id: b.ticket_id, | |
| pillClass:'pill-rose', | |
| badgeText: b.overdue_by_minutes > 0 ? `${b.overdue_by_minutes}m overdue` : 'Approaching', | |
| secondary: b.severity, | |
| subject: b.subject, | |
| source: b.rule_source, | |
| action: { label: 'Escalate β', scenarioId: 'scenario-4' } | |
| }))); | |
| // Security | |
| setStat('count-security', data.security_alerts.length); | |
| renderList('list-security', data.security_alerts.map(s => ({ | |
| id: s.ticket_id, | |
| pillClass:'pill-amber', | |
| badgeText:'Critical', | |
| secondary: null, | |
| subject: s.description, | |
| source: s.recommended_action, | |
| }))); | |
| // Clusters | |
| setStat('count-clusters', data.ticket_clusters.length); | |
| renderList('list-clusters', data.ticket_clusters.map(c => ({ | |
| id: c.known_issue_id, | |
| pillClass:'pill-cyan', | |
| badgeText:`${c.affected_tickets.length} tickets`, | |
| secondary: null, | |
| subject: c.issue_title, | |
| source: `Workaround: ${c.workaround}`, | |
| }))); | |
| // Carrier | |
| setStat('count-carrier', data.carrier_delays.length); | |
| renderList('list-carrier', data.carrier_delays.map(car => ({ | |
| id: car.order_id, | |
| pillClass:'pill-violet', | |
| badgeText:`${car.delay_hours}h delay`, | |
| secondary: null, | |
| subject: `${car.carrier} β ${car.issue_summary}`, | |
| source: car.recommended_action, | |
| }))); | |
| } catch (err) { | |
| console.error('Proactive insights error:', err); | |
| if (badge) badge.classList.remove('loading'); | |
| } | |
| } | |
| function setStat(id, count) { | |
| const el = document.getElementById(id); | |
| if (el) el.textContent = String(count); | |
| } | |
| function renderList(listId, items) { | |
| const el = document.getElementById(listId); | |
| if (!el) return; | |
| if (!items || items.length === 0) { | |
| el.innerHTML = '<div class="radar-empty">No active issues detected.</div>'; | |
| return; | |
| } | |
| el.innerHTML = items.map(item => ` | |
| <div class="alert-item"> | |
| <div class="alert-item-header"> | |
| <span class="alert-item-id pill ${escHtml(item.pillClass)}">${escHtml(item.id)}</span> | |
| ${item.secondary ? `<span class="pill ${escHtml(item.pillClass)}">${escHtml(item.secondary)}</span>` : ''} | |
| <span class="pill ${escHtml(item.pillClass)}" style="margin-left:auto">${escHtml(item.badgeText)}</span> | |
| </div> | |
| <div class="alert-item-subject">${escHtml(item.subject)}</div> | |
| <div class="alert-item-source">${escHtml(item.source)}</div> | |
| ${item.action | |
| ? `<button class="alert-action-btn" onclick="runScenario('${escHtml(item.action.scenarioId)}')">${escHtml(item.action.label)}</button>` | |
| : ''} | |
| </div> | |
| `).join(''); | |
| } | |
| /* ββββββββββββββββββββββββββββββββββββββββ | |
| CONTRACT MATRIX | |
| ββββββββββββββββββββββββββββββββββββββββ */ | |
| async function loadContractMatrix() { | |
| if (matrixLoaded) return; // Only fetch once | |
| const grid = document.getElementById('contract-matrix-grid'); | |
| grid.innerHTML = '<div class="radar-empty" style="padding:20px">Loading contractsβ¦</div>'; | |
| try { | |
| const res = await fetch('/api/data/compare-contracts'); | |
| const data = await res.json(); | |
| grid.innerHTML = data.map(item => { | |
| const planPill = item.plan === 'Enterprise' | |
| ? `<span class="pill pill-blue">${item.plan}</span>` | |
| : item.plan === 'Growth' | |
| ? `<span class="pill pill-emerald">${item.plan}</span>` | |
| : `<span class="pill pill-gray">${item.plan}</span>`; | |
| const hasContract = item.governing_contract !== 'None (Standard Policy Applies)' | |
| && item.governing_contract !== 'None (Standard Enterprise Policy Applies)'; | |
| return ` | |
| <div class="contract-card"> | |
| <div class="contract-card-header"> | |
| <span class="contract-card-name"> | |
| ${escHtml(item.account_name)} | |
| <span style="color:var(--c-txt-3);font-size:11px;font-weight:400">(${escHtml(item.account_id)})</span> | |
| </span> | |
| <div style="display:flex;gap:6px;align-items:center"> | |
| ${planPill} | |
| ${hasContract ? `<span class="pill pill-emerald" style="font-size:9px">Custom Contract</span>` : ''} | |
| </div> | |
| </div> | |
| <div class="contract-card-body"> | |
| <div class="contract-row"> | |
| <div class="contract-row-label">Agreement</div> | |
| <div class="contract-row-value muted">${escHtml(item.governing_contract)}</div> | |
| </div> | |
| <div class="contract-row"> | |
| <div class="contract-row-label">P1 SLA Target</div> | |
| <div class="contract-row-value"><strong>${escHtml(item.p1_sla)}</strong></div> | |
| </div> | |
| <div class="contract-row"> | |
| <div class="contract-row-label">Cancellation Rule</div> | |
| <div class="contract-row-value highlight">${escHtml(item.cancellation_rule)}</div> | |
| </div> | |
| <div class="contract-row"> | |
| <div class="contract-row-label">Service Credit Rule</div> | |
| <div class="contract-row-value">${escHtml(item.service_credit_rule)}</div> | |
| </div> | |
| </div> | |
| <div class="contract-note">${escHtml(item.precedence_notes)}</div> | |
| </div> | |
| `; | |
| }).join(''); | |
| matrixLoaded = true; | |
| } catch (err) { | |
| grid.innerHTML = `<div class="radar-empty">Failed to load contract matrix.</div>`; | |
| } | |
| } | |
| /* ββββββββββββββββββββββββββββββββββββββββ | |
| DATA EXPLORER | |
| ββββββββββββββββββββββββββββββββββββββββ */ | |
| function initSubtabs() { | |
| document.querySelectorAll('.subtab').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| document.querySelectorAll('.subtab').forEach(b => b.classList.remove('active')); | |
| btn.classList.add('active'); | |
| loadExplorer(btn.dataset.subtab); | |
| }); | |
| }); | |
| } | |
| async function loadExplorer(subtabId) { | |
| const container = document.getElementById('explorer-content'); | |
| container.innerHTML = '<div class="radar-empty" style="padding:24px;">Loadingβ¦</div>'; | |
| const endpoints = { | |
| 'subtab-documents': '/api/data/documents', | |
| 'subtab-accounts': '/api/data/accounts', | |
| 'subtab-orders': '/api/data/orders', | |
| 'subtab-tickets': '/api/data/tickets', | |
| }; | |
| const endpoint = endpoints[subtabId]; | |
| if (!endpoint) return; | |
| const ctx = currentUser; | |
| const url = `${endpoint}?account_id=${ctx.account_id}&is_internal=${ctx.is_internal}&role=${ctx.role}`; | |
| try { | |
| const res = await fetch(url); | |
| const data = await res.json(); | |
| if (!data || data.length === 0) { | |
| container.innerHTML = '<div class="radar-empty" style="padding:24px;">No records accessible under current context permissions.</div>'; | |
| return; | |
| } | |
| const keys = Object.keys(data[0]); | |
| const table = document.createElement('table'); | |
| table.className = 'data-table'; | |
| table.innerHTML = ` | |
| <thead> | |
| <tr>${keys.map(k => `<th>${escHtml(k.replace(/_/g,' '))}</th>`).join('')}</tr> | |
| </thead> | |
| <tbody> | |
| ${data.map(row => | |
| `<tr>${keys.map(k => `<td>${escHtml(String(row[k] ?? 'β'))}</td>`).join('')}</tr>` | |
| ).join('')} | |
| </tbody> | |
| `; | |
| container.innerHTML = ''; | |
| container.appendChild(table); | |
| } catch (err) { | |
| container.innerHTML = '<div class="radar-empty" style="padding:24px;">Failed to load data.</div>'; | |
| } | |
| } | |
| /* ββββββββββββββββββββββββββββββββββββββββ | |
| MARKDOWN & UTILITIES | |
| ββββββββββββββββββββββββββββββββββββββββ */ | |
| function renderMarkdown(text) { | |
| if (!text) return ''; | |
| // Escape first, then apply markdown | |
| let t = text | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>'); | |
| t = t | |
| .replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') | |
| .replace(/\*(.+?)\*/g, '<em>$1</em>') | |
| .replace(/`([^`]+)`/g, `<code style="font-family:var(--font-mono);font-size:11.5px;background:rgba(255,255,255,0.07);padding:1px 5px;border-radius:3px;color:#22d3ee;">$1</code>`) | |
| .replace(/^### (.+)$/gm, '<h4 style="font-size:14px;font-weight:700;margin:12px 0 6px;letter-spacing:-0.01em;color:#f1f5f9">$1</h4>') | |
| .replace(/^#### (.+)$/gm, '<h5 style="font-size:12.5px;font-weight:700;margin:9px 0 4px;color:#cbd5e1">$1</h5>') | |
| .replace(/^---$/gm, '<hr style="border:none;border-top:1px solid rgba(255,255,255,0.08);margin:10px 0">') | |
| .replace(/^- (.+)$/gm, '<li style="margin:3px 0;padding-left:4px;list-style:none;display:flex;gap:8px"><span style="color:#475569;flex-shrink:0">β</span><span>$1</span></li>') | |
| .replace(/^\d+\. (.+)$/gm, '<li style="margin:3px 0;list-style:decimal;margin-left:18px">$1</li>') | |
| .replace(/\n\n/g, '</p><p style="margin-top:7px">'); | |
| return `<p>${t}</p>`; | |
| } | |
| function escHtml(str) { | |
| return String(str) | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| .replace(/'/g, '''); | |
| } | |
| function shortDocName(name) { | |
| if (!name) return 'β'; | |
| return name | |
| .replace(/^\d+_/, '') | |
| .replace(/\.pdf$/i, '') | |
| .replace(/_/g, ' ') | |
| .trim(); | |
| } | |
| /* ββββββββββββββββββββββββββββββββββββββββ | |
| EXPORT LOG | |
| ββββββββββββββββββββββββββββββββββββββββ */ | |
| function initExportLog() { | |
| const btn = document.getElementById('export-log-btn'); | |
| if (btn) btn.addEventListener('click', exportAuditLog); | |
| } | |
| function exportAuditLog() { | |
| const dateStr = new Date().toISOString().slice(0, 19).replace(/:/g, '-'); | |
| const filename = `ParcelPilot_Audit_Log_${currentUser.account_id}_${dateStr}.txt`; | |
| let content = `=================================================================\n`; | |
| content += `PARCELPILOT AI OPERATIONS - AUDIT LOG\n`; | |
| content += `Generated: ${new Date().toISOString()}\n`; | |
| content += `Context: ${currentUser.account_name} (${currentUser.account_id}) | Role: ${currentUser.role}\n`; | |
| content += `=================================================================\n\n`; | |
| const messages = document.querySelectorAll('.msg-row'); | |
| if (messages.length === 0) { | |
| content += `No conversation history available in current session.\n`; | |
| } else { | |
| messages.forEach(msg => { | |
| const isUser = msg.classList.contains('msg-user'); | |
| const author = isUser ? currentUser.account_name : 'ParcelPilot AI'; | |
| // Get main text | |
| let text = ''; | |
| if (isUser) { | |
| text = msg.querySelector('.msg-bubble').textContent.trim(); | |
| } else { | |
| // Strip out citations text and widget text for cleaner log | |
| const bubble = msg.cloneNode(true); | |
| const widget = bubble.querySelector('.widget-card'); | |
| const citations = bubble.querySelector('.chat-citations'); | |
| if (widget) widget.remove(); | |
| if (citations) citations.remove(); | |
| text = bubble.querySelector('.msg-bubble').textContent.replace('ParcelPilot Intelligence', '').trim(); | |
| } | |
| content += `[${author}]\n${text}\n\n`; | |
| }); | |
| } | |
| const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |