2026-02-14 02:26:29 +01:00
|
|
|
|
(() => {
|
|
|
|
|
|
let ws = null;
|
|
|
|
|
|
let reconnectTimer = null;
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ensureContainer() {
|
|
|
|
|
|
let container = document.getElementById('telefoni-toast-container');
|
|
|
|
|
|
if (!container) {
|
|
|
|
|
|
container = document.createElement('div');
|
|
|
|
|
|
container.id = 'telefoni-toast-container';
|
|
|
|
|
|
container.setAttribute('aria-live', 'polite');
|
|
|
|
|
|
container.setAttribute('aria-atomic', 'true');
|
|
|
|
|
|
container.style.cssText = `
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
top: 20px;
|
|
|
|
|
|
right: 20px;
|
|
|
|
|
|
z-index: 9999;
|
|
|
|
|
|
width: 420px;
|
|
|
|
|
|
max-width: 90%;
|
|
|
|
|
|
`;
|
|
|
|
|
|
document.body.appendChild(container);
|
|
|
|
|
|
}
|
|
|
|
|
|
return container;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function escapeHtml(str) {
|
|
|
|
|
|
return String(str ?? '')
|
|
|
|
|
|
.replaceAll('&', '&')
|
|
|
|
|
|
.replaceAll('<', '<')
|
|
|
|
|
|
.replaceAll('>', '>')
|
|
|
|
|
|
.replaceAll('"', '"')
|
|
|
|
|
|
.replaceAll("'", ''');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showIncomingCallToast(data) {
|
|
|
|
|
|
const container = ensureContainer();
|
|
|
|
|
|
const contact = data.contact || null;
|
|
|
|
|
|
const number = data.number || '';
|
|
|
|
|
|
const title = contact?.name ? contact.name : 'Ukendt nummer';
|
|
|
|
|
|
const company = contact?.company ? contact.company : '';
|
2026-02-17 08:29:05 +01:00
|
|
|
|
const recentCases = data.recent_cases || [];
|
|
|
|
|
|
const lastCall = data.last_call;
|
2026-02-14 02:26:29 +01:00
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
|
|
|
|
|
|
const openContactBtn = contact?.id
|
|
|
|
|
|
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-action="open-contact">Åbn kontakt</button>`
|
|
|
|
|
|
: '';
|
|
|
|
|
|
|
2026-02-17 08:29:05 +01:00
|
|
|
|
// Build recent cases HTML
|
|
|
|
|
|
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>';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Build last call HTML
|
|
|
|
|
|
let lastCallHtml = '';
|
|
|
|
|
|
if (lastCall) {
|
|
|
|
|
|
// lastCall can be either a date string (legacy) or an object with started_at and bruger_navn
|
|
|
|
|
|
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)})` : '';
|
|
|
|
|
|
|
|
|
|
|
|
// Format duration
|
|
|
|
|
|
let durationInfo = '';
|
|
|
|
|
|
if (lastCall.duration_sec) {
|
|
|
|
|
|
const mins = Math.floor(lastCall.duration_sec / 60);
|
|
|
|
|
|
const secs = lastCall.duration_sec % 60;
|
|
|
|
|
|
if (mins > 0) {
|
|
|
|
|
|
durationInfo = ` - ${mins}m ${secs}s`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
durationInfo = ` - ${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>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 02:26:29 +01: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(data.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>` : ''}
|
2026-02-17 08:29:05 +01:00
|
|
|
|
${lastCallHtml}
|
|
|
|
|
|
${casesHtml}
|
2026-02-14 02:26:29 +01:00
|
|
|
|
<div class="d-flex gap-2 mt-3">
|
|
|
|
|
|
${openContactBtn}
|
|
|
|
|
|
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>
|
2026-06-09 00:05:28 +02:00
|
|
|
|
<button type="button" class="btn btn-sm btn-outline-primary" data-action="link-case">Link sag</button>
|
2026-02-14 02:26:29 +01:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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') {
|
|
|
|
|
|
const answer = window.prompt('Indtast eksisterende sag-ID, som opkaldet skal linkes til:');
|
|
|
|
|
|
if (answer === null) return;
|
|
|
|
|
|
const caseId = Number(String(answer).trim());
|
|
|
|
|
|
if (!Number.isInteger(caseId) || caseId <= 0) {
|
|
|
|
|
|
window.alert('Ugyldigt sag-ID');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
btn.disabled = true;
|
|
|
|
|
|
const originalText = btn.textContent;
|
|
|
|
|
|
btn.textContent = 'Gemmer...';
|
|
|
|
|
|
try {
|
|
|
|
|
|
const linkedCaseId = await patchCallCase(caseId);
|
|
|
|
|
|
window.location.href = `/sag/${linkedCaseId}/v3`;
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
window.alert(`Kunne ikke linke sag: ${err?.message || 'ukendt fejl'}`);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
btn.disabled = false;
|
|
|
|
|
|
btn.textContent = originalText;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-02-14 02:26:29 +01:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function scheduleReconnect() {
|
|
|
|
|
|
if (reconnectTimer) return;
|
|
|
|
|
|
reconnectTimer = setTimeout(() => {
|
|
|
|
|
|
reconnectTimer = null;
|
|
|
|
|
|
connect();
|
|
|
|
|
|
}, 5000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function connect() {
|
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);
|
|
|
|
|
|
|
|
|
|
|
|
ws.onopen = () => console.log('📞 Telefoni WS connected');
|
|
|
|
|
|
ws.onclose = (evt) => {
|
|
|
|
|
|
console.log('📞 Telefoni WS disconnected', evt.code, evt.reason || '');
|
|
|
|
|
|
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', connect);
|
|
|
|
|
|
window.addEventListener('storage', (evt) => {
|
|
|
|
|
|
if (evt.key === 'access_token') connect();
|
|
|
|
|
|
});
|
|
|
|
|
|
})();
|