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 @@