diff --git a/MDfile/RELEASE_NOTES_v2.5.1.md b/MDfile/RELEASE_NOTES_v2.5.1.md new file mode 100644 index 0000000..e84a4bf --- /dev/null +++ b/MDfile/RELEASE_NOTES_v2.5.1.md @@ -0,0 +1,34 @@ +# Release Notes: v2.5.1 + +**Dato:** 18. august 2026 + +## Overblik + +Version 2.5.1 retter de vigtigste fejl fundet efter 2.5-udgivelsen i tidsregistrering, VoIP og oprettelse af nye sager. + +## Tidsregistrering + +- Manuel tidsregistrering bruger nu den indtastede lokale dato og tid uden utilsigtet tidszoneforskydning. +- Flere medarbejdere kan vælges i samme registrering, og der oprettes én tidsregistrering under hver valgt medarbejder. +- Medarbejdervælgeren viser sagens tilgængelige brugere tydeligt og er adskilt fra live tracking. +- Registreringer, der krydser midnat, får korrekt slutdato. +- Sagsvisningen leveres uden browsercache, så de seneste ændringer i tidsregistreringen vises med det samme. + +## VoIP + +- Ved et ukendt telefonnummer kan en ny kontakt knyttes til et valgt firma direkte i VoIP-popupen. +- De viste åbne sager kan åbnes og knyttes direkte til opkaldet. +- Click-to-call accepterer nu en godkendt bruger på Hubens lokale eller interne adresse, selv når en proxy skjuler klientens lokale IP-adresse. +- VoIP-klientens cacheversion er opdateret. + +## Opret ny sag + +- Når et firma vælges først, hentes og vises firmaets kontaktpersoner automatisk. +- Kontaktsøgningen afgrænses til det valgte firmas kontakter og søger på navn, rolle, e-mail og telefon. +- Fjernes firmaet, skifter kontaktfeltet tilbage til den globale kontaktsøgning. +- Det er fortsat muligt at vælge flere kontaktpersoner på sagen. + +## Verifikation + +- Målrettede tests for sager, tidsregistrering og VoIP: **36 bestået**. +- Python- og diff-kontrol samt lokal API-health-check er gennemført. diff --git a/VERSION b/VERSION index 437459c..73462a5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.0 +2.5.1 diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py index 1cfb638..fe26f51 100644 --- a/app/modules/sag/frontend/views.py +++ b/app/modules/sag/frontend/views.py @@ -1242,7 +1242,7 @@ async def sag_detaljer_v3(request: Request, sag_id: int): status_options.append(current_status) is_deadline_overdue = _is_deadline_overdue(sag.get("deadline")) - return templates.TemplateResponse("modules/sag/templates/detail_v3.html", { + response = templates.TemplateResponse("modules/sag/templates/detail_v3.html", { "request": request, "case": sag, "customer": customer, @@ -1268,6 +1268,8 @@ async def sag_detaljer_v3(request: Request, sag_id: int): "assignment_users": _fetch_assignment_users(), "assignment_groups": _fetch_assignment_groups(), }) + response.headers["Cache-Control"] = "no-store, max-age=0" + return response except HTTPException: raise except Exception as e: diff --git a/app/modules/sag/templates/create.html b/app/modules/sag/templates/create.html index 3ff7055..d684f6d 100644 --- a/app/modules/sag/templates/create.html +++ b/app/modules/sag/templates/create.html @@ -181,6 +181,7 @@
+
Søg blandt alle kontakter.
@@ -381,6 +382,8 @@ let selectedContactsCompanies = {}; let customerSearchTimeout; let contactSearchTimeout; + let selectedCustomerContacts = []; + let customerContactsLoadToken = 0; let successAlertTimeout; let orderLineCounter = 0; let telefoniPrefill = { contactId: null, title: null, callId: null, customerId: null, description: null }; @@ -569,6 +572,11 @@ const contactInput = document.getElementById('contactSearch'); if (contactInput) { contactInput.addEventListener('input', (e) => handleSearch(e, 'contact')); + contactInput.addEventListener('focus', () => { + if (selectedCustomer) { + renderCustomerContactResults(contactInput.value); + } + }); document.addEventListener('click', (e) => { if (!e.target.closest('.search-position-relative')) { const cr = document.getElementById('contactResults'); @@ -588,6 +596,11 @@ const authHeaders = token ? { Authorization: `Bearer ${token}` } : {}; clearTimeout(timeoutVar); + + if (type === 'contact' && selectedCustomer) { + renderCustomerContactResults(query); + return; + } if (query.length < 2) { resultsDiv.classList.add('d-none'); @@ -653,6 +666,104 @@ else contactSearchTimeout = timeout; } + function contactDisplayName(contact) { + return `${contact.first_name || ''} ${contact.last_name || ''}`.trim() + || contact.name + || `Kontakt #${contact.id}`; + } + + function contactDisplayMeta(contact) { + const details = []; + if (contact.role) details.push(contact.role); + if (contact.email) details.push(contact.email); + const phone = contact.mobile || contact.phone; + if (phone) details.push(phone); + return details.join(' · ') || 'Ingen kontaktoplysninger'; + } + + function renderCustomerContactResults(query = '') { + const resultsDiv = document.getElementById('contactResults'); + if (!resultsDiv || !selectedCustomer) return; + + const normalizedQuery = String(query || '').trim().toLocaleLowerCase('da-DK'); + const contacts = selectedCustomerContacts.filter((contact) => { + if (!normalizedQuery) return true; + return [ + contactDisplayName(contact), + contact.email, + contact.mobile, + contact.phone, + contact.role + ].some((value) => String(value || '').toLocaleLowerCase('da-DK').includes(normalizedQuery)); + }); + + if (!contacts.length) { + resultsDiv.innerHTML = `
${ + selectedCustomerContacts.length + ? 'Ingen af firmaets kontakter matcher søgningen.' + : 'Firmaet har ingen registrerede kontakter.' + }
`; + } else { + resultsDiv.innerHTML = contacts.map((contact) => { + const name = contactDisplayName(contact); + return ` +
+
${escapeTopAlertHtml(name)}
+
${escapeTopAlertHtml(contactDisplayMeta(contact))}
+
+ `; + }).join(''); + resultsDiv.querySelectorAll('[data-contact-id]').forEach((row) => { + row.addEventListener('click', () => { + const contact = contacts.find((item) => Number(item.id) === Number(row.dataset.contactId)); + if (contact) selectContact(contact.id, contactDisplayName(contact)); + }); + }); + } + resultsDiv.classList.remove('d-none'); + } + + async function loadSelectedCustomerContacts(customerId) { + const contactInput = document.getElementById('contactSearch'); + const context = document.getElementById('contactSearchContext'); + const resultsDiv = document.getElementById('contactResults'); + const loadToken = ++customerContactsLoadToken; + + selectedCustomerContacts = []; + contactInput.placeholder = `Søg blandt kontakter hos ${selectedCustomer.name}...`; + context.textContent = `Viser kontakter hos ${selectedCustomer.name}.`; + resultsDiv.innerHTML = '
Henter firmaets kontakter...
'; + resultsDiv.classList.remove('d-none'); + + try { + const response = await fetch(`/api/v1/customers/${customerId}/contacts`, { credentials: 'include' }); + if (loadToken !== customerContactsLoadToken) return; + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const contacts = await response.json(); + selectedCustomerContacts = Array.isArray(contacts) ? contacts : []; + renderCustomerContactResults(contactInput.value); + } catch (error) { + if (loadToken !== customerContactsLoadToken) return; + console.error('Failed to load customer contacts:', error); + resultsDiv.innerHTML = '
Firmaets kontakter kunne ikke hentes.
'; + resultsDiv.classList.remove('d-none'); + } + } + + function resetCustomerContactSearch() { + customerContactsLoadToken += 1; + selectedCustomerContacts = []; + const contactInput = document.getElementById('contactSearch'); + const context = document.getElementById('contactSearchContext'); + const resultsDiv = document.getElementById('contactResults'); + contactInput.value = ''; + contactInput.placeholder = 'Søg kontakt...'; + context.textContent = 'Søg blandt alle kontakter.'; + resultsDiv.innerHTML = ''; + resultsDiv.classList.add('d-none'); + } + // --- Selection Logic --- function selectCustomer(id, name, skipAlert = false) { selectedCustomer = { id, name }; @@ -661,6 +772,7 @@ document.getElementById('customerResults').classList.add('d-none'); renderSelections(); loadCreateTopAlertsForCustomer(id); + loadSelectedCustomerContacts(id); // Show notification if (!skipAlert) { @@ -671,6 +783,7 @@ function removeCustomer() { selectedCustomer = null; document.getElementById('customer_id').value = ''; + resetCustomerContactSearch(); renderSelections(); loadCreateTopAlertsForCustomer(null); } diff --git a/app/modules/sag/templates/detail_v3.html b/app/modules/sag/templates/detail_v3.html index e090a4a..ccc115c 100644 --- a/app/modules/sag/templates/detail_v3.html +++ b/app/modules/sag/templates/detail_v3.html @@ -2557,6 +2557,17 @@ background: color-mix(in srgb, var(--module-accent, var(--accent)) 7%, var(--bg-card)); } + /* Employee multi-select must be allowed to extend beyond the compact time card. */ + #timetracking .time-v1-entry-card { + overflow: visible !important; + position: relative; + z-index: 20; + } + + #timeV1EmployeePickerMenu { + z-index: 1080; + } + .left-module-card, .right-module-card { border: 2px solid rgba(15, 76, 117, 0.28) !important; @@ -8525,7 +8536,7 @@
-
+
Live tracking
@@ -8543,8 +8554,30 @@
- - + Mig (nuværende bruger) + + {% for user in assignment_users %} + + {% endfor %} +
+
+
+
+ +
+ + + +
+
+
Skriv mindst 2 tegn og vælg firmaet.
+
Finder først eksisterende kontakt på navn og opdaterer telefon, hvis den kun mangler nummer.
@@ -658,6 +697,7 @@ const firstName = String(firstNameInput?.value || '').trim(); const lastName = String(lastNameInput?.value || '').trim(); const email = String(emailInput?.value || '').trim(); + const companyId = Number(selectedQuickCompany?.id || 0); if (!firstName) { window.alert('Fornavn er påkrævet'); @@ -700,6 +740,19 @@ } savedContactId = Number(existing.id); + + if (companyId > 0) { + const linkRes = await fetch(`/api/v1/contacts/${savedContactId}/companies`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ customer_id: companyId, is_primary: true }) + }); + if (!linkRes.ok) { + const text = await linkRes.text(); + throw new Error(text || `HTTP ${linkRes.status}`); + } + } } else { const createRes = await fetch('/api/v1/contacts', { method: 'POST', @@ -711,6 +764,8 @@ email: email || null, phone: phoneValue, title: null, + company_id: companyId > 0 ? companyId : null, + is_primary: companyId > 0, }) }); if (!createRes.ok) { @@ -768,10 +823,91 @@ if (action === 'link-case') { openCaseSmartModal(); } + if (action === 'link-recent-case') { + const caseId = Number(btn.getAttribute('data-case-id')); + btn.disabled = true; + const originalHtml = btn.innerHTML; + btn.innerHTML = 'Linker'; + try { + await patchCallCase(caseId); + btn.className = 'btn btn-sm btn-success py-0 px-2 flex-shrink-0'; + btn.innerHTML = 'Linket'; + } catch (err) { + btn.disabled = false; + btn.innerHTML = originalHtml; + window.alert(`Kunne ikke linke opkaldet til sagen: ${err?.message || 'ukendt fejl'}`); + } + } + if (action === 'select-quick-company') { + selectedQuickCompany = { + id: Number(btn.getAttribute('data-company-id')), + name: btn.getAttribute('data-company-name') || '', + }; + const input = toastEl.querySelector('[data-role="quick-company-search"]'); + const results = toastEl.querySelector('[data-role="quick-company-results"]'); + const help = toastEl.querySelector('[data-role="quick-company-help"]'); + const clearBtn = toastEl.querySelector('[data-action="clear-quick-company"]'); + if (input) input.value = selectedQuickCompany.name; + results?.classList.add('d-none'); + if (help) help.innerHTML = `Valgt: ${escapeHtml(selectedQuickCompany.name)}`; + clearBtn?.classList.remove('d-none'); + } + if (action === 'clear-quick-company') { + selectedQuickCompany = null; + const input = toastEl.querySelector('[data-role="quick-company-search"]'); + const results = toastEl.querySelector('[data-role="quick-company-results"]'); + const help = toastEl.querySelector('[data-role="quick-company-help"]'); + if (input) { + input.value = ''; + input.focus(); + } + results?.classList.add('d-none'); + if (help) help.textContent = 'Skriv mindst 2 tegn og vælg firmaet.'; + btn.classList.add('d-none'); + } if (action === 'quick-create-contact') { await createOrUpdateContactFromToast(); } }); + + toastEl.addEventListener('input', (event) => { + const input = event.target.closest('[data-role="quick-company-search"]'); + if (!input) return; + selectedQuickCompany = null; + toastEl.querySelector('[data-action="clear-quick-company"]')?.classList.add('d-none'); + const resultsEl = toastEl.querySelector('[data-role="quick-company-results"]'); + const helpEl = toastEl.querySelector('[data-role="quick-company-help"]'); + const query = String(input.value || '').trim(); + if (companySearchTimer) clearTimeout(companySearchTimer); + if (query.length < 2) { + resultsEl?.classList.add('d-none'); + if (helpEl) helpEl.textContent = 'Skriv mindst 2 tegn og vælg firmaet.'; + return; + } + const token = ++companySearchToken; + if (helpEl) helpEl.textContent = 'Søger firmaer...'; + companySearchTimer = setTimeout(async () => { + try { + const customers = await searchCustomers(query); + if (token !== companySearchToken || !resultsEl) return; + resultsEl.innerHTML = customers.length ? customers.map((customer) => { + const companyId = Number(customer.id); + const companyName = String(customer.name || `Firma #${companyId}`); + const meta = [customer.cvr_number ? `CVR ${customer.cvr_number}` : '', customer.city || ''].filter(Boolean).join(' · '); + return ``; + }).join('') : '
Ingen firmaer fundet
'; + resultsEl.classList.remove('d-none'); + if (helpEl) helpEl.textContent = customers.length ? 'Vælg et firma fra listen.' : 'Ingen firmaer fundet.'; + } catch (err) { + if (token !== companySearchToken) return; + resultsEl?.classList.add('d-none'); + if (helpEl) helpEl.textContent = `Firmaer kunne ikke hentes: ${err?.message || 'ukendt fejl'}`; + } + }, 250); + }); } function scheduleReconnect() { diff --git a/tests/test_sag_module.py b/tests/test_sag_module.py index b0545ca..5a202aa 100644 --- a/tests/test_sag_module.py +++ b/tests/test_sag_module.py @@ -138,6 +138,33 @@ def test_case_email_forward_supports_latest_mail_and_full_thread_as_new_thread() assert "linkedEmailsCache\n .filter" in template +def test_case_detail_disables_browser_cache_for_fresh_inline_ui(): + source = Path("app/modules/sag/frontend/views.py").read_text() + + assert 'response.headers["Cache-Control"] = "no-store, max-age=0"' in source + + +def test_case_create_lists_contacts_for_selected_customer(): + template = Path("app/modules/sag/templates/create.html").read_text() + + assert 'id="contactSearchContext"' in template + assert "loadSelectedCustomerContacts(id);" in template + assert "`/api/v1/customers/${customerId}/contacts`" in template + assert "if (type === 'contact' && selectedCustomer)" in template + assert "renderCustomerContactResults(contactInput.value);" in template + assert "resetCustomerContactSearch();" in template + + +def test_time_employee_picker_is_clearly_separate_from_live_tracking(): + template = Path("app/modules/sag/templates/detail_v3.html").read_text() + + assert "Manuel registrering for" in template + assert "Mig · vælg flere" in template + assert "assignment_users|length" in template + assert "#timetracking .time-v1-entry-card" in template + assert "overflow: visible !important" in template + + def test_case_email_snippet_removes_embedded_css_and_scripts(): template = Path("app/modules/sag/templates/detail_v3.html").read_text() diff --git a/tests/test_telefoni_call_logging.py b/tests/test_telefoni_call_logging.py index a81efb3..78ee4b6 100644 --- a/tests/test_telefoni_call_logging.py +++ b/tests/test_telefoni_call_logging.py @@ -3,9 +3,20 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from starlette.requests import Request + +from app.modules.telefoni.backend.router import _is_internal_request_target from app.modules.telefoni.backend.service import TelefoniService +def _telefoni_javascript() -> str: + return (Path(__file__).resolve().parents[1] / "static/js/telefoni.js").read_text(encoding="utf-8") + + +def _request_for_host(host: str) -> Request: + return Request({"type": "http", "method": "POST", "path": "/", "headers": [], "server": (host, 8001)}) + + def test_terminate_call_creates_placeholder_row_when_missing(monkeypatch): calls = [] @@ -28,3 +39,31 @@ def test_terminate_call_creates_placeholder_row_when_missing(monkeypatch): assert result is True assert any("INSERT INTO telefoni_opkald" in query for query, _ in calls) + + +def test_unknown_caller_quick_contact_can_select_and_save_company(): + source = _telefoni_javascript() + + assert 'data-role="quick-company-search"' in source + assert "async function searchCustomers" in source + assert "company_id: companyId > 0 ? companyId : null" in source + assert "body: JSON.stringify({ customer_id: companyId, is_primary: true })" in source + + +def test_recent_open_case_can_be_linked_to_call_from_popup(): + source = _telefoni_javascript() + + assert 'data-action="link-recent-case"' in source + assert "await patchCallCase(caseId)" in source + assert "Link til sag" in source + + +def test_click_to_call_recognizes_local_and_internal_hub_targets(): + assert _is_internal_request_target(_request_for_host("localhost")) is True + assert _is_internal_request_target(_request_for_host("127.0.0.1")) is True + assert _is_internal_request_target(_request_for_host("172.16.31.183")) is True + + +def test_click_to_call_rejects_external_hub_target_fallback(): + assert _is_internal_request_target(_request_for_host("example.com")) is False + assert _is_internal_request_target(_request_for_host("172.16.30.183")) is False diff --git a/tests/test_timetracking_manual_time.py b/tests/test_timetracking_manual_time.py new file mode 100644 index 0000000..a4584af --- /dev/null +++ b/tests/test_timetracking_manual_time.py @@ -0,0 +1,88 @@ +import asyncio +import importlib +from datetime import datetime +from pathlib import Path + +timetracking_router = importlib.import_module("app.timetracking.backend.router") + + +def test_manual_time_keeps_local_wall_clock_and_creates_one_entry_per_employee(monkeypatch): + captured = {} + + monkeypatch.setattr(timetracking_router, "_resolve_case_customer_id", lambda *_args: 77) + + def fake_execute_query(query, params): + captured["query"] = query + captured["params"] = params + employee_ids = params[-1] + return [{"id": index + 1, "medarbejder_id": employee_id} for index, employee_id in enumerate(employee_ids)] + + monkeypatch.setattr(timetracking_router, "execute_query", fake_execute_query) + + result = asyncio.run( + timetracking_router.create_manual_time_v1( + { + "sag_id": 88, + "medarbejder_ids": [12, 34], + "worked_date": "2026-08-18", + "start_tid": "2026-08-18T10:00:00", + "slut_tid": "2026-08-18T11:00:00", + "faktisk_tid_min": 60, + }, + current_user={"id": 9, "username": "tester"}, + ) + ) + + assert result["count"] == 2 + assert [row["medarbejder_id"] for row in result["created"]] == [12, 34] + assert captured["params"][-1] == [12, 34] + assert captured["params"][10] == datetime(2026, 8, 18, 10, 0) + assert captured["params"][11] == datetime(2026, 8, 18, 11, 0) + assert "UNNEST(%s::bigint[])" in captured["query"] + + +def test_manual_time_bulk_resolves_current_user_and_removes_duplicates(monkeypatch): + captured = {} + monkeypatch.setattr(timetracking_router, "_resolve_case_customer_id", lambda *_args: 77) + + def fake_execute_query(_query, params): + captured["employee_ids"] = params[-1] + return [{"id": 1, "medarbejder_id": employee_id} for employee_id in params[-1]] + + monkeypatch.setattr(timetracking_router, "execute_query", fake_execute_query) + + result = asyncio.run( + timetracking_router.create_manual_time_v1( + { + "sag_id": 88, + "medarbejder_ids": [None, 9, 12, 12], + "faktisk_tid_min": 30, + }, + current_user={"id": 9, "username": "tester"}, + ) + ) + + assert captured["employee_ids"] == [9, 12] + assert result["count"] == 2 + + +def test_case_time_form_sends_local_time_and_multiple_employees(): + template = Path("app/modules/sag/templates/detail_v3.html").read_text() + manual_form_script = template.split("async function createManualTimeV1(event)", 1)[1].split( + "document.addEventListener('DOMContentLoaded'", 1 + )[0] + + assert 'class="form-check-input mt-0 time-v1-employee-option"' in template + assert "{% for user in assignment_users %}" in template + assert "loadTimeV1EmployeeOptions" not in template + assert "medarbejder_ids: employeeIds" in manual_form_script + assert "startObj = `${dateVal}T${tStart}:00`;" in manual_form_script + assert "endObj = `${endDate}T${tEnd}:00`;" in manual_form_script + assert ".toISOString()" not in manual_form_script + + quick_time_script = template.split("async function registerQuickTimeFromComment(content)", 1)[1].split( + "function resolveCommentAwaitCustomerStatus", 1 + )[0] + assert "startIso = `${dateValue}T${startValue}:00`;" in quick_time_script + assert "endIso = addMinutesToTimeV1LocalIso(startIso, minutes);" in quick_time_script + assert ".toISOString()" not in quick_time_script