Compare commits

...

9 Commits

17 changed files with 718 additions and 54 deletions

View File

@ -9,7 +9,7 @@ from typing import List, Dict, Optional
from datetime import datetime, date, timedelta
from decimal import Decimal
from pathlib import Path
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single
from app.core.database import execute_query, execute_insert, execute_update, execute_query_single, table_has_column
from app.core.config import settings
from app.services.economic_service import get_economic_service
from app.services.ollama_service import ollama_service
@ -710,8 +710,29 @@ async def list_supplier_invoices(
params.append(vendor_id)
if sag_id:
query += " AND si.sag_id = %s"
params.append(sag_id)
if table_has_column("supplier_invoices", "sag_id"):
query += " AND si.sag_id = %s"
params.append(sag_id)
elif (
table_has_column("supplier_invoice_relations", "supplier_invoice_id")
and table_has_column("supplier_invoice_relations", "relation_type")
and table_has_column("supplier_invoice_relations", "relation_id")
):
query += """
AND EXISTS (
SELECT 1
FROM supplier_invoice_relations sir
WHERE sir.supplier_invoice_id = si.id
AND sir.relation_type = 'sag'
AND sir.relation_id = %s
)
"""
params.append(sag_id)
else:
logger.warning(
"⚠️ supplier invoice sag filter requested, but no schema link available (sag_id column/relation table missing)"
)
query += " AND 1 = 0"
if overdue_only:
query += " AND si.due_date < CURRENT_DATE AND si.paid_date IS NULL"

View File

@ -88,8 +88,26 @@ async def get_contacts(
params = []
if search:
where_clauses.append("(c.first_name ILIKE %s OR c.last_name ILIKE %s OR c.email ILIKE %s)")
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
where_clauses.append(
"""
(
c.first_name ILIKE %s
OR c.last_name ILIKE %s
OR c.email ILIKE %s
OR c.phone ILIKE %s
OR c.mobile ILIKE %s
OR EXISTS (
SELECT 1
FROM contact_companies cc2
JOIN customers cu2 ON cu2.id = cc2.customer_id
WHERE cc2.contact_id = c.id
AND cu2.name ILIKE %s
)
)
"""
)
like = f"%{search}%"
params.extend([like, like, like, like, like, like])
if is_active is not None:
where_clauses.append("c.is_active = %s")

View File

@ -113,8 +113,27 @@ async def get_contacts(
params = []
if search:
where_clauses.append("(c.first_name ILIKE %s OR c.last_name ILIKE %s OR c.email ILIKE %s)")
params.extend([f"%{search}%", f"%{search}%", f"%{search}%"])
where_clauses.append(
"""
(
c.first_name ILIKE %s
OR c.last_name ILIKE %s
OR c.email ILIKE %s
OR c.phone ILIKE %s
OR c.mobile ILIKE %s
OR c.user_company ILIKE %s
OR EXISTS (
SELECT 1
FROM contact_companies cc2
JOIN customers cu2 ON cu2.id = cc2.customer_id
WHERE cc2.contact_id = c.id
AND cu2.name ILIKE %s
)
)
"""
)
like = f"%{search}%"
params.extend([like, like, like, like, like, like, like])
if is_active is not None:
where_clauses.append("c.is_active = %s")

View File

@ -953,7 +953,7 @@ async function loadContacts() {
totalContacts = data.total;
currentContactsData = Array.isArray(data.contacts) ? data.contacts : [];
displayContacts(currentContactsData);
updatePagination(data.total);
updatePagination(data.total, currentContactsData.length);
} catch (error) {
if (error.name === 'AbortError') {
@ -1182,11 +1182,12 @@ function persistTablePreferences() {
}
}
function updatePagination(total) {
const start = currentPage * pageSize + 1;
const end = Math.min((currentPage + 1) * pageSize, total);
function updatePagination(total, rowsOnPage = 0) {
const safeRowsOnPage = Number.isFinite(Number(rowsOnPage)) ? Math.max(0, Number(rowsOnPage)) : 0;
const start = total > 0 ? (currentPage * pageSize + 1) : 0;
const end = total > 0 ? Math.min(currentPage * pageSize + safeRowsOnPage, total) : 0;
document.getElementById('showingStart').textContent = total > 0 ? start : 0;
document.getElementById('showingStart').textContent = start;
document.getElementById('showingEnd').textContent = end;
document.getElementById('totalCount').textContent = total;

View File

@ -595,6 +595,37 @@ class MissionService:
(project_id,),
) or []
project_open_todo_count = 0
project_open_todo_titles: list[str] = []
if MissionService._table_exists("sag_todo_steps"):
project_todo_row = execute_query_single(
"""
SELECT
COUNT(*) FILTER (
WHERE t.deleted_at IS NULL
AND COALESCE(t.is_done, FALSE) = FALSE
) AS open_todo_count,
ARRAY_REMOVE(
ARRAY_AGG(
CASE
WHEN t.deleted_at IS NULL
AND COALESCE(t.is_done, FALSE) = FALSE
THEN t.title
END
ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC
),
NULL
) AS open_todo_titles
FROM sag_todo_steps t
WHERE t.sag_id = %s
""",
(project_id,),
) or {}
project_open_todo_count = int(project_todo_row.get("open_todo_count") or 0)
titles_raw = project_todo_row.get("open_todo_titles") or []
if isinstance(titles_raw, list):
project_open_todo_titles = [str(item).strip() for item in titles_raw if str(item or "").strip()]
# Fallback for case-backed projects: fetch directly related/under cases from relation table.
# This is used when a project is a case of type project/projekt and tasks are linked as case relations.
if not tasks and MissionService._table_exists("sag_relationer"):
@ -667,6 +698,8 @@ class MissionService:
"milestones": [dict(row) for row in milestones],
"blockers": [dict(row) for row in blockers],
"tasks": [dict(row) for row in tasks],
"project_open_todo_count": project_open_todo_count,
"project_open_todo_titles": project_open_todo_titles,
}
@staticmethod

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) {
@ -1328,6 +1424,10 @@
const tasks = Array.isArray(detail?.tasks) ? detail.tasks : [];
const milestones = Array.isArray(detail?.milestones) ? detail.milestones : [];
const blockers = Array.isArray(detail?.blockers) ? detail.blockers : [];
const projectOpenTodoCount = Number(detail?.project_open_todo_count || 0);
const projectOpenTodoTitles = Array.isArray(detail?.project_open_todo_titles)
? detail.project_open_todo_titles.map((item) => String(item || '').trim()).filter(Boolean)
: [];
const grouped = { todo: [], doing: [], done: [] };
tasks.forEach((task) => {
@ -1339,6 +1439,7 @@
kpis.innerHTML = [
{ label: 'Opgaver', value: tasks.length },
{ label: 'Projekt todo', value: projectOpenTodoCount },
{ label: 'Milepæle', value: milestones.length },
{ label: 'Blockers', value: blockers.length },
{ label: 'Deadline', value: formatShortDate(detail?.ended_at || detail?.deadline) },
@ -1355,11 +1456,29 @@
{ key: 'done', label: 'Lukket' },
];
const projectTodoPreview = projectOpenTodoTitles.slice(0, 5)
.map((title) => `<div>• ${escapeHtml(title)}</div>`)
.join('');
const projectTodoMore = Math.max(projectOpenTodoCount - Math.min(projectOpenTodoTitles.length, 5), 0);
const projectTodoCard = projectOpenTodoCount > 0
? `
<div class="mc-kanban-card" style="border-left:3px solid #0f4c75;">
<div class="mc-kanban-title">Projekt todo (${projectOpenTodoCount})</div>
<div class="mc-kanban-meta">
${projectTodoPreview || '<div>-</div>'}
${projectTodoMore > 0 ? `<div>+${projectTodoMore} flere</div>` : ''}
</div>
</div>
`
: '';
board.innerHTML = laneMeta.map((lane) => {
const laneTasks = grouped[lane.key] || [];
const extraCard = lane.key === 'todo' ? projectTodoCard : '';
return `
<div class="mc-kanban-col">
<h6>${escapeHtml(lane.label)} (${laneTasks.length})</h6>
<h6>${escapeHtml(lane.label)} (${laneTasks.length + (lane.key === 'todo' && projectOpenTodoCount > 0 ? 1 : 0)})</h6>
${extraCard}
${laneTasks.length ? laneTasks.map((task) => `
<div class="mc-kanban-card">
<div class="mc-kanban-title">#${Number(task.id || 0)} ${escapeHtml(task.titel || task.title || 'Uden titel')}</div>
@ -1408,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 '';
@ -1415,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';
@ -2013,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>
`;
@ -2122,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');
@ -2427,6 +2723,11 @@
});
}
document.getElementById('dayCaseQuickSaveBtn')?.addEventListener('click', () => {
saveDayCaseQuick();
resetIdleTimer();
});
['pointerdown', 'keydown', 'mousemove', 'touchstart'].forEach((name) => {
document.addEventListener(name, resetIdleTimer, { passive: true });
});

View File

@ -11,7 +11,7 @@ from datetime import datetime, timedelta, timezone
from typing import List, Optional, Dict
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_query_single, table_has_column
@ -1273,7 +1273,7 @@ async def delete_todo_step(step_id: int):
raise HTTPException(status_code=500, detail="Failed to delete todo step")
@router.patch("/sag/{sag_id:int}")
async def update_sag(sag_id: int, updates: dict):
async def update_sag(sag_id: int, updates: dict = Body(...)):
"""Update a case."""
try:
# Check if case exists
@ -2892,7 +2892,7 @@ async def get_sale_item(sag_id: int, item_id: int):
@router.patch("/sag/{sag_id}/sale-items/{item_id}")
async def update_sale_item(sag_id: int, item_id: int, updates: dict):
async def update_sale_item(sag_id: int, item_id: int, updates: dict = Body(...)):
"""Update a sale item for a case."""
try:
check = execute_query(

View File

@ -3461,7 +3461,7 @@
<div class="d-flex justify-content-between align-items-center mb-1">
<label class="mb-0 text-secondary" style="font-size:0.8rem;">Status</label>
<select id="topbarStatusSelect" class="form-select form-select-sm bg-light" style="width: 62%;">
<select id="topbarStatusSelect" class="form-select form-select-sm bg-light" style="width: 62%;" onchange="saveCaseStatusFromTopbar()">
{% for st in status_options %}
<option value="{{ st }}" {% if (case.status or '')|lower == st|lower %}selected{% endif %}>{{ st|capitalize }}</option>
{% endfor %}
@ -3708,11 +3708,6 @@
});
};
bindChange('topbarStatusSelect', async (el) => {
await patchCase({ status: el.value || 'åben' });
location.reload();
});
bindChange('topbarTypeSelect', async (el) => {
await patchCase({ type: String(el.value || 'ticket').toLowerCase() });
location.reload();

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

@ -1156,11 +1156,13 @@ class EmailWorkflowService:
if sag_id_from_tag:
if sag_id and sag_id != sag_id_from_tag:
logger.warning(
"⚠️ Email %s contains conflicting case hints (thread: SAG-%s, tag: SAG-%s). Using thread match.",
"⚠️ Email %s contains conflicting case hints (thread: SAG-%s, tag: SAG-%s). Using SAG tag.",
email_id,
sag_id,
sag_id_from_tag
)
sag_id = sag_id_from_tag
routing_source = 'sag_tag'
elif not sag_id:
sag_id = sag_id_from_tag
routing_source = 'sag_tag'

View File

@ -1267,7 +1267,7 @@ window.addEventListener('unhandledrejection', function(event) {
<script src="/static/js/tag-picker.js?v=2.2"></script>
<script src="/static/js/task-template-selector.js?v=1.1"></script>
<script src="/static/js/notifications.js?v=1.0"></script>
<script src="/static/js/telefoni.js?v=2.2"></script>
<script src="/static/js/telefoni.js?v=2.3"></script>
<script src="/static/js/sms.js?v=1.0"></script>
<script src="/static/js/bug-report.js?v=1.0"></script>
<script src="/static/js/bottom-bar.js?v=2.23"></script>

View File

@ -536,12 +536,18 @@ if __name__ == "__main__":
log_level="info"
)
else:
api_workers_raw = os.getenv("API_WORKERS", "1").strip()
try:
api_workers = max(1, int(api_workers_raw))
except ValueError:
api_workers = 1
uvicorn.run(
"main:app",
host="0.0.0.0",
port=8000,
reload=False,
workers=2,
workers=api_workers,
timeout_keep_alive=65,
access_log=True,
log_level="info"

View File

@ -0,0 +1,48 @@
-- Migration 190: Align sag_sager status constraint with current case status model
-- Fixes PATCH /api/v1/sag/{id} failures when using statuses beyond 'åben'/'lukket'.
DO $$
DECLARE
constraint_row RECORD;
BEGIN
-- Drop legacy check constraints on sag_sager.status regardless of their generated name.
FOR constraint_row IN
SELECT c.conname
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'sag_sager'
AND c.contype = 'c'
AND pg_get_constraintdef(c.oid) ILIKE '%status%'
LOOP
EXECUTE format('ALTER TABLE public.sag_sager DROP CONSTRAINT IF EXISTS %I', constraint_row.conname);
END LOOP;
END $$;
UPDATE public.sag_sager
SET status = CASE
WHEN lower(trim(status)) IN ('aaben', 'open') THEN 'åben'
WHEN lower(trim(status)) IN ('i_gang', 'in_progress', 'under behandling') THEN 'under behandling'
WHEN lower(trim(status)) IN ('on_hold', 'waiting', 'afventer') THEN 'afventer'
WHEN lower(trim(status)) IN ('resolved', 'løst') THEN 'løst'
WHEN lower(trim(status)) IN ('closed', 'afsluttet', 'lukket') THEN 'lukket'
ELSE status
END;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE n.nspname = 'public'
AND t.relname = 'sag_sager'
AND c.conname = 'sag_sager_status_check'
) THEN
ALTER TABLE public.sag_sager
ADD CONSTRAINT sag_sager_status_check
CHECK (status IN ('åben', 'under behandling', 'afventer', 'løst', 'lukket'));
END IF;
END $$;

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;
}
}
});
}
@ -180,18 +226,16 @@
}
function connect() {
if (ws && ws.readyState === WebSocket.OPEN) {
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
return;
}
const token = getToken();
if (!token) {
scheduleReconnect();
return;
}
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
const url = `${proto}://${window.location.host}/api/v1/telefoni/ws?token=${encodeURIComponent(token)}`;
// Fallback to cookie-auth websocket when token is HttpOnly and cannot be read by JS.
const url = token
? `${proto}://${window.location.host}/api/v1/telefoni/ws?token=${encodeURIComponent(token)}`
: `${proto}://${window.location.host}/api/v1/telefoni/ws`;
ws = new WebSocket(url);
ws.onopen = () => console.log('📞 Telefoni WS connected');