(() => {
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 += `
`;
});
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
` : '';
toastEl.innerHTML = `
${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();
}
});
})();