bmc_hub/static/js/telefoni.js
Christian 8710f7f798 feat(drift): add Drift module with frontend and backend components
- Implemented the Drift module with a new FastAPI router and HTML templates for the frontend.
- Created database tables for drift sources, devices, events, event history, and customer mappings.
- Added functionality to manage and display drift events, including filtering and bulk mapping of monitors to customers.
- Introduced tests for the Drift module, covering various functionalities including event blacklisting and UISP connector configuration.
- Enhanced Telefoni service tests to ensure proper call termination handling.
2026-06-26 14:15:46 +02:00

854 lines
39 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(() => {
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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
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 = `
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content border-0 shadow-lg">
<div class="modal-header bg-primary text-white">
<div>
<h5 class="modal-title mb-0"><i class="bi bi-link-45deg me-2"></i>Link sag</h5>
<div class="small opacity-75">${escapeHtml(number || 'Ukendt nummer')} · vælg en relevant sag eller søg videre</div>
</div>
<button type="button" class="btn-close btn-close-white" data-action="close-smart-case"></button>
</div>
<div class="modal-body">
<ul class="nav nav-pills gap-2 mb-3" role="tablist">
<li class="nav-item"><button class="nav-link active" type="button" data-tab-target="contact">Kontaktens sager</button></li>
<li class="nav-item"><button class="nav-link" type="button" data-tab-target="company">Firmaets sager</button></li>
<li class="nav-item"><button class="nav-link" type="button" data-tab-target="related">Relaterede kontakter</button></li>
<li class="nav-item"><button class="nav-link" type="button" data-tab-target="search">Søg sager</button></li>
</ul>
<div class="mb-3">
<label class="form-label small text-muted mb-1">Manuel sag-ID</label>
<div class="input-group">
<input type="number" min="1" class="form-control" data-role="manual-case-id" placeholder="Fx 1234">
<button type="button" class="btn btn-outline-secondary" data-action="link-manual-case">Link sag</button>
</div>
</div>
<div class="tab-pane-holder" data-pane="contact">
<div class="text-muted small p-2"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</div>
</div>
<div class="tab-pane-holder d-none" data-pane="company">
<div class="text-muted small p-2"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</div>
</div>
<div class="tab-pane-holder d-none" data-pane="related">
<div class="text-muted small p-2"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</div>
</div>
<div class="tab-pane-holder d-none" data-pane="search">
<div class="mb-3">
<label class="form-label small text-muted mb-1">Søg i alle sager</label>
<input type="text" class="form-control" data-role="case-search-input" placeholder="Titel, kunde, ID, buzzword...">
</div>
<div class="alert alert-light border mb-0" data-role="search-hint">Skriv mindst 2 tegn for at søge.</div>
<div class="list-group mt-3" data-role="search-results"></div>
</div>
</div>
</div>
</div>
`;
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 = `<div class="alert alert-light border mb-0">${escapeHtml(emptyText)}</div>`;
return;
}
paneEl.innerHTML = `<div class="list-group">${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 `
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center gap-3" data-case-id="${caseId}">
<div>
<div class="fw-semibold">${escapeHtml(label)}</div>
<div class="small text-muted">${escapeHtml(meta || `ID: ${caseId}`)}</div>
</div>
<i class="bi bi-arrow-right-circle text-primary"></i>
</button>`;
}).join('')}</div>`;
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 = '<div class="alert alert-light border mb-0">Ingen relaterede kontakter fundet</div>';
return;
}
paneEl.innerHTML = `<div class="list-group">${relatedContacts.map((item) => {
const contactId = Number(item.id);
const label = formatContactLabel(item);
const phone = String(item.phone || item.mobile || '').trim();
return `
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center gap-3" data-contact-id="${contactId}">
<div>
<div class="fw-semibold">${escapeHtml(label)}</div>
<div class="small text-muted">${escapeHtml(phone || 'Ingen telefon')}</div>
</div>
<i class="bi bi-person-lines-fill text-primary"></i>
</button>`;
}).join('')}</div>`;
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 = '<span class="spinner-border spinner-border-sm me-2"></span>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 `
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" data-search-case-id="${caseId}">
<div>
<div class="fw-semibold">${escapeHtml(label)}</div>
<div class="small text-muted">${escapeHtml(item.status || '-')} · ID: ${caseId}</div>
</div>
<span class="badge text-bg-primary">Link</span>
</button>`;
}).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
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-action="open-contact">Åbn kontakt</button>`
: '';
let casesHtml = '';
if (recentCases.length > 0) {
casesHtml = '<div class="mt-2 mb-2"><small class="text-muted fw-semibold">Åbne sager:</small>';
recentCases.forEach(c => {
casesHtml += `<div class="small"><a href="/sag/${c.id}" class="text-decoration-none" target="_blank">${escapeHtml(c.titel)}</a></div>`;
});
casesHtml += '</div>';
}
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 = `<div class="small text-muted mt-2"><i class="bi bi-clock-history me-1"></i>Sidst snakket: ${timeAgo}${brugerInfo}${durationInfo}</div>`;
}
const quickCreateHtml = !contact?.id ? `
<div class="border rounded p-2 mt-3 bg-light-subtle">
<div class="small fw-semibold mb-2">Hurtig opret kontakt</div>
<div class="row g-2">
<div class="col-6">
<input type="text" class="form-control form-control-sm" data-role="quick-first-name" placeholder="Fornavn">
</div>
<div class="col-6">
<input type="text" class="form-control form-control-sm" data-role="quick-last-name" placeholder="Efternavn">
</div>
<div class="col-12">
<input type="email" class="form-control form-control-sm" data-role="quick-email" placeholder="E-mail (valgfri)">
</div>
<div class="col-12">
<div class="form-text mt-0">Finder først eksisterende kontakt på navn og opdaterer telefon, hvis den kun mangler nummer.</div>
</div>
</div>
<div class="d-flex gap-2 mt-2">
<button type="button" class="btn btn-sm btn-success" data-action="quick-create-contact">Opret kontakt</button>
</div>
</div>
` : '';
toastEl.innerHTML = `
<div class="toast-header">
<strong class="me-auto"><i class="bi bi-telephone me-2"></i>Opkald</strong>
<small class="text-muted">${escapeHtml(currentData.direction === 'outbound' ? 'Udgående' : 'Indgående')}</small>
<button type="button" class="btn-close ms-2" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
<div class="fw-bold">${escapeHtml(number)}</div>
<div>${escapeHtml(title)}</div>
${company ? `<div class="text-muted small">${escapeHtml(company)}</div>` : ''}
${lastCallHtml}
${casesHtml}
${quickCreateHtml}
<div class="d-flex gap-2 mt-3 flex-wrap">
${openContactBtn}
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-action="link-case">Link sag</button>
</div>
</div>
`;
}
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();
}
});
})();