feat(telefoni): enhance call handling with external number normalization and case linking

This commit is contained in:
Christian 2026-06-09 00:05:28 +02:00
parent c019a0367b
commit 592ed8640d
6 changed files with 522 additions and 22 deletions

View File

@ -805,6 +805,16 @@
padding: 0.45rem 0.8rem;
}
.mc-day-actions {
grid-template-columns: 1fr;
}
.mc-day-touch-btn {
min-height: 56px;
font-size: 1rem;
padding: 0.55rem 0.82rem;
}
.mc-case-link,
.mc-email-link {
display: inline-flex;
@ -829,6 +839,46 @@
cursor: not-allowed;
}
.mc-day-actions {
margin-top: 0.52rem;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.45rem;
}
.mc-day-touch-btn {
border: 1px solid rgba(126, 194, 239, 0.45);
border-radius: 10px;
background: rgba(15, 76, 117, 0.45);
color: #dff3ff;
font-size: 0.82rem;
font-weight: 800;
min-height: 44px;
padding: 0.42rem 0.58rem;
touch-action: manipulation;
}
.mc-day-touch-btn.secondary {
border-color: rgba(157, 181, 210, 0.35);
background: rgba(255, 255, 255, 0.05);
color: #e3f1ff;
}
.mc-day-touch-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mc-day-modal-body {
display: grid;
gap: 0.75rem;
}
.mc-day-modal-meta {
font-size: 0.86rem;
color: var(--mc-text-muted);
}
.mc-day-agents {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@ -1125,6 +1175,50 @@
</div>
</div>
<div class="modal fade" id="dayCaseQuickModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="dayCaseQuickTitle">Sag detaljer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body mc-day-modal-body">
<div class="mc-day-modal-meta" id="dayCaseQuickMeta">Vaelg en sag fra listen.</div>
<div>
<label for="dayCaseQuickUser" class="form-label">Ansvarlig medarbejder</label>
<select id="dayCaseQuickUser" class="form-select"></select>
</div>
<div>
<label for="dayCaseQuickGroup" class="form-label">Gruppe</label>
<select id="dayCaseQuickGroup" class="form-select"></select>
</div>
<div class="row g-2">
<div class="col-md-6">
<label for="dayCaseQuickStart" class="form-label">Start</label>
<input id="dayCaseQuickStart" type="datetime-local" class="form-control">
</div>
<div class="col-md-6">
<label for="dayCaseQuickDeadline" class="form-label">Deadline</label>
<input id="dayCaseQuickDeadline" type="datetime-local" class="form-control">
</div>
</div>
<div>
<label for="dayCaseQuickDesc" class="form-label">Beskrivelse</label>
<textarea id="dayCaseQuickDesc" class="form-control" rows="4" readonly></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-primary" id="dayCaseQuickSaveBtn">Gem aendringer</button>
</div>
</div>
</div>
</div>
<div id="view-calls" class="mc-view">
<div class="mc-view-grid">
<div class="mc-card">
@ -1234,6 +1328,8 @@
renderedCameraUrl: null,
renderedCameraMode: null,
selectedProjectId: null,
selectedDayCaseId: null,
dayCaseModal: null,
};
function escapeHtml(str) {
@ -1431,6 +1527,14 @@
return Number.isFinite(parsed) ? parsed : null;
}
function toDatetimeLocalValue(value) {
if (!value) return '';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '';
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function truncateText(value, maxLength = 180) {
const raw = String(value || '').trim();
if (!raw) return '';
@ -1438,6 +1542,23 @@
return `${raw.slice(0, maxLength - 1)}...`;
}
function getDayCaseById(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return null;
const source = Array.isArray(state.dayUnassignedCases) ? state.dayUnassignedCases : [];
const found = source.find((item) => Number(item.id || 0) === id);
return found || null;
}
function getOrCreateDayCaseModal() {
if (state.dayCaseModal) return state.dayCaseModal;
const el = document.getElementById('dayCaseQuickModal');
if (!el || !window.bootstrap) return null;
state.dayCaseModal = new bootstrap.Modal(el);
return state.dayCaseModal;
}
function getEmailHref(emailId) {
const id = Number(emailId || 0);
if (!Number.isFinite(id) || id <= 0) return '/emails';
@ -2036,7 +2157,12 @@
<select class="mc-day-select" id="assignGroup-${caseId}">
${groupSelectOptions}
</select>
<button type="button" class="mc-day-btn" onclick="quickAssignCase(${caseId})">Tildel</button>
<button type="button" class="mc-day-btn" onclick="quickAssignCase(${caseId})">Tildel begge</button>
</div>
<div class="mc-day-actions">
<button type="button" class="mc-day-touch-btn" onclick="quickAssignUser(${caseId})">Tildel medarbejder</button>
<button type="button" class="mc-day-touch-btn" onclick="quickAssignGroup(${caseId})">Tildel gruppe</button>
<button type="button" class="mc-day-touch-btn secondary" onclick="openDayCaseQuick(${caseId})">Mere info</button>
</div>
</div>
`;
@ -2145,7 +2271,154 @@
}
}
async function patchDayCase(caseId, payload, button, buttonText = 'Gemmer...') {
if (!payload || typeof payload !== 'object') return;
const btn = button || null;
const originalText = btn ? btn.textContent : null;
if (btn) {
btn.disabled = true;
btn.textContent = buttonText;
}
try {
const res = await fetch(`/api/v1/sag/${caseId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.detail || `HTTP ${res.status}`);
}
await loadInitialState();
} catch (error) {
alert(`Kunne ikke opdatere sag: ${error?.message || 'ukendt fejl'}`);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = originalText || 'Gem';
}
}
}
async function quickAssignUser(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return;
const userSelect = document.getElementById(`assignUser-${id}`);
const userId = toOptionalInt(userSelect?.value);
if (userId === null) {
alert('Vaelg en medarbejder foerst.');
return;
}
const card = document.getElementById(`dayCase-${id}`);
const button = card?.querySelector('.mc-day-actions .mc-day-touch-btn');
await patchDayCase(id, { ansvarlig_bruger_id: userId }, button, 'Tildeler...');
}
async function quickAssignGroup(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return;
const groupSelect = document.getElementById(`assignGroup-${id}`);
const groupId = toOptionalInt(groupSelect?.value);
if (groupId === null) {
alert('Vaelg en gruppe foerst.');
return;
}
const card = document.getElementById(`dayCase-${id}`);
const buttons = card?.querySelectorAll('.mc-day-actions .mc-day-touch-btn');
const button = buttons && buttons.length > 1 ? buttons[1] : null;
await patchDayCase(id, { assigned_group_id: groupId }, button, 'Tildeler...');
}
function openDayCaseQuick(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return;
const item = getDayCaseById(id);
if (!item) {
alert('Kunne ikke finde sag i Dagen-listen.');
return;
}
const title = document.getElementById('dayCaseQuickTitle');
const meta = document.getElementById('dayCaseQuickMeta');
const userSelect = document.getElementById('dayCaseQuickUser');
const groupSelect = document.getElementById('dayCaseQuickGroup');
const startInput = document.getElementById('dayCaseQuickStart');
const deadlineInput = document.getElementById('dayCaseQuickDeadline');
const descInput = document.getElementById('dayCaseQuickDesc');
if (!title || !meta || !userSelect || !groupSelect || !startInput || !deadlineInput || !descInput) {
return;
}
state.selectedDayCaseId = id;
title.textContent = `#${id} ${item.titel || 'Uden titel'}`;
meta.textContent = `${item.customer_name || 'Ukendt kunde'} • Status: ${item.status || '-'} • Type: ${item.case_type || item.template_key || '-'}`;
descInput.value = String(item.beskrivelse || '').trim();
const userOptions = [
'<option value="">Ingen medarbejder</option>',
...(state.assignmentUsers || []).map((user) => (`
<option value="${Number(user.user_id)}">${escapeHtml(user.display_name || 'Ukendt')}</option>
`)),
];
const groupOptions = [
'<option value="">Ingen gruppe</option>',
...(state.assignmentGroups || []).map((group) => (`
<option value="${Number(group.id)}">${escapeHtml(group.name || 'Ukendt')}</option>
`)),
];
userSelect.innerHTML = userOptions.join('');
groupSelect.innerHTML = groupOptions.join('');
userSelect.value = Number(item.ansvarlig_bruger_id || 0) > 0 ? String(Number(item.ansvarlig_bruger_id)) : '';
groupSelect.value = Number(item.assigned_group_id || 0) > 0 ? String(Number(item.assigned_group_id)) : '';
startInput.value = toDatetimeLocalValue(item.start_date);
deadlineInput.value = toDatetimeLocalValue(item.deadline);
const modal = getOrCreateDayCaseModal();
if (modal) modal.show();
}
async function saveDayCaseQuick() {
const id = Number(state.selectedDayCaseId || 0);
if (!Number.isFinite(id) || id <= 0) return;
const userSelect = document.getElementById('dayCaseQuickUser');
const groupSelect = document.getElementById('dayCaseQuickGroup');
const startInput = document.getElementById('dayCaseQuickStart');
const deadlineInput = document.getElementById('dayCaseQuickDeadline');
const saveBtn = document.getElementById('dayCaseQuickSaveBtn');
if (!userSelect || !groupSelect || !startInput || !deadlineInput) return;
const payload = {
ansvarlig_bruger_id: toOptionalInt(userSelect.value),
assigned_group_id: toOptionalInt(groupSelect.value),
start_date: startInput.value ? new Date(startInput.value).toISOString() : null,
deadline: deadlineInput.value ? new Date(deadlineInput.value).toISOString() : null,
};
await patchDayCase(id, payload, saveBtn, 'Gemmer...');
const modal = getOrCreateDayCaseModal();
if (modal) modal.hide();
}
window.quickAssignCase = quickAssignCase;
window.quickAssignUser = quickAssignUser;
window.quickAssignGroup = quickAssignGroup;
window.openDayCaseQuick = openDayCaseQuick;
function renderEnvironmentReadings() {
const container = document.getElementById('environmentReadings');
@ -2450,6 +2723,11 @@
});
}
document.getElementById('dayCaseQuickSaveBtn')?.addEventListener('click', () => {
saveDayCaseQuick();
resetIdleTimer();
});
['pointerdown', 'keydown', 'mousemove', 'touchstart'].forEach((name) => {
document.addEventListener(name, resetIdleTimer, { passive: true });
});

View File

@ -23,6 +23,7 @@ from .utils import (
digits_only,
extract_extension,
ip_in_whitelist,
normalize_external_number,
is_outbound_call,
normalize_e164,
phone_suffix_8,
@ -280,8 +281,9 @@ async def yealink_established(
if candidate:
ekstern_raw = candidate
break
ekstern_e164 = normalize_e164(ekstern_raw)
ekstern_value = ekstern_e164 or ((ekstern_raw or "").strip() or None)
ekstern_normalized = normalize_external_number(ekstern_raw)
ekstern_e164 = normalize_e164(ekstern_normalized or ekstern_raw)
ekstern_value = ekstern_normalized or ((ekstern_raw or "").strip() or None)
user_ids = TelefoniService.find_user_by_extension(local_extension)
@ -734,6 +736,11 @@ async def list_calls(
params.extend([limit, offset])
rows = execute_query(query, tuple(params)) or []
for row in rows:
display_raw = row.get("display_number") or row.get("ekstern_nummer")
repaired = normalize_external_number(display_raw)
if repaired:
row["display_number"] = repaired
if rows:
return rows

View File

@ -39,6 +39,39 @@ def normalize_e164(number: Optional[str]) -> Optional[str]:
return None
def normalize_external_number(number: Optional[str]) -> Optional[str]:
"""Normalize external numbers and recover malformed concatenated callback values.
E.164 allows max 15 digits after '+'. If payload contains longer values,
we treat it as malformed concatenation and recover from the right-most part.
"""
if not number:
return None
raw = number.strip().replace(" ", "").replace("-", "")
if not raw:
return None
d = digits_only(raw)
if not d:
return None
if len(d) > 15:
# Malformed concatenation observed from callbacks.
# Prefer the leading Danish segment (user-confirmed cases like 53125928...),
# then fall back to right-most recovery.
if len(d) >= 10 and d[:2] == "45":
return "+" + d[:10]
if len(d) >= 8 and d[0] in "23456789":
return "+45" + d[:8]
if len(d) >= 10 and d[-10:].startswith("45"):
return "+" + d[-10:]
if len(d) >= 8:
return "+45" + d[-8:]
return normalize_e164(raw) or raw
def phone_suffix_8(number: Optional[str]) -> Optional[str]:
d = digits_only(number)
if len(d) < 8:

View File

@ -4,6 +4,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.core.database import execute_query
from app.modules.telefoni.backend.utils import normalize_external_number
logger = logging.getLogger(__name__)
router = APIRouter()
@ -40,6 +41,11 @@ async def telefoni_log_page(request: Request):
""",
(),
) or []
for row in initial_calls:
display_raw = row.get("display_number")
repaired = normalize_external_number(display_raw)
if repaired:
row["display_number"] = repaired
except Exception as e:
logger.warning("⚠️ Could not load initial telefoni calls for SSR fallback: %s", e)

View File

@ -61,23 +61,73 @@
<tbody id="telefoniRows">
{% if initial_calls and initial_calls|length > 0 %}
{% for call in initial_calls %}
<tr data-call-id="{{ call.id }}">
<tr
data-call-id="{{ call.id }}"
data-direction="{{ call.direction or '' }}"
data-display-number="{{ call.display_number or '' }}"
data-ekstern-nummer="{{ call.display_number or '' }}"
data-started-at="{{ call.started_at or '' }}"
data-ended-at="{{ call.ended_at or '' }}"
data-duration-sec="{{ call.duration_sec if call.duration_sec is not none else '' }}"
data-kontakt-id="{{ call.kontakt_id if call.kontakt_id is not none else '' }}"
data-contact-name="{{ call.contact_name or '' }}"
data-sag-id="{{ call.sag_id if call.sag_id is not none else '' }}"
data-sag-titel="{{ call.sag_titel or '' }}"
data-full-name="{{ call.full_name or '' }}"
data-username="{{ call.username or '' }}"
>
<td>{{ call.started_at or '-' }}</td>
<td>{{ call.full_name or call.username or '-' }}</td>
<td>{% if call.direction == 'outbound' %}Udgående{% else %}Indgående{% endif %}</td>
<td>{{ call.display_number or '-' }}</td>
<td>
{% if call.kontakt_id %}
<a href="/contacts/{{ call.kontakt_id }}">{{ (call.contact_name or ('Kontakt #' ~ call.kontakt_id))|trim }}</a>
{% if call.display_number %}
<div class="d-flex gap-2 align-items-center flex-wrap">
<span>{{ call.display_number }}</span>
<button type="button" class="btn btn-sm btn-outline-success" onclick="callViaYealink('{{ call.display_number|replace("'", "\\'") }}')">Ring op</button>
</div>
{% else %}
-
{% endif %}
</td>
<td>
{% if call.sag_id %}
<a href="/sag/{{ call.sag_id }}/v3">{{ call.sag_titel or ('Sag #' ~ call.sag_id) }}</a>
{% if call.kontakt_id %}
<div class="d-flex align-items-center gap-2 flex-wrap">
<a href="/contacts/{{ call.kontakt_id }}">{{ (call.contact_name or ('Kontakt #' ~ call.kontakt_id))|trim }}</a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal({{ call.id }})" title="Skift kontakt / opret kontakt">
<i class="bi bi-person-gear"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkContact({{ call.id }})" title="Fjern kontakt-link">
<i class="bi bi-x-circle"></i>
</button>
</div>
{% else %}
-
<div class="d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal({{ call.id }})" title="Tilknyt eller opret kontakt">
<i class="bi bi-person-plus"></i>
</button>
</div>
{% endif %}
</td>
<td>
{% if call.sag_id %}
<div class="d-flex gap-2 align-items-center flex-wrap">
<a href="/sag/{{ call.sag_id }}/v3">{{ call.sag_titel or ('Sag #' ~ call.sag_id) }}</a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase({{ call.id }})" title="Skift sag-link">
<i class="bi bi-link-45deg"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkCase({{ call.id }})" title="Fjern sag-link">
<i class="bi bi-x-circle"></i>
</button>
</div>
{% else %}
<div class="d-flex gap-2">
<a class="btn btn-sm btn-outline-primary" href="/sag/new?telefoni_opkald_id={{ call.id }}{% if call.kontakt_id %}&contact_id={{ call.kontakt_id }}{% endif %}" title="Opret ny sag">
<i class="bi bi-folder-plus"></i>
</a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase({{ call.id }})" title="Tilknyt eksisterende sag">
<i class="bi bi-link-45deg"></i>
</button>
</div>
{% endif %}
</td>
<td class="text-end">{{ call.duration_sec if call.duration_sec is not none else '-' }}</td>
@ -203,6 +253,45 @@ function escapeHtml(str) {
.replaceAll("'", '&#039;');
}
function normalizeDisplayNumber(value) {
const raw = String(value || '').trim();
if (!raw) return '';
// Internal SIP identifiers should not be shown as external caller numbers.
if (/^sip:/i.test(raw)) {
const sipDigits = raw.replace(/\D+/g, '');
if (sipDigits.length <= 6) return '';
}
const digits = raw.replace(/\D+/g, '');
if (!digits) return raw;
// E.164 max is 15 digits (excluding +). Longer values are malformed/concatenated.
if (digits.length > 15) {
if (digits.length >= 10 && digits.slice(0, 2) === '45') {
return `+${digits.slice(0, 10)}`;
}
if (digits.length >= 8 && /[2-9]/.test(digits.charAt(0))) {
return `+45${digits.slice(0, 8)}`;
}
if (digits.length >= 10 && digits.slice(-10).startsWith('45')) {
return `+${digits.slice(-10)}`;
}
if (digits.length >= 8) {
return `+45${digits.slice(-8)}`;
}
}
if (raw.startsWith('+')) {
return digits.length >= 9 ? `+${digits}` : raw;
}
if (digits.length === 8) return `+45${digits}`;
if (digits.length === 10 && digits.startsWith('45')) return `+${digits}`;
if (digits.length >= 9) return `+${digits}`;
return raw;
}
let telefoniCurrentUserId = null;
const telefoniCallMap = new Map();
const linkSagState = {
@ -850,6 +939,40 @@ function hasExistingCallRows(tbody) {
return Boolean(tbody && tbody.querySelector('tr[data-call-id]'));
}
function hydrateCallMapFromSsrRows() {
const rows = document.querySelectorAll('#telefoniRows tr[data-call-id]');
if (!rows.length) return;
rows.forEach((row) => {
const callId = Number(row.dataset.callId);
if (!Number.isInteger(callId) || callId <= 0) return;
const toIntOrNull = (value) => {
const n = Number(value);
return Number.isInteger(n) && n > 0 ? n : null;
};
const durationRaw = String(row.dataset.durationSec || '').trim();
const durationSec = durationRaw === '' ? null : Number(durationRaw);
telefoniCallMap.set(callId, {
id: callId,
direction: String(row.dataset.direction || '').trim() || null,
display_number: normalizeDisplayNumber(String(row.dataset.displayNumber || '').trim()) || null,
ekstern_nummer: normalizeDisplayNumber(String(row.dataset.eksternNummer || '').trim()) || null,
started_at: String(row.dataset.startedAt || '').trim() || null,
ended_at: String(row.dataset.endedAt || '').trim() || null,
duration_sec: Number.isFinite(durationSec) ? durationSec : null,
kontakt_id: toIntOrNull(row.dataset.kontaktId),
contact_name: String(row.dataset.contactName || '').trim() || null,
sag_id: toIntOrNull(row.dataset.sagId),
sag_titel: String(row.dataset.sagTitel || '').trim() || null,
full_name: String(row.dataset.fullName || '').trim() || null,
username: String(row.dataset.username || '').trim() || null,
});
});
}
function cacheInitialSsrRows(tbody) {
if (!tbody) return;
if (tbody.dataset.initialRowsCached === '1') return;
@ -919,7 +1042,13 @@ async function loadCalls(options = {}) {
}
const rows = await res.json();
telefoniCallMap.clear();
(rows || []).forEach(r => telefoniCallMap.set(Number(r.id), r));
(rows || []).forEach(r => {
const fixedDisplay = normalizeDisplayNumber(r.display_number || r.ekstern_nummer || '');
const fixedExternal = normalizeDisplayNumber(r.ekstern_nummer || r.display_number || '');
r.display_number = fixedDisplay || null;
r.ekstern_nummer = fixedExternal || null;
telefoniCallMap.set(Number(r.id), r);
});
if (!rows || rows.length === 0) {
if (preserveOnEmpty && hadRowsBeforeLoad && noActiveFilters) {
console.warn('Telefoni: API returnerede 0 rækker, bevarer eksisterende SSR-visning');
@ -940,7 +1069,7 @@ async function loadCalls(options = {}) {
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 numberRaw = normalizeDisplayNumber(r.display_number || r.ekstern_nummer || '');
const numTxt = numberRaw
? `<div class="d-flex gap-2 align-items-center flex-wrap">
<span>${escapeHtml(numberRaw)}</span>
@ -952,18 +1081,18 @@ async function loadCalls(options = {}) {
? `<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>
${canMutateCall
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${callId})">Skift</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkContact(${callId})">Fjern</button>`
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${callId})" title="Skift kontakt / opret kontakt"><i class="bi bi-person-gear"></i></button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkContact(${callId})" title="Fjern kontakt-link"><i class="bi bi-x-circle"></i></button>`
: '<span class="badge bg-light text-muted border">Historik</span>'}
</div>
${r.contact_company ? `<div class="text-muted small">${escapeHtml(r.contact_company)}</div>` : ''}`
: (canMutateCall
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${callId})" title="Vælg kontakt/firma">
<i class="bi bi-three-dots"></i>
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="openLinkContactModal(${callId})" title="Tilknyt eller opret kontakt">
<i class="bi bi-person-plus"></i>
</button>`
: '<span class="text-muted small">Historisk opkald</span>');
const numberForTitle = (r.display_number || r.ekstern_nummer || '').trim();
const numberForTitle = normalizeDisplayNumber(r.display_number || r.ekstern_nummer || '');
const createQs = new URLSearchParams();
if (r.kontakt_id) createQs.set('contact_id', String(r.kontakt_id));
if (canMutateCall) createQs.set('telefoni_opkald_id', String(callId));
@ -973,14 +1102,14 @@ async function loadCalls(options = {}) {
? `<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>
${canMutateCall
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase(${callId})">Skift link</button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkCase(${callId})">Fjern link</button>`
? `<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase(${callId})" title="Skift sag-link"><i class="bi bi-link-45deg"></i></button>
<button type="button" class="btn btn-sm btn-outline-danger" onclick="unlinkCase(${callId})" title="Fjern sag-link"><i class="bi bi-x-circle"></i></button>`
: '<span class="badge bg-light text-muted border">Historik</span>'}
</div>`
: (canMutateCall
? `<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(${callId})">Link sag</button>
<a class="btn btn-sm btn-outline-primary" href="/sag/new?${createQs.toString()}" title="Opret ny sag"><i class="bi bi-folder-plus"></i></a>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="linkExistingCase(${callId})" title="Tilknyt eksisterende sag"><i class="bi bi-link-45deg"></i></button>
</div>`
: '<span class="text-muted small">Ingen sag</span>');
@ -1066,6 +1195,7 @@ async function unlinkCase(callId) {
document.addEventListener('DOMContentLoaded', async () => {
const telefoniRows = document.getElementById('telefoniRows');
cacheInitialSsrRows(telefoniRows);
hydrateCallMapFromSsrRows();
initLinkContactModalEvents();
initLinkSagModalEvents();
const userFilter = document.getElementById('filterUser');

View File

@ -143,6 +143,7 @@
<div class="d-flex gap-2 mt-3">
${openContactBtn}
<button type="button" class="btn btn-sm btn-primary" data-action="create-case">Opret sag</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-action="link-case">Link sag</button>
</div>
</div>
`;
@ -153,7 +154,30 @@
toastEl.addEventListener('hidden.bs.toast', () => toastEl.remove());
toastEl.addEventListener('click', (e) => {
async function patchCallCase(caseId) {
const parsedCaseId = Number(caseId);
if (!Number.isInteger(parsedCaseId) || parsedCaseId <= 0) {
throw new Error('Ugyldigt sag-ID');
}
if (!Number.isInteger(Number(callId)) || Number(callId) <= 0) {
throw new Error('Mangler call_id');
}
const res = await fetch(`/api/v1/telefoni/calls/${Number(callId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ sag_id: parsedCaseId })
});
if (!res.ok) {
const t = await res.text();
throw new Error(t || `HTTP ${res.status}`);
}
return parsedCaseId;
}
toastEl.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn) return;
const action = btn.getAttribute('data-action');
@ -168,6 +192,28 @@
qs.set('telefoni_opkald_id', String(callId));
window.location.href = `/sag/new?${qs.toString()}`;
}
if (action === 'link-case') {
const answer = window.prompt('Indtast eksisterende sag-ID, som opkaldet skal linkes til:');
if (answer === null) return;
const caseId = Number(String(answer).trim());
if (!Number.isInteger(caseId) || caseId <= 0) {
window.alert('Ugyldigt sag-ID');
return;
}
btn.disabled = true;
const originalText = btn.textContent;
btn.textContent = 'Gemmer...';
try {
const linkedCaseId = await patchCallCase(caseId);
window.location.href = `/sag/${linkedCaseId}/v3`;
} catch (err) {
window.alert(`Kunne ikke linke sag: ${err?.message || 'ukendt fejl'}`);
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
}
});
}