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

524 lines
21 KiB
HTML
Raw Normal View History

{% 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 %}