(() => { let ws = null; let reconnectTimer = null; let reconnectDisabledForAuth = false; function normalizeToken(value) { const token = String(value || '').trim(); if (!token) return ''; if (token.toLowerCase().startsWith('bearer ')) { return token.slice(7).trim(); } return token; } function getCookie(name) { const cookie = document.cookie || ''; const parts = cookie.split(';').map(p => p.trim()); const match = parts.find(p => p.startsWith(`${name}=`)); if (!match) return ''; return decodeURIComponent(match.slice(name.length + 1)); } function getToken() { const fromLocal = normalizeToken(localStorage.getItem('access_token')); if (fromLocal) return fromLocal; const fromSession = normalizeToken(sessionStorage.getItem('access_token')); if (fromSession) return fromSession; const fromCookie = normalizeToken(getCookie('access_token')); return fromCookie; } async function hasActiveSession() { try { const res = await fetch('/api/v1/auth/me', { credentials: 'include', headers: { 'Accept': 'application/json' } }); return res.ok; } catch (_) { return false; } } function ensureContainer() { let container = document.getElementById('telefoni-toast-container'); if (!container) { container = document.createElement('div'); container.id = 'telefoni-toast-container'; container.setAttribute('aria-live', 'polite'); container.setAttribute('aria-atomic', 'true'); container.style.cssText = ` position: fixed; top: 20px; right: 20px; z-index: 9999; width: 420px; max-width: 90%; `; document.body.appendChild(container); } return container; } function escapeHtml(str) { return String(str ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function normalizeNamePart(value) { return String(value || '') .trim() .toLocaleLowerCase('da-DK') .replace(/\s+/g, ' '); } function normalizeDigits(value) { return String(value || '').replace(/\D+/g, ''); } function buildToastContact(contactData) { if (!contactData) return null; const firstName = String(contactData.first_name || '').trim(); const lastName = String(contactData.last_name || '').trim(); const name = String(contactData.name || `${firstName} ${lastName}` || '').trim(); const primaryCompany = Array.isArray(contactData.companies) && contactData.companies.length > 0 ? contactData.companies[0] : null; return { id: contactData.id, first_name: firstName, last_name: lastName, name, company: primaryCompany?.name || contactData.company || '', company_id: primaryCompany?.id || contactData.company_id || null, phone: contactData.phone || null, mobile: contactData.mobile || null, }; } function formatCaseLabel(caseItem) { if (!caseItem) return ''; const title = String(caseItem.titel || caseItem.title || `Sag #${caseItem.id || ''}`).trim(); const customer = String(caseItem.customer_name || '').trim(); return customer ? `${title} · ${customer}` : title; } function formatContactLabel(contactItem) { if (!contactItem) return ''; const firstName = String(contactItem.first_name || '').trim(); const lastName = String(contactItem.last_name || '').trim(); const name = [firstName, lastName].filter(Boolean).join(' ') || `Kontakt #${contactItem.id || ''}`; const companyNames = Array.isArray(contactItem.company_names) ? contactItem.company_names.filter(Boolean) : []; return companyNames.length ? `${name} · ${companyNames[0]}` : name; } async function fetchContactByName(firstName, lastName, phoneValue = '') { const fullName = [String(firstName || '').trim(), String(lastName || '').trim()] .filter(Boolean) .join(' '); const digits = normalizeDigits(phoneValue); if (!fullName && !digits) return null; const queries = []; if (fullName) queries.push(fullName); if (digits && digits !== fullName) queries.push(phoneValue || digits); const contactMap = new Map(); for (const query of queries) { const qs = new URLSearchParams({ search: query, limit: '25' }); const res = await fetch(`/api/v1/contacts?${qs.toString()}`, { credentials: 'include' }); if (!res.ok) { const text = await res.text(); throw new Error(text || `HTTP ${res.status}`); } const payload = await res.json(); const contacts = Array.isArray(payload?.contacts) ? payload.contacts : []; contacts.forEach((item) => { if (item?.id) contactMap.set(Number(item.id), item); }); } const contacts = Array.from(contactMap.values()); const wantedFirst = normalizeNamePart(firstName); const wantedLast = normalizeNamePart(lastName); const wantedDigits = normalizeDigits(phoneValue); const exactNameMatch = contacts.find((item) => { return wantedFirst && normalizeNamePart(item?.first_name) === wantedFirst && normalizeNamePart(item?.last_name) === wantedLast; }); if (exactNameMatch) return exactNameMatch; if (wantedDigits) { const exactNumberMatch = contacts.find((item) => { const numbers = [item?.phone, item?.mobile].map(normalizeDigits).filter(Boolean); return numbers.some((number) => number === wantedDigits || number.endsWith(wantedDigits) || wantedDigits.endsWith(number)); }); if (exactNumberMatch) return exactNumberMatch; } return contacts[0] || null; } async function fetchFullContact(contactId) { const res = await fetch(`/api/v1/contacts/${Number(contactId)}`, { credentials: 'include' }); if (!res.ok) { const text = await res.text(); throw new Error(text || `HTTP ${res.status}`); } return res.json(); } async function fetchContactCases(contactId) { const res = await fetch(`/api/v1/contacts/${Number(contactId)}/cases`, { credentials: 'include' }); if (!res.ok) { const text = await res.text(); throw new Error(text || `HTTP ${res.status}`); } return res.json(); } async function searchCases(query) { const qs = new URLSearchParams({ q: String(query || '').trim() }); const res = await fetch(`/api/v1/search/sag?${qs.toString()}`, { credentials: 'include' }); if (!res.ok) { const text = await res.text(); throw new Error(text || `HTTP ${res.status}`); } const payload = await res.json(); return Array.isArray(payload) ? payload : []; } function showIncomingCallToast(data) { const container = ensureContainer(); let currentData = { ...data }; let contact = currentData.contact || null; const number = data.number || ''; const callId = data.call_id; const toastEl = document.createElement('div'); toastEl.className = 'toast align-items-stretch'; toastEl.setAttribute('role', 'alert'); toastEl.setAttribute('aria-live', 'assertive'); toastEl.setAttribute('aria-atomic', 'true'); function closeSmartCaseModal() { const existing = document.getElementById('telefoni-smart-case-modal'); if (existing) existing.remove(); document.querySelectorAll('[data-telefoni-backdrop]').forEach((el) => el.remove()); document.body.classList.remove('modal-open'); } function openCaseSmartModal() { closeSmartCaseModal(); const modal = document.createElement('div'); modal.id = 'telefoni-smart-case-modal'; modal.className = 'modal fade show'; modal.style.display = 'block'; modal.setAttribute('tabindex', '-1'); modal.setAttribute('aria-modal', 'true'); modal.setAttribute('role', 'dialog'); modal.innerHTML = ` `; document.body.appendChild(modal); const backdrop = document.createElement('div'); backdrop.className = 'modal-backdrop fade show'; backdrop.dataset.telefoniBackdrop = '1'; document.body.appendChild(backdrop); document.body.classList.add('modal-open'); // ── helpers ────────────────────────────────────────────────────── const showPane = (paneName) => { modal.querySelectorAll('.tab-pane-holder').forEach((pane) => { pane.classList.toggle('d-none', pane.dataset.pane !== paneName); }); modal.querySelectorAll('[data-tab-target]').forEach((tabBtn) => { tabBtn.classList.toggle('active', tabBtn.dataset.tabTarget === paneName); }); }; const renderCaseList = (pane, items, emptyText) => { const paneEl = modal.querySelector(`[data-pane="${pane}"]`); if (!paneEl) return; if (!items || items.length === 0) { paneEl.innerHTML = `
${escapeHtml(emptyText)}
`; return; } paneEl.innerHTML = `
${items.map((item) => { const caseId = Number(item.id); const label = formatCaseLabel(item); const meta = [item.status, item.created_at ? new Date(item.created_at).toLocaleDateString('da-DK') : ''] .filter(Boolean).join(' · '); return ` `; }).join('')}
`; paneEl.querySelectorAll('[data-case-id]').forEach((button) => { button.addEventListener('click', async () => { const caseId = Number(button.getAttribute('data-case-id')); await patchCallCase(caseId); closeSmartCaseModal(); window.location.href = `/sag/${caseId}/v3`; }); }); }; const renderRelatedContacts = (relatedContacts) => { const paneEl = modal.querySelector('[data-pane="related"]'); if (!paneEl) return; if (!relatedContacts.length) { paneEl.innerHTML = '
Ingen relaterede kontakter fundet
'; return; } paneEl.innerHTML = `
${relatedContacts.map((item) => { const contactId = Number(item.id); const label = formatContactLabel(item); const phone = String(item.phone || item.mobile || '').trim(); return ` `; }).join('')}
`; paneEl.querySelectorAll('[data-contact-id]').forEach((button) => { button.addEventListener('click', () => { const cid = Number(button.getAttribute('data-contact-id')); if (cid > 0) window.location.href = `/contacts/${cid}`; }); }); }; // ── søg-fane ───────────────────────────────────────────────────── const searchInput = modal.querySelector('[data-role="case-search-input"]'); const searchResults = modal.querySelector('[data-role="search-results"]'); const searchHint = modal.querySelector('[data-role="search-hint"]'); let searchTimer = null; let searchToken = 0; const runSearch = async (query) => { const token = ++searchToken; const q = String(query || '').trim(); if (q.length < 2) { searchHint.textContent = 'Skriv mindst 2 tegn for at søge.'; searchResults.innerHTML = ''; return; } searchHint.innerHTML = 'Søger...'; searchResults.innerHTML = ''; try { const results = await searchCases(q); if (token !== searchToken) return; if (!results.length) { searchHint.textContent = 'Ingen sager fundet'; return; } searchHint.textContent = `Fandt ${results.length} sager`; searchResults.innerHTML = results.map((item) => { const caseId = Number(item.id); const label = formatCaseLabel(item); return ` `; }).join(''); searchResults.querySelectorAll('[data-search-case-id]').forEach((button) => { button.addEventListener('click', async () => { const caseId = Number(button.getAttribute('data-search-case-id')); await patchCallCase(caseId); closeSmartCaseModal(); window.location.href = `/sag/${caseId}/v3`; }); }); } catch (error) { if (token !== searchToken) return; searchHint.textContent = `Fejl: ${error?.message || 'ukendt fejl'}`; } }; searchInput?.addEventListener('input', () => { if (searchTimer) clearTimeout(searchTimer); searchTimer = setTimeout(() => runSearch(searchInput.value), 250); }); searchInput?.addEventListener('keydown', (event) => { if (event.key !== 'Enter') return; event.preventDefault(); if (searchTimer) clearTimeout(searchTimer); runSearch(searchInput.value); }); modal.querySelector('[data-action="link-manual-case"]')?.addEventListener('click', async () => { const manualValue = Number(modal.querySelector('[data-role="manual-case-id"]')?.value || 0); if (!Number.isInteger(manualValue) || manualValue <= 0) { window.alert('Ugyldigt sag-ID'); return; } await patchCallCase(manualValue); closeSmartCaseModal(); window.location.href = `/sag/${manualValue}/v3`; }); modal.querySelector('[data-action="close-smart-case"]')?.addEventListener('click', closeSmartCaseModal); backdrop.addEventListener('click', closeSmartCaseModal); document.addEventListener('keydown', function onKeydown(event) { if (event.key === 'Escape') { closeSmartCaseModal(); document.removeEventListener('keydown', onKeydown); } }); modal.querySelectorAll('[data-tab-target]').forEach((tabBtn) => { tabBtn.addEventListener('click', () => showPane(tabBtn.dataset.tabTarget)); }); // ── hent altid friske data fra API ──────────────────────────────── const contactId = contact?.id ? Number(contact.id) : null; if (contactId) { fetchContactCases(contactId).then((ctx) => { const contactCases = ctx?.contact_cases || []; const companyCases = ctx?.company_cases || []; // Gem i currentData så efterfølgende åbninger også har data currentData.contact_cases = contactCases; currentData.company_cases = companyCases; renderCaseList('contact', contactCases, 'Ingen sager tilknyttet denne kontakt'); renderCaseList('company', companyCases, 'Ingen sager på firmaet'); // Relaterede kontakter fra WS-payload (hurtig) eller tom const relatedContacts = Array.isArray(currentData.related_contacts) ? currentData.related_contacts : []; renderRelatedContacts(relatedContacts); const hasSuggestions = contactCases.length > 0 || companyCases.length > 0; if (!hasSuggestions) { showPane('search'); const term = String(contact?.name || contact?.company || number || '').trim(); if (term.length >= 2) { searchInput.value = term; runSearch(term); } else { setTimeout(() => searchInput?.focus(), 50); } } }).catch((err) => { console.warn('📞 fetchContactCases fejlede:', err?.message || err); renderCaseList('contact', [], 'Ingen data'); renderCaseList('company', [], 'Ingen data'); renderRelatedContacts([]); showPane('search'); const term = String(contact?.name || contact?.company || number || '').trim(); if (term.length >= 2) { searchInput.value = term; runSearch(term); } else { setTimeout(() => searchInput?.focus(), 50); } }); } else { // Ingen kontakt — vis søge-fanen med det samme renderCaseList('contact', [], 'Ingen kontakt tilknyttet dette opkald'); renderCaseList('company', [], 'Ingen kontakt tilknyttet dette opkald'); renderRelatedContacts([]); showPane('search'); const term = String(number || '').trim(); if (term.length >= 2) { searchInput.value = term; runSearch(term); } else { setTimeout(() => searchInput?.focus(), 50); } } } function renderToastBody() { contact = currentData.contact || null; const title = contact?.name ? contact.name : 'Ukendt nummer'; const company = contact?.company ? contact.company : ''; const recentCases = currentData.recent_cases || []; const lastCall = currentData.last_call; const openContactBtn = contact?.id ? `` : ''; let casesHtml = ''; if (recentCases.length > 0) { casesHtml = '
Åbne sager:'; recentCases.forEach(c => { casesHtml += `
${escapeHtml(c.titel)}
`; }); casesHtml += '
'; } let lastCallHtml = ''; if (lastCall) { const callDate = lastCall.started_at ? lastCall.started_at : lastCall; const lastCallDate = new Date(callDate); const now = new Date(); const diffMs = now - lastCallDate; const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); let timeAgo = ''; if (diffDays === 0) { timeAgo = 'I dag'; } else if (diffDays === 1) { timeAgo = 'I går'; } else if (diffDays < 7) { timeAgo = `${diffDays} dage siden`; } else { timeAgo = lastCallDate.toLocaleDateString('da-DK'); } const brugerInfo = lastCall.bruger_navn ? ` (${escapeHtml(lastCall.bruger_navn)})` : ''; let durationInfo = ''; if (lastCall.duration_sec) { const mins = Math.floor(lastCall.duration_sec / 60); const secs = lastCall.duration_sec % 60; durationInfo = mins > 0 ? ` - ${mins}m ${secs}s` : ` - ${secs}s`; } lastCallHtml = `
Sidst snakket: ${timeAgo}${brugerInfo}${durationInfo}
`; } const quickCreateHtml = !contact?.id ? `
Hurtig opret kontakt
Finder først eksisterende kontakt på navn og opdaterer telefon, hvis den kun mangler nummer.
` : ''; toastEl.innerHTML = `
Opkald ${escapeHtml(currentData.direction === 'outbound' ? 'Udgående' : 'Indgående')}
${escapeHtml(number)}
${escapeHtml(title)}
${company ? `
${escapeHtml(company)}
` : ''} ${lastCallHtml} ${casesHtml} ${quickCreateHtml}
${openContactBtn}
`; } renderToastBody(); container.appendChild(toastEl); const toast = new bootstrap.Toast(toastEl, { autohide: false }); toast.show(); toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove()); async function patchCallCase(caseId) { const parsedCaseId = Number(caseId); if (!Number.isInteger(parsedCaseId) || parsedCaseId <= 0) { throw new Error('Ugyldigt sag-ID'); } if (!Number.isInteger(Number(callId)) || Number(callId) <= 0) { throw new Error('Mangler call_id'); } const res = await fetch(`/api/v1/telefoni/calls/${Number(callId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ sag_id: parsedCaseId }) }); if (!res.ok) { const t = await res.text(); throw new Error(t || `HTTP ${res.status}`); } return parsedCaseId; } async function patchCallContact(contactId) { if (!Number.isInteger(Number(callId)) || Number(callId) <= 0) { throw new Error('Mangler call_id'); } const res = await fetch(`/api/v1/telefoni/calls/${Number(callId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ kontakt_id: Number(contactId) }) }); if (!res.ok) { const text = await res.text(); throw new Error(text || `HTTP ${res.status}`); } return res.json(); } async function createOrUpdateContactFromToast() { const firstNameInput = toastEl.querySelector('[data-role="quick-first-name"]'); const lastNameInput = toastEl.querySelector('[data-role="quick-last-name"]'); const emailInput = toastEl.querySelector('[data-role="quick-email"]'); const firstName = String(firstNameInput?.value || '').trim(); const lastName = String(lastNameInput?.value || '').trim(); const email = String(emailInput?.value || '').trim(); if (!firstName) { window.alert('Fornavn er påkrævet'); firstNameInput?.focus(); return; } const actionBtn = toastEl.querySelector('[data-action="quick-create-contact"]'); if (!actionBtn) return; actionBtn.disabled = true; const originalText = actionBtn.textContent; actionBtn.textContent = 'Gemmer...'; try { const phoneValue = String(number || '').trim() || null; const existing = await fetchContactByName(firstName, lastName, phoneValue || ''); let savedContactId = null; if (existing?.id) { const existingHasPhone = String(existing.phone || existing.mobile || '').trim(); const shouldUpdatePhone = !existingHasPhone && phoneValue; const shouldUpdateEmail = !String(existing.email || '').trim() && email; if (shouldUpdatePhone || shouldUpdateEmail) { const updatePayload = {}; if (shouldUpdatePhone) updatePayload.phone = phoneValue; if (shouldUpdateEmail) updatePayload.email = email; const updateRes = await fetch(`/api/v1/contacts/${Number(existing.id)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify(updatePayload) }); if (!updateRes.ok) { const text = await updateRes.text(); throw new Error(text || `HTTP ${updateRes.status}`); } } savedContactId = Number(existing.id); } else { const createRes = await fetch('/api/v1/contacts', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ first_name: firstName, last_name: lastName, email: email || null, phone: phoneValue, title: null, }) }); if (!createRes.ok) { const text = await createRes.text(); throw new Error(text || `HTTP ${createRes.status}`); } const created = await createRes.json(); savedContactId = Number(created?.id || 0); } if (!Number.isInteger(savedContactId) || savedContactId <= 0) { throw new Error('Kontakt-ID mangler'); } await patchCallContact(savedContactId); const [fullContact, caseContext] = await Promise.all([ fetchFullContact(savedContactId), fetchContactCases(savedContactId), ]); currentData = { ...currentData, contact: buildToastContact(fullContact), contact_cases: caseContext?.contact_cases || currentData.contact_cases || [], company_cases: caseContext?.company_cases || currentData.company_cases || [], related_contacts: caseContext?.related_contacts || currentData.related_contacts || [], }; renderToastBody(); } catch (err) { window.alert(`Kunne ikke oprette kontakt: ${err?.message || 'ukendt fejl'}`); } finally { const refreshedActionBtn = toastEl.querySelector('[data-action="quick-create-contact"]'); if (refreshedActionBtn) { refreshedActionBtn.disabled = false; refreshedActionBtn.textContent = originalText; } } } toastEl.addEventListener('click', async (e) => { const btn = e.target.closest('button[data-action]'); if (!btn) return; const action = btn.getAttribute('data-action'); if (action === 'open-contact' && contact?.id) { window.location.href = `/contacts/${contact.id}`; } if (action === 'create-case') { const qs = new URLSearchParams(); if (contact?.id) qs.set('contact_id', String(contact.id)); if (contact?.company_id) qs.set('customer_id', String(contact.company_id)); qs.set('title', `Telefonsamtale – ${number}`); qs.set('telefoni_opkald_id', String(callId)); window.location.href = `/sag/new?${qs.toString()}`; } if (action === 'link-case') { openCaseSmartModal(); } if (action === 'quick-create-contact') { await createOrUpdateContactFromToast(); } }); } function scheduleReconnect() { if (reconnectTimer || reconnectDisabledForAuth) return; reconnectTimer = setTimeout(async () => { reconnectTimer = null; const sessionOk = await hasActiveSession(); if (!sessionOk) { reconnectDisabledForAuth = true; console.info('📞 Telefoni WS reconnect paused (unauthorized session)'); return; } connect(); }, 5000); } function connect() { if (reconnectDisabledForAuth) { return; } if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { return; } const token = getToken(); const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'; // Fallback to cookie-auth websocket when token is HttpOnly and cannot be read by JS. const url = token ? `${proto}://${window.location.host}/api/v1/telefoni/ws?token=${encodeURIComponent(token)}` : `${proto}://${window.location.host}/api/v1/telefoni/ws`; ws = new WebSocket(url); ws.onopen = () => { reconnectDisabledForAuth = false; console.log('📞 Telefoni WS connected'); }; ws.onclose = (evt) => { console.log('📞 Telefoni WS disconnected', evt.code, evt.reason || ''); if (evt && evt.code === 1008) { reconnectDisabledForAuth = true; console.info('📞 Telefoni WS reconnect paused (auth rejected by server)'); return; } scheduleReconnect(); }; ws.onerror = () => { // onclose handles reconnect }; ws.onmessage = (evt) => { try { const msg = JSON.parse(evt.data); if (msg?.event === 'incoming_call') { showIncomingCallToast(msg.data || {}); } } catch (e) { console.warn('Telefoni WS message parse failed', e); } }; } document.addEventListener('DOMContentLoaded', connect); window.addEventListener('focus', async () => { if (reconnectDisabledForAuth) { const sessionOk = await hasActiveSession(); if (sessionOk) { reconnectDisabledForAuth = false; } else { return; } } connect(); }); window.addEventListener('storage', (evt) => { if (evt.key === 'access_token') { reconnectDisabledForAuth = false; connect(); } }); })();