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 %} +
+ {{ (call.contact_name or ('Kontakt #' ~ call.kontakt_id))|trim }} + + +
{% else %} - - +
+ +
+ {% endif %} + + + {% if call.sag_id %} +
+ {{ call.sag_titel or ('Sag #' ~ 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 = {}) { ? `
${escapeHtml(r.contact_name || ('Kontakt #' + r.kontakt_id))} ${canMutateCall - ? ` - ` + ? ` + ` : 'Historik'}
${r.contact_company ? `
${escapeHtml(r.contact_company)}
` : ''}` : (canMutateCall - ? `` : 'Historisk opkald'); - 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 = {}) { ? `
${escapeHtml(r.sag_titel || ('Sag #' + r.sag_id))} ${canMutateCall - ? ` - ` + ? ` + ` : 'Historik'}
` : (canMutateCall ? `
- Opret sag - + +
` : 'Ingen sag'); @@ -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'); diff --git a/static/js/telefoni.js b/static/js/telefoni.js index c579093..4a5c21f 100644 --- a/static/js/telefoni.js +++ b/static/js/telefoni.js @@ -143,6 +143,7 @@
${openContactBtn} +
`; @@ -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; + } + } }); }