2026-02-14 02:26:29 +01:00
|
|
|
|
{% 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>
|
2026-05-06 07:01:43 +02:00
|
|
|
|
<tbody id="telefoniRows">
|
|
|
|
|
|
<tr><td colspan="7" class="text-muted small">Indlæser...</td></tr>
|
2026-02-14 02:26:29 +01:00
|
|
|
|
</tbody>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-02 11:02:29 +02:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-02-14 02:26:29 +01:00
|
|
|
|
<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('&', '&')
|
|
|
|
|
|
.replaceAll('<', '<')
|
|
|
|
|
|
.replaceAll('>', '>')
|
|
|
|
|
|
.replaceAll('"', '"')
|
|
|
|
|
|
.replaceAll("'", ''');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
let telefoniCurrentUserId = null;
|
|
|
|
|
|
const telefoniCallMap = new Map();
|
|
|
|
|
|
const linkSagState = {
|
|
|
|
|
|
callId: null,
|
|
|
|
|
|
selectedSagId: null,
|
|
|
|
|
|
selectedLabel: '',
|
|
|
|
|
|
searchTimer: null,
|
|
|
|
|
|
searchToken: 0,
|
|
|
|
|
|
modal: null
|
|
|
|
|
|
};
|
2026-05-02 11:02:29 +02:00
|
|
|
|
const linkContactState = {
|
|
|
|
|
|
callId: null,
|
|
|
|
|
|
selectedContactId: null,
|
|
|
|
|
|
selectedLabel: '',
|
|
|
|
|
|
searchTimer: null,
|
|
|
|
|
|
companySearchTimer: null,
|
|
|
|
|
|
searchToken: 0,
|
|
|
|
|
|
companySearchToken: 0,
|
|
|
|
|
|
modal: null,
|
|
|
|
|
|
number: '',
|
|
|
|
|
|
mode: 'contact',
|
|
|
|
|
|
selectedCompanyId: null,
|
|
|
|
|
|
selectedCompanyLabel: ''
|
|
|
|
|
|
};
|
2026-02-14 02:26:29 +01:00
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-02 11:02:29 +02:00
|
|
|
|
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 {
|
2026-05-06 07:01:43 +02:00
|
|
|
|
const qs = new URLSearchParams({ search: query || '', limit: '30', offset: '0' });
|
|
|
|
|
|
const res = await fetch(`/api/v1/contacts?${qs.toString()}`, { credentials: 'include' });
|
2026-05-02 11:02:29 +02:00
|
|
|
|
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();
|
2026-05-06 07:01:43 +02:00
|
|
|
|
const results = Array.isArray(data?.contacts) ? data.contacts : [];
|
2026-05-02 11:02:29 +02:00
|
|
|
|
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'));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-14 02:26:29 +01:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-03-05 08:41:59 +01:00
|
|
|
|
sel.value = '';
|
2026-02-14 02:26:29 +01:00
|
|
|
|
} 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>';
|
|
|
|
|
|
|
2026-05-06 07:01:43 +02:00
|
|
|
|
const userId = document.getElementById('filterUser').value;
|
|
|
|
|
|
const from = document.getElementById('filterFrom').value;
|
|
|
|
|
|
const to = document.getElementById('filterTo').value;
|
|
|
|
|
|
const withoutCase = document.getElementById('filterWithoutCase').checked;
|
2026-02-14 02:26:29 +01:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-02 11:02:29 +02:00
|
|
|
|
? `<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>
|
2026-05-06 07:01:43 +02:00
|
|
|
|
<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>
|
2026-05-02 11:02:29 +02:00
|
|
|
|
</div>
|
|
|
|
|
|
${r.contact_company ? `<div class="text-muted small">${escapeHtml(r.contact_company)}</div>` : ''}`
|
2026-05-06 07:01:43 +02:00
|
|
|
|
: `<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>`;
|
2026-02-14 02:26:29 +01:00
|
|
|
|
|
|
|
|
|
|
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">
|
feat: Update sag links to include versioning in URLs across multiple templates and services
- Updated links in index_old.html, varekob_salg.html, log.html, opportunities.html, detail.html, and various frontend files to point to the new versioned sag URLs.
- Modified reminder_notification_service.py to reflect the new sag URL structure in notifications.
- Added FedEx shipment management functionality, including API client, service layer, and router for handling FedEx bookings, tracking, and cancellations.
- Created database migration for FedEx shipments, including tables for shipments, packages, and tracking events.
2026-04-30 23:06:00 +02:00
|
|
|
|
<a href="/sag/${r.sag_id}/v3">${escapeHtml(r.sag_titel || ('Sag #' + r.sag_id))}</a>
|
2026-02-14 02:26:29 +01:00
|
|
|
|
<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 () => {
|
2026-05-02 11:02:29 +02:00
|
|
|
|
initLinkContactModalEvents();
|
2026-02-14 02:26:29 +01:00
|
|
|
|
initLinkSagModalEvents();
|
2026-03-05 08:41:59 +01:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-02-14 02:26:29 +01:00
|
|
|
|
await loadUsers();
|
2026-05-06 07:01:43 +02:00
|
|
|
|
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);
|
2026-02-14 02:26:29 +01:00
|
|
|
|
await loadCalls();
|
|
|
|
|
|
});
|
|
|
|
|
|
</script>
|
|
|
|
|
|
{% endblock %}
|