bmc_hub/static/js/telefoni.js
2026-08-18 20:57:49 +02:00

990 lines
48 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 : [];
}
async function searchCustomers(query) {
const qs = new URLSearchParams({
search: String(query || '').trim(),
limit: '20',
is_active: 'true',
});
const res = await fetch(`/api/v1/customers?${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?.customers) ? payload.customers : [];
}
function showIncomingCallToast(data) {
const container = ensureContainer();
let currentData = { ...data };
let contact = currentData.contact || null;
let selectedQuickCompany = null;
let companySearchTimer = null;
let companySearchToken = 0;
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 => {
const caseId = Number(c.id);
casesHtml += `
<div class="d-flex align-items-center justify-content-between gap-2 border rounded px-2 py-1 mt-1 small">
<a href="/sag/${caseId}/v3" class="text-decoration-none text-truncate" target="_blank" title="Åbn sag #${caseId}">
<span class="text-muted">#${caseId}</span> ${escapeHtml(c.titel)}
</a>
<button type="button" class="btn btn-sm btn-outline-primary py-0 px-2 flex-shrink-0" data-action="link-recent-case" data-case-id="${caseId}">
<i class="bi bi-link-45deg me-1"></i>Link til sag
</button>
</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 position-relative">
<label class="form-label small text-muted mb-1">Firma</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-building"></i></span>
<input type="search" class="form-control" data-role="quick-company-search" placeholder="Søg og vælg firma" autocomplete="off">
<button type="button" class="btn btn-outline-secondary d-none" data-action="clear-quick-company" title="Fjern valgt firma"><i class="bi bi-x-lg"></i></button>
</div>
<div class="list-group position-absolute start-0 end-0 mt-1 shadow-sm d-none" data-role="quick-company-results" style="z-index: 10010; max-height: 180px; overflow-y: auto;"></div>
<div class="form-text" data-role="quick-company-help">Skriv mindst 2 tegn og vælg firmaet.</div>
</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();
const companyId = Number(selectedQuickCompany?.id || 0);
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);
if (companyId > 0) {
const linkRes = await fetch(`/api/v1/contacts/${savedContactId}/companies`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ customer_id: companyId, is_primary: true })
});
if (!linkRes.ok) {
const text = await linkRes.text();
throw new Error(text || `HTTP ${linkRes.status}`);
}
}
} 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,
company_id: companyId > 0 ? companyId : null,
is_primary: companyId > 0,
})
});
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 === 'link-recent-case') {
const caseId = Number(btn.getAttribute('data-case-id'));
btn.disabled = true;
const originalHtml = btn.innerHTML;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Linker';
try {
await patchCallCase(caseId);
btn.className = 'btn btn-sm btn-success py-0 px-2 flex-shrink-0';
btn.innerHTML = '<i class="bi bi-check-lg me-1"></i>Linket';
} catch (err) {
btn.disabled = false;
btn.innerHTML = originalHtml;
window.alert(`Kunne ikke linke opkaldet til sagen: ${err?.message || 'ukendt fejl'}`);
}
}
if (action === 'select-quick-company') {
selectedQuickCompany = {
id: Number(btn.getAttribute('data-company-id')),
name: btn.getAttribute('data-company-name') || '',
};
const input = toastEl.querySelector('[data-role="quick-company-search"]');
const results = toastEl.querySelector('[data-role="quick-company-results"]');
const help = toastEl.querySelector('[data-role="quick-company-help"]');
const clearBtn = toastEl.querySelector('[data-action="clear-quick-company"]');
if (input) input.value = selectedQuickCompany.name;
results?.classList.add('d-none');
if (help) help.innerHTML = `<span class="text-success"><i class="bi bi-check-circle me-1"></i>Valgt: ${escapeHtml(selectedQuickCompany.name)}</span>`;
clearBtn?.classList.remove('d-none');
}
if (action === 'clear-quick-company') {
selectedQuickCompany = null;
const input = toastEl.querySelector('[data-role="quick-company-search"]');
const results = toastEl.querySelector('[data-role="quick-company-results"]');
const help = toastEl.querySelector('[data-role="quick-company-help"]');
if (input) {
input.value = '';
input.focus();
}
results?.classList.add('d-none');
if (help) help.textContent = 'Skriv mindst 2 tegn og vælg firmaet.';
btn.classList.add('d-none');
}
if (action === 'quick-create-contact') {
await createOrUpdateContactFromToast();
}
});
toastEl.addEventListener('input', (event) => {
const input = event.target.closest('[data-role="quick-company-search"]');
if (!input) return;
selectedQuickCompany = null;
toastEl.querySelector('[data-action="clear-quick-company"]')?.classList.add('d-none');
const resultsEl = toastEl.querySelector('[data-role="quick-company-results"]');
const helpEl = toastEl.querySelector('[data-role="quick-company-help"]');
const query = String(input.value || '').trim();
if (companySearchTimer) clearTimeout(companySearchTimer);
if (query.length < 2) {
resultsEl?.classList.add('d-none');
if (helpEl) helpEl.textContent = 'Skriv mindst 2 tegn og vælg firmaet.';
return;
}
const token = ++companySearchToken;
if (helpEl) helpEl.textContent = 'Søger firmaer...';
companySearchTimer = setTimeout(async () => {
try {
const customers = await searchCustomers(query);
if (token !== companySearchToken || !resultsEl) return;
resultsEl.innerHTML = customers.length ? customers.map((customer) => {
const companyId = Number(customer.id);
const companyName = String(customer.name || `Firma #${companyId}`);
const meta = [customer.cvr_number ? `CVR ${customer.cvr_number}` : '', customer.city || ''].filter(Boolean).join(' · ');
return `<button type="button" class="list-group-item list-group-item-action py-2" data-action="select-quick-company" data-company-id="${companyId}" data-company-name="${escapeHtml(companyName)}">
<div class="fw-semibold small">${escapeHtml(companyName)}</div>
${meta ? `<div class="text-muted" style="font-size: .72rem">${escapeHtml(meta)}</div>` : ''}
</button>`;
}).join('') : '<div class="list-group-item text-muted small">Ingen firmaer fundet</div>';
resultsEl.classList.remove('d-none');
if (helpEl) helpEl.textContent = customers.length ? 'Vælg et firma fra listen.' : 'Ingen firmaer fundet.';
} catch (err) {
if (token !== companySearchToken) return;
resultsEl?.classList.add('d-none');
if (helpEl) helpEl.textContent = `Firmaer kunne ikke hentes: ${err?.message || 'ukendt fejl'}`;
}
}, 250);
});
}
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();
}
});
})();