2026-02-14 02:26:29 +01:00
|
|
|
|
(() => {
|
|
|
|
|
|
let ws = null;
|
|
|
|
|
|
let reconnectTimer = null;
|
2026-06-26 14:15:46 +02:00
|
|
|
|
let reconnectDisabledForAuth = false;
|
2026-02-14 02:26:29 +01:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-26 14:15:46 +02:00
|
|
|
|
async function hasActiveSession() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await fetch('/api/v1/auth/me', {
|
|
|
|
|
|
credentials: 'include',
|
|
|
|
|
|
headers: { 'Accept': 'application/json' }
|
|
|
|
|
|
});
|
|
|
|
|
|
return res.ok;
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 02:26:29 +01:00
|
|
|
|
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("'", ''');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 22:20:39 +02:00
|
|
|
|
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 : [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 02:26:29 +01:00
|
|
|
|
function showIncomingCallToast(data) {
|
|
|
|
|
|
const container = ensureContainer();
|
2026-06-18 22:20:39 +02:00
|
|
|
|
let currentData = { ...data };
|
|
|
|
|
|
let contact = currentData.contact || null;
|
2026-02-14 02:26:29 +01:00
|
|
|
|
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');
|
|
|
|
|
|
|
2026-06-18 22:20:39 +02:00
|
|
|
|
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;
|
2026-02-14 02:26:29 +01:00
|
|
|
|
|
2026-06-18 22:20:39 +02:00
|
|
|
|
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`;
|
2026-02-17 08:29:05 +01:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-06-18 22:20:39 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-02-17 08:29:05 +01:00
|
|
|
|
} else {
|
2026-06-18 22:20:39 +02:00
|
|
|
|
// 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);
|
2026-02-17 08:29:05 +01:00
|
|
|
|
} else {
|
2026-06-18 22:20:39 +02:00
|
|
|
|
setTimeout(() => searchInput?.focus(), 50);
|
2026-02-17 08:29:05 +01:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 22:20:39 +02:00
|
|
|
|
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>
|
2026-02-14 02:26:29 +01:00
|
|
|
|
</div>
|
2026-06-18 22:20:39 +02:00
|
|
|
|
` : '';
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
2026-02-14 02:26:29 +01:00
|
|
|
|
|
|
|
|
|
|
container.appendChild(toastEl);
|
|
|
|
|
|
const toast = new bootstrap.Toast(toastEl, { autohide: false });
|
|
|
|
|
|
toast.show();
|
|
|
|
|
|
|
|
|
|
|
|
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
|
|
|
|
|
|
|
2026-06-09 00:05:28 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-18 22:20:39 +02:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-09 00:05:28 +02:00
|
|
|
|
toastEl.addEventListener('click', async (e) => {
|
2026-02-14 02:26:29 +01:00
|
|
|
|
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));
|
2026-03-26 00:32:54 +01:00
|
|
|
|
if (contact?.company_id) qs.set('customer_id', String(contact.company_id));
|
2026-02-14 02:26:29 +01:00
|
|
|
|
qs.set('title', `Telefonsamtale – ${number}`);
|
|
|
|
|
|
qs.set('telefoni_opkald_id', String(callId));
|
|
|
|
|
|
window.location.href = `/sag/new?${qs.toString()}`;
|
|
|
|
|
|
}
|
2026-06-09 00:05:28 +02:00
|
|
|
|
if (action === 'link-case') {
|
2026-06-18 22:20:39 +02:00
|
|
|
|
openCaseSmartModal();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (action === 'quick-create-contact') {
|
|
|
|
|
|
await createOrUpdateContactFromToast();
|
2026-06-09 00:05:28 +02:00
|
|
|
|
}
|
2026-02-14 02:26:29 +01:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function scheduleReconnect() {
|
2026-06-26 14:15:46 +02:00
|
|
|
|
if (reconnectTimer || reconnectDisabledForAuth) return;
|
|
|
|
|
|
reconnectTimer = setTimeout(async () => {
|
2026-02-14 02:26:29 +01:00
|
|
|
|
reconnectTimer = null;
|
2026-06-26 14:15:46 +02:00
|
|
|
|
const sessionOk = await hasActiveSession();
|
|
|
|
|
|
if (!sessionOk) {
|
|
|
|
|
|
reconnectDisabledForAuth = true;
|
|
|
|
|
|
console.info('📞 Telefoni WS reconnect paused (unauthorized session)');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-14 02:26:29 +01:00
|
|
|
|
connect();
|
|
|
|
|
|
}, 5000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function connect() {
|
2026-06-26 14:15:46 +02:00
|
|
|
|
if (reconnectDisabledForAuth) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-06-11 12:38:40 +02:00
|
|
|
|
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
2026-02-14 02:26:29 +01:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const token = getToken();
|
|
|
|
|
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
2026-06-11 12:38:40 +02:00
|
|
|
|
// 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`;
|
2026-02-14 02:26:29 +01:00
|
|
|
|
ws = new WebSocket(url);
|
|
|
|
|
|
|
2026-06-26 14:15:46 +02:00
|
|
|
|
ws.onopen = () => {
|
|
|
|
|
|
reconnectDisabledForAuth = false;
|
|
|
|
|
|
console.log('📞 Telefoni WS connected');
|
|
|
|
|
|
};
|
2026-02-14 02:26:29 +01:00
|
|
|
|
ws.onclose = (evt) => {
|
|
|
|
|
|
console.log('📞 Telefoni WS disconnected', evt.code, evt.reason || '');
|
2026-06-26 14:15:46 +02:00
|
|
|
|
if (evt && evt.code === 1008) {
|
|
|
|
|
|
reconnectDisabledForAuth = true;
|
|
|
|
|
|
console.info('📞 Telefoni WS reconnect paused (auth rejected by server)');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-14 02:26:29 +01:00
|
|
|
|
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);
|
2026-06-26 14:15:46 +02:00
|
|
|
|
window.addEventListener('focus', async () => {
|
|
|
|
|
|
if (reconnectDisabledForAuth) {
|
|
|
|
|
|
const sessionOk = await hasActiveSession();
|
|
|
|
|
|
if (sessionOk) {
|
|
|
|
|
|
reconnectDisabledForAuth = false;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
connect();
|
|
|
|
|
|
});
|
2026-02-14 02:26:29 +01:00
|
|
|
|
window.addEventListener('storage', (evt) => {
|
2026-06-26 14:15:46 +02:00
|
|
|
|
if (evt.key === 'access_token') {
|
|
|
|
|
|
reconnectDisabledForAuth = false;
|
|
|
|
|
|
connect();
|
|
|
|
|
|
}
|
2026-02-14 02:26:29 +01:00
|
|
|
|
});
|
|
|
|
|
|
})();
|