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

524 lines
21 KiB
HTML
Raw Permalink 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">
<tr><td colspan="7" class="text-muted small">Indlæser...</td></tr>
</tbody>
</table>
</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;
const telefoniCallMap = new Map();
const linkSagState = {
callId: null,
selectedSagId: null,
selectedLabel: '',
searchTimer: null,
searchToken: 0,
modal: null
};
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 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');
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 userId = document.getElementById('filterUser').value;
const from = document.getElementById('filterFrom').value;
const to = document.getElementById('filterTo').value;
const withoutCase = document.getElementById('filterWithoutCase').checked;
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) {
tbody.innerHTML = '<tr><td colspan="7" class="text-muted small">Ingen opkald fundet</td></tr>';
return;
}
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
? `<a href="/contacts/${r.kontakt_id}">${escapeHtml(r.contact_name || ('Kontakt #' + r.kontakt_id))}</a>${r.contact_company ? `<div class="text-muted small">${escapeHtml(r.contact_company)}</div>` : ''}`
: '<span class="text-muted">-</span>';
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}">${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 () => {
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;
await loadUsers();
document.getElementById('btnRefresh').addEventListener('click', loadCalls);
document.getElementById('filterUser').addEventListener('change', loadCalls);
document.getElementById('filterFrom').addEventListener('change', loadCalls);
document.getElementById('filterTo').addEventListener('change', loadCalls);
document.getElementById('filterWithoutCase').addEventListener('change', loadCalls);
await loadCalls();
});
</script>
{% endblock %}