diff --git a/app/dashboard/frontend/mission_control_v2.html b/app/dashboard/frontend/mission_control_v2.html
index 7376eb9..c18b8f6 100644
--- a/app/dashboard/frontend/mission_control_v2.html
+++ b/app/dashboard/frontend/mission_control_v2.html
@@ -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 @@
+
@@ -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 @@
-
+
+
+
+
+
+
`;
@@ -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 = [
+ '
',
+ ...(state.assignmentUsers || []).map((user) => (`
+
+ `)),
+ ];
+ const groupOptions = [
+ '
',
+ ...(state.assignmentGroups || []).map((group) => (`
+
+ `)),
+ ];
+
+ 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 });
});
diff --git a/app/modules/telefoni/backend/router.py b/app/modules/telefoni/backend/router.py
index 1c77c88..6e7331e 100644
--- a/app/modules/telefoni/backend/router.py
+++ b/app/modules/telefoni/backend/router.py
@@ -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
diff --git a/app/modules/telefoni/backend/utils.py b/app/modules/telefoni/backend/utils.py
index 63b4e1e..ae832da 100644
--- a/app/modules/telefoni/backend/utils.py
+++ b/app/modules/telefoni/backend/utils.py
@@ -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:
diff --git a/app/modules/telefoni/frontend/views.py b/app/modules/telefoni/frontend/views.py
index a4d8f4e..18aa365 100644
--- a/app/modules/telefoni/frontend/views.py
+++ b/app/modules/telefoni/frontend/views.py
@@ -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)
diff --git a/app/modules/telefoni/templates/log.html b/app/modules/telefoni/templates/log.html
index e62c4c1..3e4cb72 100644
--- a/app/modules/telefoni/templates/log.html
+++ b/app/modules/telefoni/templates/log.html
@@ -61,23 +61,73 @@
{% if initial_calls and initial_calls|length > 0 %}
{% for call in initial_calls %}
-
+
| {{ call.started_at or '-' }} |
{{ call.full_name or call.username or '-' }} |
{% if call.direction == 'outbound' %}Udgående{% else %}Indgående{% endif %} |
- {{ call.display_number or '-' }} |
- {% if call.kontakt_id %}
- {{ (call.contact_name or ('Kontakt #' ~ call.kontakt_id))|trim }}
+ {% if call.display_number %}
+
+ {{ call.display_number }}
+
+
{% else %}
-
{% endif %}
|
- {% if call.sag_id %}
- {{ call.sag_titel or ('Sag #' ~ call.sag_id) }}
+ {% if call.kontakt_id %}
+
{% else %}
- -
+
+
+
+ {% endif %}
+ |
+
+ {% if call.sag_id %}
+
+ {% else %}
+
{% endif %}
|
{{ call.duration_sec if call.duration_sec is not none else '-' }} |
@@ -203,6 +253,45 @@ function escapeHtml(str) {
.replaceAll("'", ''');
}
+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
? `
${escapeHtml(numberRaw)}
@@ -952,18 +1081,18 @@ async function loadCalls(options = {}) {
? `
${r.contact_company ? `
${escapeHtml(r.contact_company)}
` : ''}`
: (canMutateCall
- ? `
`;
@@ -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;
+ }
+ }
});
}