bmc_hub/static/js/telefoni.js
Christian 3cddb71cec feat: Add Technician Dashboard V1, V2, and V3 with enhanced UI and functionality
- Introduced Technician Dashboard V1 (tech_v1_overview.html) with KPI cards and new cases overview.
- Implemented Technician Dashboard V2 (tech_v2_workboard.html) featuring a workboard layout for daily tasks and opportunities.
- Developed Technician Dashboard V3 (tech_v3_table_focus.html) with a power table for detailed case management.
- Created a dashboard selector page (technician_dashboard_selector.html) for easy navigation between dashboard versions.
- Added user dashboard preferences migration (130_user_dashboard_preferences.sql) to store default dashboard paths.
- Enhanced sag_sager table with assigned group ID (131_sag_assignment_group.sql) for better case management.
- Updated sag_subscriptions table to include cancellation rules and billing dates (132_subscription_cancellation.sql, 134_subscription_billing_dates.sql).
- Implemented subscription staging for CRM integration (136_simply_subscription_staging.sql).
- Added a script to move time tracking section in detail view (move_time_section.py).
- Created a test script for subscription processing (test_subscription_processing.py).
2026-02-17 08:29:05 +01:00

222 lines
8.2 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;
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('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
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 : '';
const recentCases = data.recent_cases || [];
const lastCall = data.last_call;
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>`
: '';
// 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>`;
}
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>` : ''}
${lastCallHtml}
${casesHtml}
<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>
</div>
</div>
`;
container.appendChild(toastEl);
const toast = new bootstrap.Toast(toastEl, { autohide: false });
toast.show();
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
toastEl.addEventListener('click', (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));
qs.set('title', `Telefonsamtale ${number}`);
qs.set('telefoni_opkald_id', String(callId));
window.location.href = `/sag/new?${qs.toString()}`;
}
});
}
function scheduleReconnect() {
if (reconnectTimer) return;
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, 5000);
}
function connect() {
if (ws && ws.readyState === WebSocket.OPEN) {
return;
}
const token = getToken();
if (!token) {
scheduleReconnect();
return;
}
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${window.location.host}/api/v1/telefoni/ws?token=${encodeURIComponent(token)}`;
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();
});
})();