- Added bug reporting API router to main application. - Enhanced screenshot functionality in bug-report.js with timeout handling and dynamic loading of html2canvas library. - Improved screenshot capture strategies and error handling for better user experience. - Updated modal handling for bug reports to include screenshot previews and status messages. - Refactored code for better readability and maintainability. feat: implement user-specific case status and preferences - Created migration to allow dynamic case statuses in sag_sager table. - Added user_sag_list_preferences table for per-user preferences on case list filters. - Introduced user_menu_preferences table to manage per-user menu visibility preferences with triggers for updated_at timestamps.
811 lines
38 KiB
JavaScript
811 lines
38 KiB
JavaScript
(() => {
|
||
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 normalizeNamePart(value) {
|
||
return String(value || '')
|
||
.trim()
|
||
.toLocaleLowerCase('da-DK')
|
||
.replace(/\s+/g, ' ');
|
||
}
|
||
|
||
function normalizeDigits(value) {
|
||
return String(value || '').replace(/\D+/g, '');
|
||
}
|
||
|
||
function buildToastContact(contactData) {
|
||
if (!contactData) return null;
|
||
|
||
const firstName = String(contactData.first_name || '').trim();
|
||
const lastName = String(contactData.last_name || '').trim();
|
||
const name = String(contactData.name || `${firstName} ${lastName}` || '').trim();
|
||
const primaryCompany = Array.isArray(contactData.companies) && contactData.companies.length > 0
|
||
? contactData.companies[0]
|
||
: null;
|
||
|
||
return {
|
||
id: contactData.id,
|
||
first_name: firstName,
|
||
last_name: lastName,
|
||
name,
|
||
company: primaryCompany?.name || contactData.company || '',
|
||
company_id: primaryCompany?.id || contactData.company_id || null,
|
||
phone: contactData.phone || null,
|
||
mobile: contactData.mobile || null,
|
||
};
|
||
}
|
||
|
||
function formatCaseLabel(caseItem) {
|
||
if (!caseItem) return '';
|
||
const title = String(caseItem.titel || caseItem.title || `Sag #${caseItem.id || ''}`).trim();
|
||
const customer = String(caseItem.customer_name || '').trim();
|
||
return customer ? `${title} · ${customer}` : title;
|
||
}
|
||
|
||
function formatContactLabel(contactItem) {
|
||
if (!contactItem) return '';
|
||
const firstName = String(contactItem.first_name || '').trim();
|
||
const lastName = String(contactItem.last_name || '').trim();
|
||
const name = [firstName, lastName].filter(Boolean).join(' ') || `Kontakt #${contactItem.id || ''}`;
|
||
const companyNames = Array.isArray(contactItem.company_names) ? contactItem.company_names.filter(Boolean) : [];
|
||
return companyNames.length ? `${name} · ${companyNames[0]}` : name;
|
||
}
|
||
|
||
async function fetchContactByName(firstName, lastName, phoneValue = '') {
|
||
const fullName = [String(firstName || '').trim(), String(lastName || '').trim()]
|
||
.filter(Boolean)
|
||
.join(' ');
|
||
const digits = normalizeDigits(phoneValue);
|
||
if (!fullName && !digits) return null;
|
||
|
||
const queries = [];
|
||
if (fullName) queries.push(fullName);
|
||
if (digits && digits !== fullName) queries.push(phoneValue || digits);
|
||
|
||
const contactMap = new Map();
|
||
for (const query of queries) {
|
||
const qs = new URLSearchParams({ search: query, limit: '25' });
|
||
const res = await fetch(`/api/v1/contacts?${qs.toString()}`, {
|
||
credentials: 'include'
|
||
});
|
||
if (!res.ok) {
|
||
const text = await res.text();
|
||
throw new Error(text || `HTTP ${res.status}`);
|
||
}
|
||
|
||
const payload = await res.json();
|
||
const contacts = Array.isArray(payload?.contacts) ? payload.contacts : [];
|
||
contacts.forEach((item) => {
|
||
if (item?.id) contactMap.set(Number(item.id), item);
|
||
});
|
||
}
|
||
|
||
const contacts = Array.from(contactMap.values());
|
||
const wantedFirst = normalizeNamePart(firstName);
|
||
const wantedLast = normalizeNamePart(lastName);
|
||
const wantedDigits = normalizeDigits(phoneValue);
|
||
|
||
const exactNameMatch = contacts.find((item) => {
|
||
return wantedFirst && normalizeNamePart(item?.first_name) === wantedFirst
|
||
&& normalizeNamePart(item?.last_name) === wantedLast;
|
||
});
|
||
if (exactNameMatch) return exactNameMatch;
|
||
|
||
if (wantedDigits) {
|
||
const exactNumberMatch = contacts.find((item) => {
|
||
const numbers = [item?.phone, item?.mobile].map(normalizeDigits).filter(Boolean);
|
||
return numbers.some((number) => number === wantedDigits || number.endsWith(wantedDigits) || wantedDigits.endsWith(number));
|
||
});
|
||
if (exactNumberMatch) return exactNumberMatch;
|
||
}
|
||
|
||
return contacts[0] || null;
|
||
}
|
||
|
||
async function fetchFullContact(contactId) {
|
||
const res = await fetch(`/api/v1/contacts/${Number(contactId)}`, {
|
||
credentials: 'include'
|
||
});
|
||
if (!res.ok) {
|
||
const text = await res.text();
|
||
throw new Error(text || `HTTP ${res.status}`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
async function fetchContactCases(contactId) {
|
||
const res = await fetch(`/api/v1/contacts/${Number(contactId)}/cases`, {
|
||
credentials: 'include'
|
||
});
|
||
if (!res.ok) {
|
||
const text = await res.text();
|
||
throw new Error(text || `HTTP ${res.status}`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
async function searchCases(query) {
|
||
const qs = new URLSearchParams({ q: String(query || '').trim() });
|
||
const res = await fetch(`/api/v1/search/sag?${qs.toString()}`, {
|
||
credentials: 'include'
|
||
});
|
||
if (!res.ok) {
|
||
const text = await res.text();
|
||
throw new Error(text || `HTTP ${res.status}`);
|
||
}
|
||
const payload = await res.json();
|
||
return Array.isArray(payload) ? payload : [];
|
||
}
|
||
|
||
function showIncomingCallToast(data) {
|
||
const container = ensureContainer();
|
||
let currentData = { ...data };
|
||
let contact = currentData.contact || null;
|
||
const number = data.number || '';
|
||
const callId = data.call_id;
|
||
|
||
const toastEl = document.createElement('div');
|
||
toastEl.className = 'toast align-items-stretch';
|
||
toastEl.setAttribute('role', 'alert');
|
||
toastEl.setAttribute('aria-live', 'assertive');
|
||
toastEl.setAttribute('aria-atomic', 'true');
|
||
|
||
function closeSmartCaseModal() {
|
||
const existing = document.getElementById('telefoni-smart-case-modal');
|
||
if (existing) existing.remove();
|
||
document.querySelectorAll('[data-telefoni-backdrop]').forEach((el) => el.remove());
|
||
document.body.classList.remove('modal-open');
|
||
}
|
||
|
||
function openCaseSmartModal() {
|
||
closeSmartCaseModal();
|
||
|
||
const modal = document.createElement('div');
|
||
modal.id = 'telefoni-smart-case-modal';
|
||
modal.className = 'modal fade show';
|
||
modal.style.display = 'block';
|
||
modal.setAttribute('tabindex', '-1');
|
||
modal.setAttribute('aria-modal', 'true');
|
||
modal.setAttribute('role', 'dialog');
|
||
modal.innerHTML = `
|
||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||
<div class="modal-content border-0 shadow-lg">
|
||
<div class="modal-header bg-primary text-white">
|
||
<div>
|
||
<h5 class="modal-title mb-0"><i class="bi bi-link-45deg me-2"></i>Link sag</h5>
|
||
<div class="small opacity-75">${escapeHtml(number || 'Ukendt nummer')} · vælg en relevant sag eller søg videre</div>
|
||
</div>
|
||
<button type="button" class="btn-close btn-close-white" data-action="close-smart-case"></button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<ul class="nav nav-pills gap-2 mb-3" role="tablist">
|
||
<li class="nav-item"><button class="nav-link active" type="button" data-tab-target="contact">Kontaktens sager</button></li>
|
||
<li class="nav-item"><button class="nav-link" type="button" data-tab-target="company">Firmaets sager</button></li>
|
||
<li class="nav-item"><button class="nav-link" type="button" data-tab-target="related">Relaterede kontakter</button></li>
|
||
<li class="nav-item"><button class="nav-link" type="button" data-tab-target="search">Søg sager</button></li>
|
||
</ul>
|
||
<div class="mb-3">
|
||
<label class="form-label small text-muted mb-1">Manuel sag-ID</label>
|
||
<div class="input-group">
|
||
<input type="number" min="1" class="form-control" data-role="manual-case-id" placeholder="Fx 1234">
|
||
<button type="button" class="btn btn-outline-secondary" data-action="link-manual-case">Link sag</button>
|
||
</div>
|
||
</div>
|
||
<div class="tab-pane-holder" data-pane="contact">
|
||
<div class="text-muted small p-2"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</div>
|
||
</div>
|
||
<div class="tab-pane-holder d-none" data-pane="company">
|
||
<div class="text-muted small p-2"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</div>
|
||
</div>
|
||
<div class="tab-pane-holder d-none" data-pane="related">
|
||
<div class="text-muted small p-2"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</div>
|
||
</div>
|
||
<div class="tab-pane-holder d-none" data-pane="search">
|
||
<div class="mb-3">
|
||
<label class="form-label small text-muted mb-1">Søg i alle sager</label>
|
||
<input type="text" class="form-control" data-role="case-search-input" placeholder="Titel, kunde, ID, buzzword...">
|
||
</div>
|
||
<div class="alert alert-light border mb-0" data-role="search-hint">Skriv mindst 2 tegn for at søge.</div>
|
||
<div class="list-group mt-3" data-role="search-results"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
const backdrop = document.createElement('div');
|
||
backdrop.className = 'modal-backdrop fade show';
|
||
backdrop.dataset.telefoniBackdrop = '1';
|
||
document.body.appendChild(backdrop);
|
||
document.body.classList.add('modal-open');
|
||
|
||
// ── helpers ──────────────────────────────────────────────────────
|
||
const showPane = (paneName) => {
|
||
modal.querySelectorAll('.tab-pane-holder').forEach((pane) => {
|
||
pane.classList.toggle('d-none', pane.dataset.pane !== paneName);
|
||
});
|
||
modal.querySelectorAll('[data-tab-target]').forEach((tabBtn) => {
|
||
tabBtn.classList.toggle('active', tabBtn.dataset.tabTarget === paneName);
|
||
});
|
||
};
|
||
|
||
const renderCaseList = (pane, items, emptyText) => {
|
||
const paneEl = modal.querySelector(`[data-pane="${pane}"]`);
|
||
if (!paneEl) return;
|
||
if (!items || items.length === 0) {
|
||
paneEl.innerHTML = `<div class="alert alert-light border mb-0">${escapeHtml(emptyText)}</div>`;
|
||
return;
|
||
}
|
||
paneEl.innerHTML = `<div class="list-group">${items.map((item) => {
|
||
const caseId = Number(item.id);
|
||
const label = formatCaseLabel(item);
|
||
const meta = [item.status, item.created_at ? new Date(item.created_at).toLocaleDateString('da-DK') : '']
|
||
.filter(Boolean).join(' · ');
|
||
return `
|
||
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center gap-3" data-case-id="${caseId}">
|
||
<div>
|
||
<div class="fw-semibold">${escapeHtml(label)}</div>
|
||
<div class="small text-muted">${escapeHtml(meta || `ID: ${caseId}`)}</div>
|
||
</div>
|
||
<i class="bi bi-arrow-right-circle text-primary"></i>
|
||
</button>`;
|
||
}).join('')}</div>`;
|
||
paneEl.querySelectorAll('[data-case-id]').forEach((button) => {
|
||
button.addEventListener('click', async () => {
|
||
const caseId = Number(button.getAttribute('data-case-id'));
|
||
await patchCallCase(caseId);
|
||
closeSmartCaseModal();
|
||
window.location.href = `/sag/${caseId}/v3`;
|
||
});
|
||
});
|
||
};
|
||
|
||
const renderRelatedContacts = (relatedContacts) => {
|
||
const paneEl = modal.querySelector('[data-pane="related"]');
|
||
if (!paneEl) return;
|
||
if (!relatedContacts.length) {
|
||
paneEl.innerHTML = '<div class="alert alert-light border mb-0">Ingen relaterede kontakter fundet</div>';
|
||
return;
|
||
}
|
||
paneEl.innerHTML = `<div class="list-group">${relatedContacts.map((item) => {
|
||
const contactId = Number(item.id);
|
||
const label = formatContactLabel(item);
|
||
const phone = String(item.phone || item.mobile || '').trim();
|
||
return `
|
||
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center gap-3" data-contact-id="${contactId}">
|
||
<div>
|
||
<div class="fw-semibold">${escapeHtml(label)}</div>
|
||
<div class="small text-muted">${escapeHtml(phone || 'Ingen telefon')}</div>
|
||
</div>
|
||
<i class="bi bi-person-lines-fill text-primary"></i>
|
||
</button>`;
|
||
}).join('')}</div>`;
|
||
paneEl.querySelectorAll('[data-contact-id]').forEach((button) => {
|
||
button.addEventListener('click', () => {
|
||
const cid = Number(button.getAttribute('data-contact-id'));
|
||
if (cid > 0) window.location.href = `/contacts/${cid}`;
|
||
});
|
||
});
|
||
};
|
||
|
||
// ── søg-fane ─────────────────────────────────────────────────────
|
||
const searchInput = modal.querySelector('[data-role="case-search-input"]');
|
||
const searchResults = modal.querySelector('[data-role="search-results"]');
|
||
const searchHint = modal.querySelector('[data-role="search-hint"]');
|
||
let searchTimer = null;
|
||
let searchToken = 0;
|
||
|
||
const runSearch = async (query) => {
|
||
const token = ++searchToken;
|
||
const q = String(query || '').trim();
|
||
if (q.length < 2) {
|
||
searchHint.textContent = 'Skriv mindst 2 tegn for at søge.';
|
||
searchResults.innerHTML = '';
|
||
return;
|
||
}
|
||
searchHint.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Søger...';
|
||
searchResults.innerHTML = '';
|
||
try {
|
||
const results = await searchCases(q);
|
||
if (token !== searchToken) return;
|
||
if (!results.length) {
|
||
searchHint.textContent = 'Ingen sager fundet';
|
||
return;
|
||
}
|
||
searchHint.textContent = `Fandt ${results.length} sager`;
|
||
searchResults.innerHTML = results.map((item) => {
|
||
const caseId = Number(item.id);
|
||
const label = formatCaseLabel(item);
|
||
return `
|
||
<button type="button" class="list-group-item list-group-item-action d-flex justify-content-between align-items-center" data-search-case-id="${caseId}">
|
||
<div>
|
||
<div class="fw-semibold">${escapeHtml(label)}</div>
|
||
<div class="small text-muted">${escapeHtml(item.status || '-')} · ID: ${caseId}</div>
|
||
</div>
|
||
<span class="badge text-bg-primary">Link</span>
|
||
</button>`;
|
||
}).join('');
|
||
searchResults.querySelectorAll('[data-search-case-id]').forEach((button) => {
|
||
button.addEventListener('click', async () => {
|
||
const caseId = Number(button.getAttribute('data-search-case-id'));
|
||
await patchCallCase(caseId);
|
||
closeSmartCaseModal();
|
||
window.location.href = `/sag/${caseId}/v3`;
|
||
});
|
||
});
|
||
} catch (error) {
|
||
if (token !== searchToken) return;
|
||
searchHint.textContent = `Fejl: ${error?.message || 'ukendt fejl'}`;
|
||
}
|
||
};
|
||
|
||
searchInput?.addEventListener('input', () => {
|
||
if (searchTimer) clearTimeout(searchTimer);
|
||
searchTimer = setTimeout(() => runSearch(searchInput.value), 250);
|
||
});
|
||
searchInput?.addEventListener('keydown', (event) => {
|
||
if (event.key !== 'Enter') return;
|
||
event.preventDefault();
|
||
if (searchTimer) clearTimeout(searchTimer);
|
||
runSearch(searchInput.value);
|
||
});
|
||
|
||
modal.querySelector('[data-action="link-manual-case"]')?.addEventListener('click', async () => {
|
||
const manualValue = Number(modal.querySelector('[data-role="manual-case-id"]')?.value || 0);
|
||
if (!Number.isInteger(manualValue) || manualValue <= 0) {
|
||
window.alert('Ugyldigt sag-ID');
|
||
return;
|
||
}
|
||
await patchCallCase(manualValue);
|
||
closeSmartCaseModal();
|
||
window.location.href = `/sag/${manualValue}/v3`;
|
||
});
|
||
|
||
modal.querySelector('[data-action="close-smart-case"]')?.addEventListener('click', closeSmartCaseModal);
|
||
backdrop.addEventListener('click', closeSmartCaseModal);
|
||
document.addEventListener('keydown', function onKeydown(event) {
|
||
if (event.key === 'Escape') {
|
||
closeSmartCaseModal();
|
||
document.removeEventListener('keydown', onKeydown);
|
||
}
|
||
});
|
||
|
||
modal.querySelectorAll('[data-tab-target]').forEach((tabBtn) => {
|
||
tabBtn.addEventListener('click', () => showPane(tabBtn.dataset.tabTarget));
|
||
});
|
||
|
||
// ── hent altid friske data fra API ────────────────────────────────
|
||
const contactId = contact?.id ? Number(contact.id) : null;
|
||
if (contactId) {
|
||
fetchContactCases(contactId).then((ctx) => {
|
||
const contactCases = ctx?.contact_cases || [];
|
||
const companyCases = ctx?.company_cases || [];
|
||
// Gem i currentData så efterfølgende åbninger også har data
|
||
currentData.contact_cases = contactCases;
|
||
currentData.company_cases = companyCases;
|
||
|
||
renderCaseList('contact', contactCases, 'Ingen sager tilknyttet denne kontakt');
|
||
renderCaseList('company', companyCases, 'Ingen sager på firmaet');
|
||
|
||
// Relaterede kontakter fra WS-payload (hurtig) eller tom
|
||
const relatedContacts = Array.isArray(currentData.related_contacts)
|
||
? currentData.related_contacts : [];
|
||
renderRelatedContacts(relatedContacts);
|
||
|
||
const hasSuggestions = contactCases.length > 0 || companyCases.length > 0;
|
||
if (!hasSuggestions) {
|
||
showPane('search');
|
||
const term = String(contact?.name || contact?.company || number || '').trim();
|
||
if (term.length >= 2) {
|
||
searchInput.value = term;
|
||
runSearch(term);
|
||
} else {
|
||
setTimeout(() => searchInput?.focus(), 50);
|
||
}
|
||
}
|
||
}).catch((err) => {
|
||
console.warn('📞 fetchContactCases fejlede:', err?.message || err);
|
||
renderCaseList('contact', [], 'Ingen data');
|
||
renderCaseList('company', [], 'Ingen data');
|
||
renderRelatedContacts([]);
|
||
showPane('search');
|
||
const term = String(contact?.name || contact?.company || number || '').trim();
|
||
if (term.length >= 2) {
|
||
searchInput.value = term;
|
||
runSearch(term);
|
||
} else {
|
||
setTimeout(() => searchInput?.focus(), 50);
|
||
}
|
||
});
|
||
} else {
|
||
// Ingen kontakt — vis søge-fanen med det samme
|
||
renderCaseList('contact', [], 'Ingen kontakt tilknyttet dette opkald');
|
||
renderCaseList('company', [], 'Ingen kontakt tilknyttet dette opkald');
|
||
renderRelatedContacts([]);
|
||
showPane('search');
|
||
const term = String(number || '').trim();
|
||
if (term.length >= 2) {
|
||
searchInput.value = term;
|
||
runSearch(term);
|
||
} else {
|
||
setTimeout(() => searchInput?.focus(), 50);
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderToastBody() {
|
||
contact = currentData.contact || null;
|
||
const title = contact?.name ? contact.name : 'Ukendt nummer';
|
||
const company = contact?.company ? contact.company : '';
|
||
const recentCases = currentData.recent_cases || [];
|
||
const lastCall = currentData.last_call;
|
||
const openContactBtn = contact?.id
|
||
? `<button type="button" class="btn btn-sm btn-outline-secondary" data-action="open-contact">Åbn kontakt</button>`
|
||
: '';
|
||
|
||
let casesHtml = '';
|
||
if (recentCases.length > 0) {
|
||
casesHtml = '<div class="mt-2 mb-2"><small class="text-muted fw-semibold">Åbne sager:</small>';
|
||
recentCases.forEach(c => {
|
||
casesHtml += `<div class="small"><a href="/sag/${c.id}" class="text-decoration-none" target="_blank">${escapeHtml(c.titel)}</a></div>`;
|
||
});
|
||
casesHtml += '</div>';
|
||
}
|
||
|
||
let lastCallHtml = '';
|
||
if (lastCall) {
|
||
const callDate = lastCall.started_at ? lastCall.started_at : lastCall;
|
||
const lastCallDate = new Date(callDate);
|
||
const now = new Date();
|
||
const diffMs = now - lastCallDate;
|
||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||
|
||
let timeAgo = '';
|
||
if (diffDays === 0) {
|
||
timeAgo = 'I dag';
|
||
} else if (diffDays === 1) {
|
||
timeAgo = 'I går';
|
||
} else if (diffDays < 7) {
|
||
timeAgo = `${diffDays} dage siden`;
|
||
} else {
|
||
timeAgo = lastCallDate.toLocaleDateString('da-DK');
|
||
}
|
||
|
||
const brugerInfo = lastCall.bruger_navn ? ` (${escapeHtml(lastCall.bruger_navn)})` : '';
|
||
|
||
let durationInfo = '';
|
||
if (lastCall.duration_sec) {
|
||
const mins = Math.floor(lastCall.duration_sec / 60);
|
||
const secs = lastCall.duration_sec % 60;
|
||
durationInfo = mins > 0 ? ` - ${mins}m ${secs}s` : ` - ${secs}s`;
|
||
}
|
||
|
||
lastCallHtml = `<div class="small text-muted mt-2"><i class="bi bi-clock-history me-1"></i>Sidst snakket: ${timeAgo}${brugerInfo}${durationInfo}</div>`;
|
||
}
|
||
|
||
const quickCreateHtml = !contact?.id ? `
|
||
<div class="border rounded p-2 mt-3 bg-light-subtle">
|
||
<div class="small fw-semibold mb-2">Hurtig opret kontakt</div>
|
||
<div class="row g-2">
|
||
<div class="col-6">
|
||
<input type="text" class="form-control form-control-sm" data-role="quick-first-name" placeholder="Fornavn">
|
||
</div>
|
||
<div class="col-6">
|
||
<input type="text" class="form-control form-control-sm" data-role="quick-last-name" placeholder="Efternavn">
|
||
</div>
|
||
<div class="col-12">
|
||
<input type="email" class="form-control form-control-sm" data-role="quick-email" placeholder="E-mail (valgfri)">
|
||
</div>
|
||
<div class="col-12">
|
||
<div class="form-text mt-0">Finder først eksisterende kontakt på navn og opdaterer telefon, hvis den kun mangler nummer.</div>
|
||
</div>
|
||
</div>
|
||
<div class="d-flex gap-2 mt-2">
|
||
<button type="button" class="btn btn-sm btn-success" data-action="quick-create-contact">Opret kontakt</button>
|
||
</div>
|
||
</div>
|
||
` : '';
|
||
|
||
toastEl.innerHTML = `
|
||
<div class="toast-header">
|
||
<strong class="me-auto"><i class="bi bi-telephone me-2"></i>Opkald</strong>
|
||
<small class="text-muted">${escapeHtml(currentData.direction === 'outbound' ? 'Udgående' : 'Indgående')}</small>
|
||
<button type="button" class="btn-close ms-2" data-bs-dismiss="toast" aria-label="Close"></button>
|
||
</div>
|
||
<div class="toast-body">
|
||
<div class="fw-bold">${escapeHtml(number)}</div>
|
||
<div>${escapeHtml(title)}</div>
|
||
${company ? `<div class="text-muted small">${escapeHtml(company)}</div>` : ''}
|
||
${lastCallHtml}
|
||
${casesHtml}
|
||
${quickCreateHtml}
|
||
<div class="d-flex gap-2 mt-3 flex-wrap">
|
||
${openContactBtn}
|
||
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>
|
||
<button type="button" class="btn btn-sm btn-outline-primary" data-action="link-case">Link sag</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
renderToastBody();
|
||
|
||
container.appendChild(toastEl);
|
||
const toast = new bootstrap.Toast(toastEl, { autohide: false });
|
||
toast.show();
|
||
|
||
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
|
||
|
||
async function patchCallCase(caseId) {
|
||
const parsedCaseId = Number(caseId);
|
||
if (!Number.isInteger(parsedCaseId) || parsedCaseId <= 0) {
|
||
throw new Error('Ugyldigt sag-ID');
|
||
}
|
||
if (!Number.isInteger(Number(callId)) || Number(callId) <= 0) {
|
||
throw new Error('Mangler call_id');
|
||
}
|
||
|
||
const res = await fetch(`/api/v1/telefoni/calls/${Number(callId)}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify({ sag_id: parsedCaseId })
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const t = await res.text();
|
||
throw new Error(t || `HTTP ${res.status}`);
|
||
}
|
||
return parsedCaseId;
|
||
}
|
||
|
||
async function patchCallContact(contactId) {
|
||
if (!Number.isInteger(Number(callId)) || Number(callId) <= 0) {
|
||
throw new Error('Mangler call_id');
|
||
}
|
||
|
||
const res = await fetch(`/api/v1/telefoni/calls/${Number(callId)}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify({ kontakt_id: Number(contactId) })
|
||
});
|
||
|
||
if (!res.ok) {
|
||
const text = await res.text();
|
||
throw new Error(text || `HTTP ${res.status}`);
|
||
}
|
||
|
||
return res.json();
|
||
}
|
||
|
||
async function createOrUpdateContactFromToast() {
|
||
const firstNameInput = toastEl.querySelector('[data-role="quick-first-name"]');
|
||
const lastNameInput = toastEl.querySelector('[data-role="quick-last-name"]');
|
||
const emailInput = toastEl.querySelector('[data-role="quick-email"]');
|
||
const firstName = String(firstNameInput?.value || '').trim();
|
||
const lastName = String(lastNameInput?.value || '').trim();
|
||
const email = String(emailInput?.value || '').trim();
|
||
|
||
if (!firstName) {
|
||
window.alert('Fornavn er påkrævet');
|
||
firstNameInput?.focus();
|
||
return;
|
||
}
|
||
|
||
const actionBtn = toastEl.querySelector('[data-action="quick-create-contact"]');
|
||
if (!actionBtn) return;
|
||
|
||
actionBtn.disabled = true;
|
||
const originalText = actionBtn.textContent;
|
||
actionBtn.textContent = 'Gemmer...';
|
||
|
||
try {
|
||
const phoneValue = String(number || '').trim() || null;
|
||
const existing = await fetchContactByName(firstName, lastName, phoneValue || '');
|
||
let savedContactId = null;
|
||
|
||
if (existing?.id) {
|
||
const existingHasPhone = String(existing.phone || existing.mobile || '').trim();
|
||
const shouldUpdatePhone = !existingHasPhone && phoneValue;
|
||
const shouldUpdateEmail = !String(existing.email || '').trim() && email;
|
||
|
||
if (shouldUpdatePhone || shouldUpdateEmail) {
|
||
const updatePayload = {};
|
||
if (shouldUpdatePhone) updatePayload.phone = phoneValue;
|
||
if (shouldUpdateEmail) updatePayload.email = email;
|
||
|
||
const updateRes = await fetch(`/api/v1/contacts/${Number(existing.id)}`, {
|
||
method: 'PUT',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify(updatePayload)
|
||
});
|
||
if (!updateRes.ok) {
|
||
const text = await updateRes.text();
|
||
throw new Error(text || `HTTP ${updateRes.status}`);
|
||
}
|
||
}
|
||
|
||
savedContactId = Number(existing.id);
|
||
} else {
|
||
const createRes = await fetch('/api/v1/contacts', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
credentials: 'include',
|
||
body: JSON.stringify({
|
||
first_name: firstName,
|
||
last_name: lastName,
|
||
email: email || null,
|
||
phone: phoneValue,
|
||
title: null,
|
||
})
|
||
});
|
||
if (!createRes.ok) {
|
||
const text = await createRes.text();
|
||
throw new Error(text || `HTTP ${createRes.status}`);
|
||
}
|
||
|
||
const created = await createRes.json();
|
||
savedContactId = Number(created?.id || 0);
|
||
}
|
||
|
||
if (!Number.isInteger(savedContactId) || savedContactId <= 0) {
|
||
throw new Error('Kontakt-ID mangler');
|
||
}
|
||
|
||
await patchCallContact(savedContactId);
|
||
const [fullContact, caseContext] = await Promise.all([
|
||
fetchFullContact(savedContactId),
|
||
fetchContactCases(savedContactId),
|
||
]);
|
||
currentData = {
|
||
...currentData,
|
||
contact: buildToastContact(fullContact),
|
||
contact_cases: caseContext?.contact_cases || currentData.contact_cases || [],
|
||
company_cases: caseContext?.company_cases || currentData.company_cases || [],
|
||
related_contacts: caseContext?.related_contacts || currentData.related_contacts || [],
|
||
};
|
||
renderToastBody();
|
||
} catch (err) {
|
||
window.alert(`Kunne ikke oprette kontakt: ${err?.message || 'ukendt fejl'}`);
|
||
} finally {
|
||
const refreshedActionBtn = toastEl.querySelector('[data-action="quick-create-contact"]');
|
||
if (refreshedActionBtn) {
|
||
refreshedActionBtn.disabled = false;
|
||
refreshedActionBtn.textContent = originalText;
|
||
}
|
||
}
|
||
}
|
||
|
||
toastEl.addEventListener('click', async (e) => {
|
||
const btn = e.target.closest('button[data-action]');
|
||
if (!btn) return;
|
||
const action = btn.getAttribute('data-action');
|
||
if (action === 'open-contact' && contact?.id) {
|
||
window.location.href = `/contacts/${contact.id}`;
|
||
}
|
||
if (action === 'create-case') {
|
||
const qs = new URLSearchParams();
|
||
if (contact?.id) qs.set('contact_id', String(contact.id));
|
||
if (contact?.company_id) qs.set('customer_id', String(contact.company_id));
|
||
qs.set('title', `Telefonsamtale – ${number}`);
|
||
qs.set('telefoni_opkald_id', String(callId));
|
||
window.location.href = `/sag/new?${qs.toString()}`;
|
||
}
|
||
if (action === 'link-case') {
|
||
openCaseSmartModal();
|
||
}
|
||
if (action === 'quick-create-contact') {
|
||
await createOrUpdateContactFromToast();
|
||
}
|
||
});
|
||
}
|
||
|
||
function scheduleReconnect() {
|
||
if (reconnectTimer) return;
|
||
reconnectTimer = setTimeout(() => {
|
||
reconnectTimer = null;
|
||
connect();
|
||
}, 5000);
|
||
}
|
||
|
||
function connect() {
|
||
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 = () => 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();
|
||
});
|
||
})();
|