bmc_hub/app/modules/telefoni/templates/log.html

1086 lines
45 KiB
HTML
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.

{% extends "shared/frontend/base.html" %}
{% block title %}Telefoni - BMC Hub{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="d-flex align-items-center justify-content-between mb-4">
<div>
<h2 class="mb-1"><i class="bi bi-telephone me-2"></i>Telefoni</h2>
<div class="text-muted small">Opkaldslog fra Yealink Action URL (Established/Terminated)</div>
</div>
</div>
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="row g-3 align-items-end">
<div class="col-md-3">
<label class="form-label">Bruger</label>
<select id="filterUser" class="form-select">
<option value="">Alle</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Dato fra</label>
<input id="filterFrom" type="date" class="form-control" />
</div>
<div class="col-md-3">
<label class="form-label">Dato til</label>
<input id="filterTo" type="date" class="form-control" />
</div>
<div class="col-md-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="filterWithoutCase" />
<label class="form-check-label" for="filterWithoutCase">Kun uden sag</label>
</div>
</div>
<div class="col-md-1">
<button id="btnRefresh" class="btn btn-primary w-100">
<i class="bi bi-arrow-clockwise"></i>
</button>
</div>
</div>
</div>
</div>
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Dato</th>
<th>Bruger</th>
<th>Retning</th>
<th>Nummer</th>
<th>Kontakt</th>
<th>Sag</th>
<th class="text-end">Varighed</th>
</tr>
</thead>
<tbody id="telefoniRows" data-initial-count="{{ initial_calls|length if initial_calls else 0 }}">
{% if initial_calls and initial_calls|length > 0 %}
{% for r in initial_calls %}
<tr>
<td>{{ r.started_at or '-' }}</td>
<td>{{ r.full_name or r.username or '-' }}</td>
<td>{{ 'Udgående' if r.direction == 'outbound' else 'Indgående' }}</td>
<td>{{ r.display_number or '-' }}</td>
<td>
{% if r.kontakt_id %}
<a href="/contacts/{{ r.kontakt_id }}">{{ r.contact_name or ('Kontakt #' ~ r.kontakt_id) }}</a>
{% else %}
-
{% endif %}
</td>
<td>
{% if r.sag_id %}
<a href="/sag/{{ r.sag_id }}/v3">{{ r.sag_titel or ('Sag #' ~ r.sag_id) }}</a>
{% else %}
-
{% endif %}
</td>
<td class="text-end">
{% if r.duration_sec is not none %}
{{ r.duration_sec }}s
{% elif r.ended_at %}
-
{% else %}
I gang
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr><td colspan="7" class="text-muted small">Indlæser...</td></tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="modal fade" id="linkContactModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Knyt eller opret kontakt</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div id="linkContactContext" class="small text-muted mb-3">Opkald: -</div>
<div class="mb-3">
<label for="linkContactSearch" class="form-label">Søg eksisterende kontakt</label>
<input id="linkContactSearch" type="text" class="form-control" placeholder="Søg navn, email, telefon..." autocomplete="off" />
</div>
<div id="linkContactSelected" class="alert alert-secondary py-2 mb-3">Ingen kontakt valgt</div>
<div id="linkContactResults" class="list-group mb-3"></div>
<hr>
<h6 class="mb-3">Eller opret ny kontakt</h6>
<div class="row g-3">
<div class="col-md-6">
<label for="newContactFirstName" class="form-label">Fornavn</label>
<input id="newContactFirstName" type="text" class="form-control" placeholder="Fornavn" />
</div>
<div class="col-md-6">
<label for="newContactLastName" class="form-label">Efternavn</label>
<input id="newContactLastName" type="text" class="form-control" placeholder="Efternavn" />
</div>
<div class="col-md-6">
<label for="newContactPhone" class="form-label">Telefon</label>
<input id="newContactPhone" type="text" class="form-control" placeholder="Telefonnummer" />
</div>
<div class="col-md-6">
<label for="newContactEmail" class="form-label">Email</label>
<input id="newContactEmail" type="email" class="form-control" placeholder="mail@firma.dk" />
</div>
<div class="col-md-12">
<label for="newContactTitle" class="form-label">Titel</label>
<input id="newContactTitle" type="text" class="form-control" placeholder="Fx IT-ansvarlig" />
</div>
</div>
<hr>
<h6 class="mb-3">Eller brug firmaets hovednummer</h6>
<div class="mb-3">
<label for="linkCompanySearch" class="form-label">Søg firma</label>
<input id="linkCompanySearch" type="text" class="form-control" placeholder="Søg firmanavn (min. 2 tegn)" autocomplete="off" />
</div>
<div id="linkCompanySelected" class="alert alert-secondary py-2 mb-3">Intet firma valgt</div>
<div id="linkCompanyResults" class="list-group"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" id="linkContactConfirm" class="btn btn-primary" disabled>Knyt kontakt</button>
<button type="button" id="createContactConfirm" class="btn btn-success">Opret og knyt</button>
<button type="button" id="createCompanyCaseConfirm" class="btn btn-warning">Opret sag på firma</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="linkSagModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Link opkald til sag</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<div id="linkSagContext" class="small text-muted mb-3">Opkald: -</div>
<div class="mb-3">
<label for="linkSagSearch" class="form-label">Søg sag</label>
<input id="linkSagSearch" type="text" class="form-control" placeholder="Søg på titel, kunde eller ID" autocomplete="off" />
</div>
<div class="mb-3">
<label for="linkSagIdManual" class="form-label">Eller indtast sag-ID manuelt</label>
<input id="linkSagIdManual" type="number" min="1" step="1" class="form-control" placeholder="Fx 1234" />
</div>
<div id="linkSagSelected" class="alert alert-secondary py-2 mb-3">Ingen sag valgt</div>
<div id="linkSagResults" class="list-group"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Annuller</button>
<button type="button" id="linkSagConfirm" class="btn btn-primary" disabled>Link sag</button>
</div>
</div>
</div>
</div>
<script>
function fmtDuration(sec, endedAt) {
if (sec === null || sec === undefined) return endedAt ? '-' : 'I gang';
const s = Number(sec);
if (!Number.isFinite(s) || s < 0) return endedAt ? '-' : 'I gang';
const mm = Math.floor(s / 60);
const ss = s % 60;
return `${mm}:${String(ss).padStart(2,'0')}`;
}
function escapeHtml(str) {
return String(str ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
let telefoniCurrentUserId = null;
let telefoniAutoResetTried = false;
let telefoniFirstApiLoadDone = false;
let telefoniFiltersArmed = false;
const telefoniCallMap = new Map();
const linkSagState = {
callId: null,
selectedSagId: null,
selectedLabel: '',
searchTimer: null,
searchToken: 0,
modal: null
};
const linkContactState = {
callId: null,
selectedContactId: null,
selectedLabel: '',
searchTimer: null,
companySearchTimer: null,
searchToken: 0,
companySearchToken: 0,
modal: null,
number: '',
mode: 'contact',
selectedCompanyId: null,
selectedCompanyLabel: ''
};
async function ensureCurrentUserId() {
if (telefoniCurrentUserId !== null) return telefoniCurrentUserId;
try {
const res = await fetch('/api/v1/auth/me', { credentials: 'include' });
if (!res.ok) return null;
const me = await res.json();
telefoniCurrentUserId = Number(me?.id) || null;
return telefoniCurrentUserId;
} catch (e) {
return null;
}
}
function getLinkSagModalInstance() {
if (!linkSagState.modal) {
const el = document.getElementById('linkSagModal');
if (!el || !window.bootstrap) return null;
linkSagState.modal = new bootstrap.Modal(el);
}
return linkSagState.modal;
}
function getLinkContactModalInstance() {
if (!linkContactState.modal) {
const el = document.getElementById('linkContactModal');
if (!el || !window.bootstrap) return null;
linkContactState.modal = new bootstrap.Modal(el);
}
return linkContactState.modal;
}
function setLinkContactSelected(contactId, label) {
const selected = document.getElementById('linkContactSelected');
const confirmBtn = document.getElementById('linkContactConfirm');
const numericContactId = Number(contactId);
if (!Number.isInteger(numericContactId) || numericContactId <= 0) {
linkContactState.selectedContactId = null;
linkContactState.selectedLabel = '';
if (selected) {
selected.className = 'alert alert-secondary py-2 mb-3';
selected.textContent = 'Ingen kontakt valgt';
}
if (confirmBtn) confirmBtn.disabled = true;
return;
}
linkContactState.selectedContactId = numericContactId;
linkContactState.selectedLabel = String(label || `Kontakt #${numericContactId}`);
if (selected) {
selected.className = 'alert alert-success py-2 mb-3';
selected.textContent = `Valgt: ${linkContactState.selectedLabel} (ID: ${numericContactId})`;
}
if (confirmBtn) confirmBtn.disabled = false;
}
function setLinkCompanySelected(companyId, label) {
const selected = document.getElementById('linkCompanySelected');
const numericCompanyId = Number(companyId);
if (!Number.isInteger(numericCompanyId) || numericCompanyId <= 0) {
linkContactState.selectedCompanyId = null;
linkContactState.selectedCompanyLabel = '';
if (selected) {
selected.className = 'alert alert-secondary py-2 mb-3';
selected.textContent = 'Intet firma valgt';
}
return;
}
linkContactState.selectedCompanyId = numericCompanyId;
linkContactState.selectedCompanyLabel = String(label || `Firma #${numericCompanyId}`);
if (selected) {
selected.className = 'alert alert-success py-2 mb-3';
selected.textContent = `Valgt: ${linkContactState.selectedCompanyLabel} (ID: ${numericCompanyId})`;
}
}
function renderLinkContactResults(results) {
const container = document.getElementById('linkContactResults');
if (!container) return;
if (!results || results.length === 0) {
container.innerHTML = '<div class="alert alert-light border mb-0">Ingen kontakter fundet</div>';
return;
}
container.innerHTML = (results || []).map((item) => {
const cid = Number(item.id);
const name = `${item.first_name || ''} ${item.last_name || ''}`.trim() || `Kontakt #${cid}`;
const email = String(item.email || '-');
const phone = String(item.mobile || item.phone || '-');
return `
<button type="button" class="list-group-item list-group-item-action" data-contact-id="${cid}" data-contact-label="${escapeHtml(name)}">
<div class="fw-semibold">${escapeHtml(name)}</div>
<div class="small text-muted">${escapeHtml(email)} · ${escapeHtml(phone)}</div>
</button>
`;
}).join('');
container.querySelectorAll('[data-contact-id]').forEach((btn) => {
btn.addEventListener('click', () => {
const cid = Number(btn.getAttribute('data-contact-id'));
const label = btn.getAttribute('data-contact-label') || `Kontakt #${cid}`;
setLinkContactSelected(cid, label);
});
});
}
async function searchContacts(query) {
const token = ++linkContactState.searchToken;
const container = document.getElementById('linkContactResults');
if (container) {
container.innerHTML = '<div class="alert alert-light border mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
}
try {
const qs = new URLSearchParams({ search: query || '', limit: '30', offset: '0' });
const res = await fetch(`/api/v1/contacts?${qs.toString()}`, { credentials: 'include' });
if (token !== linkContactState.searchToken) return;
if (!res.ok) {
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Kunne ikke søge kontakter</div>';
return;
}
const data = await res.json();
const results = Array.isArray(data?.contacts) ? data.contacts : [];
renderLinkContactResults(results);
} catch (e) {
if (token !== linkContactState.searchToken) return;
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Fejl under søgning</div>';
}
}
async function patchCallContact(callId, contactId) {
const res = await fetch(`/api/v1/telefoni/calls/${callId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ kontakt_id: contactId })
});
if (!res.ok) {
const t = await res.text();
throw new Error(t || `HTTP ${res.status}`);
}
}
function renderCompanyResults(results) {
const container = document.getElementById('linkCompanyResults');
if (!container) return;
if (!results || results.length === 0) {
container.innerHTML = '<div class="alert alert-light border mb-0">Ingen firmaer fundet</div>';
return;
}
container.innerHTML = (results || []).map((item) => {
const cid = Number(item.id);
const name = String(item.name || `Firma #${cid}`);
const cvr = String(item.cvr_nummer || item.cvr_number || '-');
return `
<button type="button" class="list-group-item list-group-item-action" data-company-id="${cid}" data-company-label="${escapeHtml(name)}">
<div class="fw-semibold">${escapeHtml(name)}</div>
<div class="small text-muted">CVR: ${escapeHtml(cvr)}</div>
</button>
`;
}).join('');
container.querySelectorAll('[data-company-id]').forEach((btn) => {
btn.addEventListener('click', () => {
const cid = Number(btn.getAttribute('data-company-id'));
const label = btn.getAttribute('data-company-label') || `Firma #${cid}`;
setLinkCompanySelected(cid, label);
});
});
}
async function searchCompanies(query) {
const token = ++linkContactState.companySearchToken;
const container = document.getElementById('linkCompanyResults');
if (container) {
container.innerHTML = '<div class="alert alert-light border mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
}
try {
const res = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(query || '')}`, { credentials: 'include' });
if (token !== linkContactState.companySearchToken) return;
if (!res.ok) {
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Kunne ikke søge firmaer</div>';
return;
}
const data = await res.json();
renderCompanyResults(Array.isArray(data) ? data : []);
} catch (e) {
if (token !== linkContactState.companySearchToken) return;
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Fejl under søgning</div>';
}
}
function initLinkContactModalEvents() {
const searchInput = document.getElementById('linkContactSearch');
const companySearchInput = document.getElementById('linkCompanySearch');
const confirmBtn = document.getElementById('linkContactConfirm');
const createBtn = document.getElementById('createContactConfirm');
const createCompanyCaseBtn = document.getElementById('createCompanyCaseConfirm');
const modalEl = document.getElementById('linkContactModal');
if (!searchInput || !companySearchInput || !confirmBtn || !createBtn || !createCompanyCaseBtn) return;
searchInput.addEventListener('input', () => {
if (linkContactState.searchTimer) clearTimeout(linkContactState.searchTimer);
const q = String(searchInput.value || '').trim();
linkContactState.searchTimer = setTimeout(() => {
if (!q) {
renderLinkContactResults([]);
return;
}
searchContacts(q);
}, 250);
});
companySearchInput.addEventListener('input', () => {
if (linkContactState.companySearchTimer) clearTimeout(linkContactState.companySearchTimer);
const q = String(companySearchInput.value || '').trim();
linkContactState.companySearchTimer = setTimeout(() => {
if (q.length < 2) {
renderCompanyResults([]);
return;
}
searchCompanies(q);
}, 250);
});
confirmBtn.addEventListener('click', async () => {
if (!linkContactState.callId || !linkContactState.selectedContactId) return;
confirmBtn.disabled = true;
try {
await patchCallContact(linkContactState.callId, linkContactState.selectedContactId);
const modal = getLinkContactModalInstance();
if (modal) modal.hide();
await loadCalls();
} catch (error) {
alert('Kunne ikke knytte kontakt: ' + (error?.message || 'ukendt fejl'));
} finally {
confirmBtn.disabled = false;
}
});
createBtn.addEventListener('click', async () => {
const firstName = String(document.getElementById('newContactFirstName')?.value || '').trim();
const lastName = String(document.getElementById('newContactLastName')?.value || '').trim();
const email = String(document.getElementById('newContactEmail')?.value || '').trim();
const title = String(document.getElementById('newContactTitle')?.value || '').trim();
const phoneInput = String(document.getElementById('newContactPhone')?.value || '').trim() || linkContactState.number;
if (!firstName) {
alert('Fornavn er påkrævet for at oprette kontakt');
return;
}
if (!linkContactState.callId) {
alert('Opkald mangler');
return;
}
createBtn.disabled = true;
createBtn.textContent = 'Opretter...';
try {
const res = 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: phoneInput || null,
title: title || null,
})
});
if (!res.ok) {
const t = await res.text();
throw new Error(t || `HTTP ${res.status}`);
}
const created = await res.json();
const contactId = Number(created?.id || 0);
if (!Number.isInteger(contactId) || contactId <= 0) {
throw new Error('Kontakt blev oprettet men ID mangler');
}
await patchCallContact(linkContactState.callId, contactId);
const modal = getLinkContactModalInstance();
if (modal) modal.hide();
await loadCalls();
} catch (error) {
alert('Kunne ikke oprette kontakt: ' + (error?.message || 'ukendt fejl'));
} finally {
createBtn.disabled = false;
createBtn.textContent = 'Opret og knyt';
}
});
createCompanyCaseBtn.addEventListener('click', async () => {
if (!linkContactState.callId) {
alert('Opkald mangler');
return;
}
if (!linkContactState.selectedCompanyId) {
alert('Vælg et firma først');
return;
}
const numberForTitle = linkContactState.number || 'ukendt nummer';
const qs = new URLSearchParams();
qs.set('customer_id', String(linkContactState.selectedCompanyId));
qs.set('telefoni_opkald_id', String(linkContactState.callId));
qs.set('title', `Telefonsamtale ${numberForTitle}`);
qs.set('description', `Opkald fra firmaets hovednummer: ${numberForTitle}`);
window.location.href = `/sag/new?${qs.toString()}`;
});
if (modalEl) {
modalEl.addEventListener('hidden.bs.modal', () => {
if (linkContactState.searchTimer) clearTimeout(linkContactState.searchTimer);
if (linkContactState.companySearchTimer) clearTimeout(linkContactState.companySearchTimer);
linkContactState.searchTimer = null;
linkContactState.companySearchTimer = null;
linkContactState.searchToken++;
linkContactState.companySearchToken++;
linkContactState.callId = null;
linkContactState.number = '';
linkContactState.mode = 'contact';
setLinkContactSelected(null, '');
setLinkCompanySelected(null, '');
searchInput.value = '';
companySearchInput.value = '';
document.getElementById('newContactFirstName').value = '';
document.getElementById('newContactLastName').value = '';
document.getElementById('newContactPhone').value = '';
document.getElementById('newContactEmail').value = '';
document.getElementById('newContactTitle').value = '';
const results = document.getElementById('linkContactResults');
const companyResults = document.getElementById('linkCompanyResults');
const ctx = document.getElementById('linkContactContext');
if (results) results.innerHTML = '';
if (companyResults) companyResults.innerHTML = '';
if (ctx) ctx.textContent = 'Opkald: -';
});
}
}
function openLinkContactModal(callId, mode = 'contact') {
const numericCallId = Number(callId);
const call = telefoniCallMap.get(numericCallId);
linkContactState.callId = numericCallId;
linkContactState.number = String(call?.display_number || call?.ekstern_nummer || '').trim();
linkContactState.mode = mode;
const ctx = document.getElementById('linkContactContext');
const searchInput = document.getElementById('linkContactSearch');
const companySearchInput = document.getElementById('linkCompanySearch');
const phoneInput = document.getElementById('newContactPhone');
const firstNameInput = document.getElementById('newContactFirstName');
const label = call
? `${call.direction === 'outbound' ? 'Udgående' : 'Indgående'} · ${linkContactState.number || '-'} · ${call.started_at ? new Date(call.started_at).toLocaleString('da-DK') : '-'}`
: `Opkald #${numericCallId}`;
if (ctx) ctx.textContent = `Opkald: ${label}`;
if (searchInput) searchInput.value = linkContactState.number;
if (companySearchInput) companySearchInput.value = '';
if (phoneInput) phoneInput.value = linkContactState.number;
if (firstNameInput && !firstNameInput.value) {
firstNameInput.value = 'Ukendt';
}
setLinkContactSelected(null, '');
setLinkCompanySelected(null, '');
renderLinkContactResults([]);
renderCompanyResults([]);
const modal = getLinkContactModalInstance();
if (modal) modal.show();
setTimeout(() => {
if (mode === 'company') {
companySearchInput?.focus();
} else {
searchInput?.focus();
}
if (linkContactState.number) {
searchContacts(linkContactState.number);
}
}, 200);
}
async function unlinkContact(callId) {
if (!confirm('Fjern link til kontakt for dette opkald?')) return;
try {
await patchCallContact(callId, null);
await loadCalls();
} catch (error) {
alert('Kunne ikke fjerne kontakt-link: ' + (error?.message || 'ukendt fejl'));
}
}
function setLinkSagSelected(sagId, label) {
const selected = document.getElementById('linkSagSelected');
const confirmBtn = document.getElementById('linkSagConfirm');
const numericSagId = Number(sagId);
if (!Number.isInteger(numericSagId) || numericSagId <= 0) {
linkSagState.selectedSagId = null;
linkSagState.selectedLabel = '';
if (selected) {
selected.className = 'alert alert-secondary py-2 mb-3';
selected.textContent = 'Ingen sag valgt';
}
if (confirmBtn) confirmBtn.disabled = true;
return;
}
linkSagState.selectedSagId = numericSagId;
linkSagState.selectedLabel = String(label || `Sag #${numericSagId}`);
if (selected) {
selected.className = 'alert alert-success py-2 mb-3';
selected.textContent = `Valgt: ${linkSagState.selectedLabel} (ID: ${numericSagId})`;
}
if (confirmBtn) confirmBtn.disabled = false;
}
function renderLinkSagResults(results) {
const container = document.getElementById('linkSagResults');
if (!container) return;
if (!results || results.length === 0) {
container.innerHTML = '<div class="alert alert-light border mb-0">Ingen sager fundet</div>';
return;
}
container.innerHTML = (results || []).map(item => {
const sid = Number(item.id);
const title = String(item.titel || item.title || `Sag #${sid}`);
const customer = String(item.customer_name || 'Ukendt kunde');
const status = String(item.status || '-');
const label = `${title}${customer}`;
return `
<div class="list-group-item d-flex justify-content-between align-items-start gap-3">
<div>
<div class="fw-semibold">${escapeHtml(title)}</div>
<div class="small text-muted">${escapeHtml(customer)} · ${escapeHtml(status)} · ID: ${sid}</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" onclick="setLinkSagSelected(${sid}, '${escapeHtml(label)}')">Vælg</button>
</div>
`;
}).join('');
}
function buildInitialSagQuery(call) {
if (!call) return '';
const number = String(call.display_number || call.ekstern_nummer || '').trim();
const contact = String(call.contact_name || '').trim();
const company = String(call.contact_company || '').trim();
if (contact && company) return `${contact} ${company}`;
if (contact) return contact;
if (company) return company;
if (number) return number;
return '';
}
async function searchSager(query) {
const token = ++linkSagState.searchToken;
const container = document.getElementById('linkSagResults');
if (container) {
container.innerHTML = '<div class="alert alert-light border mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Søger...</div>';
}
try {
const res = await fetch(`/api/v1/search/sag?q=${encodeURIComponent(query || '')}`, { credentials: 'include' });
if (token !== linkSagState.searchToken) return;
if (!res.ok) {
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Kunne ikke søge sager</div>';
return;
}
const data = await res.json();
const results = Array.isArray(data) ? data : (data?.items || data?.results || []);
renderLinkSagResults(results);
} catch (e) {
if (token !== linkSagState.searchToken) return;
if (container) container.innerHTML = '<div class="alert alert-danger mb-0">Fejl under søgning</div>';
}
}
function initLinkSagModalEvents() {
const searchInput = document.getElementById('linkSagSearch');
const manualInput = document.getElementById('linkSagIdManual');
const confirmBtn = document.getElementById('linkSagConfirm');
const modalEl = document.getElementById('linkSagModal');
if (!searchInput || !manualInput || !confirmBtn) return;
searchInput.addEventListener('input', () => {
if (linkSagState.searchTimer) clearTimeout(linkSagState.searchTimer);
const q = String(searchInput.value || '').trim();
linkSagState.searchTimer = setTimeout(() => {
if (!q) {
renderLinkSagResults([]);
return;
}
searchSager(q);
}, 250);
});
searchInput.addEventListener('keydown', (event) => {
if (event.key !== 'Enter') return;
event.preventDefault();
const firstSelectBtn = document.querySelector('#linkSagResults .btn-outline-primary');
if (firstSelectBtn) {
firstSelectBtn.click();
return;
}
const manualVal = Number(manualInput.value);
if (Number.isInteger(manualVal) && manualVal > 0) {
setLinkSagSelected(manualVal, `Sag #${manualVal} (manuel)`);
}
});
manualInput.addEventListener('input', () => {
const val = Number(manualInput.value);
if (Number.isInteger(val) && val > 0) {
setLinkSagSelected(val, `Sag #${val} (manuel)`);
} else {
setLinkSagSelected(null, '');
}
});
confirmBtn.addEventListener('click', async () => {
if (!linkSagState.callId || !linkSagState.selectedSagId) return;
try {
const res = await fetch(`/api/v1/telefoni/calls/${linkSagState.callId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ sag_id: linkSagState.selectedSagId })
});
if (!res.ok) {
const t = await res.text();
alert('Kunne ikke linke sag: ' + t);
return;
}
const modal = getLinkSagModalInstance();
if (modal) modal.hide();
await loadCalls();
} catch (e) {
alert('Kunne ikke linke sag');
}
});
if (modalEl) {
modalEl.addEventListener('hidden.bs.modal', () => {
if (linkSagState.searchTimer) clearTimeout(linkSagState.searchTimer);
linkSagState.searchTimer = null;
linkSagState.searchToken++;
linkSagState.callId = null;
setLinkSagSelected(null, '');
searchInput.value = '';
manualInput.value = '';
const results = document.getElementById('linkSagResults');
const ctx = document.getElementById('linkSagContext');
if (results) results.innerHTML = '';
if (ctx) ctx.textContent = 'Opkald: -';
});
}
}
async function callViaYealink(number) {
const clean = String(number || '').trim();
if (!clean || clean === '-') {
alert('Intet gyldigt nummer at ringe til');
return;
}
const userId = await ensureCurrentUserId();
try {
const res = await fetch('/api/v1/telefoni/click-to-call', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ number: clean, user_id: userId })
});
if (!res.ok) {
const t = await res.text();
alert('Ring ud fejlede: ' + t);
return;
}
alert('Ringer ud via Yealink...');
} catch (e) {
alert('Kunne ikke starte opkald');
}
}
async function loadUsers() {
const sel = document.getElementById('filterUser');
try {
const res = await fetch('/api/v1/telefoni/users', { credentials: 'include' });
if (!res.ok) return;
const users = await res.json();
(users || []).forEach(u => {
const opt = document.createElement('option');
opt.value = u.user_id;
opt.textContent = `${u.full_name || u.username || ('#' + u.user_id)}${u.telefoni_extension ? ' (' + u.telefoni_extension + ')' : ''}`;
sel.appendChild(opt);
});
sel.value = '';
} catch (e) {
console.error('Failed loading telefoni users', e);
}
}
async function loadCalls() {
const tbody = document.getElementById('telefoniRows');
const initialCount = Number(tbody?.dataset?.initialCount || '0');
const hadServerRows = Number.isFinite(initialCount) && initialCount > 0;
tbody.innerHTML = '<tr><td colspan="7" class="text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Indlæser...</td></tr>';
const userEl = document.getElementById('filterUser');
const fromEl = document.getElementById('filterFrom');
const toEl = document.getElementById('filterTo');
const withoutCaseEl = document.getElementById('filterWithoutCase');
let userId = userEl.value;
let from = fromEl.value;
let to = toEl.value;
let withoutCase = withoutCaseEl.checked;
// On first automatic load, ignore browser-restored filter values.
// Filters are only applied after explicit user interaction.
if (!telefoniFiltersArmed) {
userId = '';
from = '';
to = '';
withoutCase = false;
userEl.value = '';
fromEl.value = '';
toEl.value = '';
withoutCaseEl.checked = false;
}
const qs = new URLSearchParams();
if (userId) qs.set('user_id', userId);
if (from) qs.set('date_from', from);
if (to) qs.set('date_to', to);
if (withoutCase) qs.set('without_case', '1');
try {
const res = await fetch('/api/v1/telefoni/calls?' + qs.toString(), { credentials: 'include' });
if (!res.ok) {
const t = await res.text();
tbody.innerHTML = `<tr><td colspan="7" class="text-danger small">Fejl: ${escapeHtml(t)}</td></tr>`;
return;
}
const rows = await res.json();
telefoniCallMap.clear();
(rows || []).forEach(r => telefoniCallMap.set(Number(r.id), r));
if (!rows || rows.length === 0) {
const hadFilters = Boolean(userId || from || to || withoutCase);
// If SSR already showed calls, avoid replacing them with an empty first auto-refresh.
if (!telefoniFirstApiLoadDone && hadServerRows && !hadFilters) {
telefoniFirstApiLoadDone = true;
return;
}
if (hadFilters && !telefoniAutoResetTried) {
telefoniAutoResetTried = true;
document.getElementById('filterUser').value = '';
document.getElementById('filterFrom').value = '';
document.getElementById('filterTo').value = '';
document.getElementById('filterWithoutCase').checked = false;
await loadCalls();
return;
}
tbody.innerHTML = '<tr><td colspan="7" class="text-muted small">Ingen opkald fundet</td></tr>';
telefoniFirstApiLoadDone = true;
return;
}
telefoniAutoResetTried = false;
telefoniFirstApiLoadDone = true;
if (!telefoniFiltersArmed) {
telefoniFiltersArmed = true;
}
tbody.innerHTML = rows.map(r => {
const started = r.started_at ? new Date(r.started_at) : null;
const dateTxt = started ? started.toLocaleString('da-DK') : '-';
const userTxt = escapeHtml(r.full_name || r.username || '-');
const dirTxt = r.direction === 'outbound' ? 'Udgående' : 'Indgående';
const numberRaw = (r.display_number || r.ekstern_nummer || '').trim();
const numTxt = numberRaw
? `<div class="d-flex gap-2 align-items-center flex-wrap">
<span>${escapeHtml(numberRaw)}</span>
<button type="button" class="btn btn-sm btn-outline-success" onclick="callViaYealink('${escapeHtml(numberRaw)}')">Ring op</button>
</div>`
: '-';
const contactHtml = r.kontakt_id
? `<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="/contacts/${r.kontakt_id}">${escapeHtml(r.contact_name || ('Kontakt #' + r.kontakt_id))}</a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${Number(r.id)})">Skift</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkContact(${Number(r.id)})">Fjern</button>
</div>
${r.contact_company ? `<div class="text-muted small">${escapeHtml(r.contact_company)}</div>` : ''}`
: `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${Number(r.id)})" title="Vælg kontakt/firma">
<i class="bi bi-three-dots"></i>
</button>`;
const numberForTitle = (r.display_number || r.ekstern_nummer || '').trim();
const createQs = new URLSearchParams();
if (r.kontakt_id) createQs.set('contact_id', String(r.kontakt_id));
createQs.set('telefoni_opkald_id', String(r.id));
createQs.set('title', `Telefonsamtale ${numberForTitle || 'ukendt nummer'}`);
const sagHtml = r.sag_id
? `<div class="d-flex gap-2 align-items-center flex-wrap">
<a href="/sag/${r.sag_id}/v3">${escapeHtml(r.sag_titel || ('Sag #' + r.sag_id))}</a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase(${Number(r.id)})">Skift link</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkCase(${Number(r.id)})">Fjern link</button>
</div>`
: `<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-primary" href="/sag/new?${createQs.toString()}">Opret sag</a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase(${Number(r.id)})">Link sag</button>
</div>`;
return `
<tr>
<td>${escapeHtml(dateTxt)}</td>
<td>${userTxt}</td>
<td>${escapeHtml(dirTxt)}</td>
<td>${numTxt}</td>
<td>${contactHtml}</td>
<td>${sagHtml}</td>
<td class="text-end">${escapeHtml(fmtDuration(r.duration_sec, r.ended_at))}</td>
</tr>
`;
}).join('');
} catch (e) {
console.error('Failed loading calls', e);
tbody.innerHTML = '<tr><td colspan="7" class="text-danger small">Kunne ikke hente opkald</td></tr>';
}
}
async function linkExistingCase(callId) {
linkSagState.callId = Number(callId);
const call = telefoniCallMap.get(Number(callId));
const ctx = document.getElementById('linkSagContext');
const searchInput = document.getElementById('linkSagSearch');
const manualInput = document.getElementById('linkSagIdManual');
const results = document.getElementById('linkSagResults');
const label = call
? `${call.direction === 'outbound' ? 'Udgående' : 'Indgående'} · ${call.display_number || call.ekstern_nummer || '-'} · ${call.started_at ? new Date(call.started_at).toLocaleString('da-DK') : '-'}`
: `Opkald #${callId}`;
if (ctx) ctx.textContent = `Opkald: ${label}`;
const initialQuery = buildInitialSagQuery(call);
if (searchInput) searchInput.value = initialQuery;
if (manualInput) manualInput.value = '';
if (results) {
results.innerHTML = initialQuery
? '<div class="alert alert-light border mb-0"><span class="spinner-border spinner-border-sm me-2"></span>Søger relevante sager...</div>'
: '<div class="alert alert-light border mb-0">Søg efter en sag eller indtast ID manuelt</div>';
}
setLinkSagSelected(null, '');
const modal = getLinkSagModalInstance();
if (modal) modal.show();
setTimeout(() => {
searchInput?.focus();
if (initialQuery) {
searchSager(initialQuery);
}
}, 200);
}
async function unlinkCase(callId) {
if (!confirm('Fjern link til sag for dette opkald?')) return;
try {
const res = await fetch(`/api/v1/telefoni/calls/${callId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ sag_id: null })
});
if (!res.ok) {
const t = await res.text();
alert('Kunne ikke fjerne link: ' + t);
return;
}
await loadCalls();
} catch (e) {
alert('Kunne ikke fjerne link');
}
}
document.addEventListener('DOMContentLoaded', async () => {
initLinkContactModalEvents();
initLinkSagModalEvents();
const userFilter = document.getElementById('filterUser');
const fromFilter = document.getElementById('filterFrom');
const toFilter = document.getElementById('filterTo');
const withoutCaseFilter = document.getElementById('filterWithoutCase');
if (userFilter) userFilter.value = '';
if (fromFilter) fromFilter.value = '';
if (toFilter) toFilter.value = '';
if (withoutCaseFilter) withoutCaseFilter.checked = false;
telefoniAutoResetTried = false;
telefoniFiltersArmed = false;
await loadUsers();
document.getElementById('btnRefresh').addEventListener('click', () => {
telefoniFiltersArmed = true;
loadCalls();
});
document.getElementById('filterUser').addEventListener('change', () => {
telefoniFiltersArmed = true;
loadCalls();
});
document.getElementById('filterFrom').addEventListener('change', () => {
telefoniFiltersArmed = true;
loadCalls();
});
document.getElementById('filterTo').addEventListener('change', () => {
telefoniFiltersArmed = true;
loadCalls();
});
document.getElementById('filterWithoutCase').addEventListener('change', () => {
telefoniFiltersArmed = true;
loadCalls();
});
await loadCalls();
});
</script>
{% endblock %}