+
@@ -38,21 +39,23 @@
{% endblock %}
diff --git a/app/settings/frontend/settings.html b/app/settings/frontend/settings.html
index 17a0b13..e9550b1 100644
--- a/app/settings/frontend/settings.html
+++ b/app/settings/frontend/settings.html
@@ -2656,7 +2656,7 @@ function buildYealinkActionUrls() {
const base = baseRaw.replace(/\/$/, '');
const tokenPart = token ? `token=${encodeURIComponent(token)}&` : '';
- incomingEl.value = `${base}/api/v1/mission/webhook/telefoni/ringing?${tokenPart}call_id=$call_id&caller_number=$display_remote&queue_name=$local&recipient_extension=$active_user`;
+ incomingEl.value = `${base}/api/v1/mission/webhook/telefoni/ringing?${tokenPart}call_id=$call_id&caller_number=$display_remote&queue_name=$local&recipient_extension=$local`;
estEl.value = `${base}/api/v1/telefoni/established?${tokenPart}callid=$call_id&remote=$remote&display_remote=$display_remote&caller_id=$callerID&local=$local&active_user=$active_user&called_number=$calledNumber`;
termEl.value = `${base}/api/v1/telefoni/terminated?${tokenPart}callid=$call_id&duration=$call_duration`;
}
diff --git a/app/shared/frontend/base.html b/app/shared/frontend/base.html
index 4019799..21e90ab 100644
--- a/app/shared/frontend/base.html
+++ b/app/shared/frontend/base.html
@@ -2289,6 +2289,9 @@ if (bmcOriginalFetch) {
navigateResults(-1);
} else if (e.key === 'Enter') {
e.preventDefault();
+ if (navigateToCaseNumber(globalSearchInput.value)) {
+ return;
+ }
if (navigateToSagFromScan(e.target.value)) {
return;
}
@@ -2531,6 +2534,19 @@ if (bmcOriginalFetch) {
return true;
}
+ function navigateToCaseNumber(value) {
+ // An exact #123 entry is an intentional direct case lookup. Keep this
+ // separate from scan handling so typing it does not navigate before Enter.
+ const match = String(value || '').trim().match(/^#\s*(\d+)$/);
+ if (!match) return false;
+
+ const sagId = Number(match[1]);
+ if (!Number.isSafeInteger(sagId) || sagId < 1) return false;
+
+ window.location.href = `/sag/${sagId}/v4`;
+ return true;
+ }
+
// Global search function
async function performGlobalSearch(query) {
if (navigateToSagFromScan(query)) {
diff --git a/design_prototypes/sag_v5/arbejdsrum.html b/design_prototypes/sag_v5/arbejdsrum.html
new file mode 100644
index 0000000..790fe66
--- /dev/null
+++ b/design_prototypes/sag_v5/arbejdsrum.html
@@ -0,0 +1,456 @@
+
+
+
+
+
+
Sag V5 · Arbejdsrummet
+
+
+
+
+
BMC HUB / Sager
+
+
V5 · Designprototype · Fiktive data
+
+
+
+
+
+
+
Sag #1248 · Nordby Kontor A/S
+
+
Printer virker ikke
+
+
+
+
+
+
+
+
+
+
+Peter Hansen
+
+Ansvarlig Christian
+
+Deadline 05.10.2026
+
+
+
+
+
+Næste todo · Genstart printspooler på serveren
+Maria Jensen · 05.10
+
+
+
+Overblik
+Tidsforbrug · 15 min
+Historik
+Relations-træ
+Øvrige moduler +
+
+
+
+
+
+
Opgaven
+Rediger ✎
+
+
+
+
+Printeren på 1. sal udskriver ikke.
+
+
Job bliver stående i køen hos alle medarbejdere. Printeren er online, og kopiering virker. Fejlen opstod efter gårsdagens opdatering.
+
Brother MFC-L8900CDW · Administration · 1. sal
+
+
+
+
+
+
Samtale og handlinger
+Alle · Mail · Internt
+
+
+
+
Hvad er næste skridt?
+Intern kommentar
+
+
+
+
+Gemmer en intern kommentar, registrerer 15 minutter og tildeler Maria.
+
+
+PH
+
+
+Peter Hansen Mail
+
+02.10 · 08.42
+
+
Hej. Ingen af os kan printe fra computeren i dag. Jeg har prøvet at slukke printeren, men det hjalp ikke. Kan I kigge på det?
+
+Til: support@example.com · Emne: Printer virker ikke
+
+
+
+
+CT
+
+
+Christian Thomas Intern kommentar
+
+02.10 · 09.10
+
+
Printeren svarer på netværket. Køen på printserveren stopper efter første job. Jeg har kontrolleret driveren og fundet en fejl i spooleren.
+
+
+
+◷
+
+
+15 minutter registreret Tid
+
+02.10 · 09.12
+
+
Christian · Fejlsøgning · 09.00–09.15
+
+
+
+→
+
+
+Christian → Maria Tildeling
+
+02.10 · 09.18
+
+
+Begrundelse: Maria har adgang til printserveren og kan genstarte spooleren. Netværk og printer er kontrolleret.
+
+
+
+
+
+
Sagsdetaljer
+Eksempel på åbent detaljepanel
+
+
+
+
+Status
+Igangværende
+
+
+Type
+Ticket
+
+
+Prioritet
+Høj
+
+
+Ansvarlig
+Christian Thomas
+
+
+Gruppe
+Support
+
+
+Medarbejdere
+Christian · Maria
+
+
+Startdato
+02.10.2026
+
+
+Senest / udsat
+05.10.2026
+
+
+Deadline
+05.10.2026
+
+
+Forventet tid
+60 minutter
+
+
+Trigger
+Afventer #1242 → lukket
+
+
+Oprettet
+02.10.2026 · 08.42
+
+
+
+
+
+
+
+
+
+
+
diff --git a/design_prototypes/sag_v5/journal.html b/design_prototypes/sag_v5/journal.html
new file mode 100644
index 0000000..7247a3c
--- /dev/null
+++ b/design_prototypes/sag_v5/journal.html
@@ -0,0 +1,470 @@
+
+
+
+
+
+
Sag V5 · Sagsjournalen
+
+
+
+
+
BMC HUB / Sager
+
+
V5 · Designprototype · Fiktive data
+
+
+
+
+
+
+
Sag #1248 · Nordby Kontor A/S
+
+
Printer virker ikke
+
+
+
+
+
+
+
+
+
+
+
+Kontakt
+Peter Hansen
+
+
+Ansvarlig
+Christian Thomas
+
+
+Gruppe
+Support
+
+
+Deadline
+05.10.2026
+
+
+
+
+Næste todo · Genstart printspooler på serveren
+Maria Jensen · 05.10
+
+
+
+Overblik
+Tidsforbrug · 15 min
+Historik
+Relations-træ
+Øvrige moduler +
+
+
+
+
+
+
Henvendelsen
+Rediger ✎
+
+
+
+
+Printeren på 1. sal udskriver ikke.
+
+
Job bliver stående i køen hos alle medarbejdere. Printeren er online, og kopiering virker. Fejlen opstod efter gårsdagens opdatering.
+
Brother MFC-L8900CDW · Administration · 1. sal
+
+
+
+
+
+
Sagens forløb
+
+Alle
+Mail · Tid · Tildeling
+
+
+FREDAG 2. OKTOBER 2026
+
+PH
+
+
+Peter Hansen Mail
+
+02.10 · 08.42
+
+
Hej. Ingen af os kan printe fra computeren i dag. Jeg har prøvet at slukke printeren, men det hjalp ikke. Kan I kigge på det?
+
+Til: support@example.com · Emne: Printer virker ikke
+
+
+
+
+CT
+
+
+Christian Thomas Intern kommentar
+
+02.10 · 09.10
+
+
Printeren svarer på netværket. Køen på printserveren stopper efter første job. Jeg har kontrolleret driveren og fundet en fejl i spooleren.
+
+
+
+◷
+
+
+15 minutter registreret Tid
+
+02.10 · 09.12
+
+
Christian · Fejlsøgning · 09.00–09.15
+
+
+
+→
+
+
+Christian → Maria Tildeling
+
+02.10 · 09.18
+
+
+Begrundelse: Maria har adgang til printserveren og kan genstarte spooleren. Netværk og printer er kontrolleret.
+
+
+
+
+
Hvad er næste skridt?
+Intern kommentar
+
+
+
+
+Gemmer en intern kommentar, registrerer 15 minutter og tildeler Maria.
+
+
+
+
+
+
+
+
+
diff --git a/design_prototypes/sag_v5/sagsbord.html b/design_prototypes/sag_v5/sagsbord.html
new file mode 100644
index 0000000..6064408
--- /dev/null
+++ b/design_prototypes/sag_v5/sagsbord.html
@@ -0,0 +1,449 @@
+
+
+
+
+
+
Sag V5 · Sagsbordet
+
+
+
+
+
BMC HUB / Sager
+
+
V5 · Designprototype · Fiktive data
+
+
+
+
+
+
+
+
+Overblik
+Tidsforbrug · 15 min
+Historik
+Relations-træ
+Øvrige moduler +
+
+
+
+
Opgavebeskrivelse
+Rediger ✎
+
+
+
+
+Printeren på 1. sal udskriver ikke.
+
+
Job bliver stående i køen hos alle medarbejdere. Printeren er online, og kopiering virker. Fejlen opstod efter gårsdagens opdatering.
+
Brother MFC-L8900CDW · Administration · 1. sal
+
+
+
+
+
+
Kommentarer og handlinger
+4 hændelser
+
+
+
+
Hvad er næste skridt?
+Intern kommentar
+
+
+
+
+Gemmer en intern kommentar, registrerer 15 minutter og tildeler Maria.
+
+
+PH
+
+
+Peter Hansen Mail
+
+02.10 · 08.42
+
+
Hej. Ingen af os kan printe fra computeren i dag. Jeg har prøvet at slukke printeren, men det hjalp ikke. Kan I kigge på det?
+
+Til: support@example.com · Emne: Printer virker ikke
+
+
+
+
+CT
+
+
+Christian Thomas Intern kommentar
+
+02.10 · 09.10
+
+
Printeren svarer på netværket. Køen på printserveren stopper efter første job. Jeg har kontrolleret driveren og fundet en fejl i spooleren.
+
+
+
+◷
+
+
+15 minutter registreret Tid
+
+02.10 · 09.12
+
+
Christian · Fejlsøgning · 09.00–09.15
+
+
+
+→
+
+
+Christian → Maria Tildeling
+
+02.10 · 09.18
+
+
+Begrundelse: Maria har adgang til printserveren og kan genstarte spooleren. Netværk og printer er kontrolleret.
+
+
+
+
+
+
+
+
+
+
diff --git a/migrations/1053_sag_internal_comments.sql b/migrations/1053_sag_internal_comments.sql
new file mode 100644
index 0000000..8c4743e
--- /dev/null
+++ b/migrations/1053_sag_internal_comments.sql
@@ -0,0 +1,4 @@
+-- V5 assignment explanations must remain internal. Existing comments keep
+-- their current visibility; no rows are removed or reclassified.
+ALTER TABLE sag_kommentarer
+ ADD COLUMN IF NOT EXISTS er_intern BOOLEAN NOT NULL DEFAULT FALSE;
diff --git a/static/css/case-v5.css b/static/css/case-v5.css
new file mode 100644
index 0000000..4e23296
--- /dev/null
+++ b/static/css/case-v5.css
@@ -0,0 +1,49 @@
+/* V5 only: retain module behavior, replace page presentation. */
+.case-detail-page-shell.case-v5{--case-primary:#177b68;--case-primary-soft:#e7f3ee;--case-border:#dfe6eb;--case-radius:11px;background:#edf1f4!important;margin-top:0!important;padding:18px 20px 36px;color:var(--text-color,#243449)}
+.case-v5 .v5-version-line{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:14px;font-size:12px;color:var(--case-muted,#728095)}
+.v5-version-line>div{display:flex;gap:16px}.v5-version-line a{color:var(--case-primary)}
+.case-v5 .header-bg{background:#17364d!important;margin:0 0 18px!important;padding:18px 8px!important;border-radius:12px}
+.case-v5 .case-header-main{display:flex;align-items:center;justify-content:space-between;gap:24px}.case-v5 .case-header-copy{flex:1}
+.case-v5 #sag-titel-text{font-size:25px!important;line-height:1.3;letter-spacing:-.5px}.case-v5 .case-header-todo{background:transparent!important;border:0!important;box-shadow:none!important;max-width:340px}
+.case-v5 .case-header-todo-label{color:#b9e6d7!important}.case-v5 #topbarNextTodoValue{color:#fff}.case-v5 .case-v4-widget-dashboard{display:none!important}
+.v5-board{display:grid;grid-template-columns:260px minmax(0,1fr) 300px;gap:18px;align-items:start}.v5-board>*,.v5-board main{min-width:0}
+.case-v5 .v5-case-facts .card{margin-bottom:14px;border:1px solid var(--case-border)!important;border-radius:11px!important;background:var(--bg-surface,#fff)!important;box-shadow:none!important}
+.case-v5 .v5-case-facts .card-body{padding:16px!important}.case-v5 .v5-case-facts .form-select,.case-v5 .v5-case-facts .input-group{width:62%!important;min-width:0}
+.case-v5 .v5-case-facts .contact-widget-field{min-width:0}.case-v5 .v5-case-facts .contact-widget-row{flex-wrap:wrap}
+.case-v5 #caseTabs{margin:0 0 14px!important;padding:0!important;border-bottom:1px solid var(--case-border);border-radius:0!important;background:transparent!important;gap:2px;display:flex;flex-wrap:wrap}
+.case-v5 #caseTabs .nav-link{font-size:12px!important;padding:9px 8px!important;background:transparent!important;border-radius:0!important;box-shadow:none!important;border:0!important}
+.case-v5 #caseTabs .nav-link.active{border-bottom:2px solid var(--case-primary)!important;color:var(--case-primary)!important}.case-v5 #caseTabs .nav-link i{display:none}
+.case-v5 #planner-tab,.case-v5 #planner{display:none!important}
+.case-v5 #case-left-column,.case-v5 #case-right-column{width:100%!important;max-width:100%!important;padding:0!important;flex:none!important}
+.case-v5 .case-v4-details-columns{display:block!important;margin:0!important}.case-v5 #inner-center-col,.case-v5 #inner-left-col{padding:0!important;width:100%!important}
+.case-v5 #case-left-column>.row,.case-v5 #inner-center-col>.row{margin:0!important;display:block!important}.case-v5 #inner-center-col>.row>.col-12{padding:0!important;margin:0!important}
+.case-v5 .case-details-shell{background:transparent!important;border:0!important;box-shadow:none!important;padding:0!important}.case-v5 .case-details-shell>.card-body{padding:0!important}.case-v5 #beskrivelse-section{padding-top:0!important}
+.case-v5 .right-modules-grid{display:flex!important;flex-direction:column!important;gap:14px!important}.case-v5 .right-modules-grid>.d-none{display:none!important}
+.case-v5 .right-module-card,.case-v5 .left-module-card{height:auto!important;border:1px solid var(--case-border)!important;border-radius:11px!important;box-shadow:none!important;background:var(--bg-surface,#fff)!important;margin-bottom:14px}
+.case-v5 .right-module-card{margin-bottom:0}.case-v5 .card-header{background:transparent!important;border-bottom:1px solid var(--case-border)!important;padding:14px 16px!important}
+.case-v5 .module-title{font-size:14px!important;letter-spacing:0!important}.case-v5 .module-icon{color:var(--case-primary)!important}
+.case-v5 .comment-composer{display:flex;flex-direction:column;gap:10px;border:0!important;background:transparent!important;padding:0!important}.case-v5 .comment-composer textarea{background:var(--bg-element,#f6f8fa);border:1px solid var(--case-border)!important;border-radius:9px!important;min-height:120px}
+.case-v5 .comment-send{align-self:flex-end;position:static!important;width:auto!important;background:#177b68;border-color:#177b68}
+.v5-composer-actions{display:flex;align-items:center;gap:9px;margin-bottom:10px}.v5-selected-actions{display:flex;gap:6px;flex-wrap:wrap}
+.v5-selected-actions button{font-size:12px;border:1px solid #b9d9cc;border-radius:7px;background:var(--case-primary-soft);color:var(--case-primary);padding:5px 9px}
+.v5-action-menu{border:1px solid var(--case-border);border-radius:9px;padding:13px;margin-bottom:12px;background:var(--bg-surface,#fff)}
+.v5-menu-title{font-size:12px;font-weight:700;margin-bottom:9px}.v5-action-options{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}
+.v5-action-options button{font-size:12px;text-align:left;border:1px solid var(--case-border);background:var(--bg-element,#f7f9fa);color:var(--text-color,#243449);border-radius:7px;padding:9px}
+.v5-action-options button[aria-pressed=true],.v5-picker button[aria-pressed=true]{border-color:#79b7a2;background:var(--case-primary-soft);color:var(--case-primary)}
+.v5-assignment-fields{background:var(--case-primary-soft);border-radius:9px;padding:13px;margin-bottom:12px}.v5-assignment-fields label{font-size:12px;font-weight:600;margin:8px 0 5px;display:block}.v5-assignment-fields label:first-child{margin-top:0}
+.v5-picker{display:flex;flex-wrap:wrap;gap:6px;max-height:145px;overflow:auto;margin:8px 0}.v5-picker button{border:1px solid var(--case-border);border-radius:7px;padding:5px 9px;background:var(--bg-surface,#fff);color:var(--text-color,#243449);font-size:12px}
+.case-v5 .comment-action-chips{display:none!important}.case-v5 .comment-toolbar{flex-wrap:wrap}.case-v5 .comment-item{box-shadow:none!important;border-radius:8px!important}
+.v5-action-menu[hidden],.v5-assignment-fields[hidden]{display:none!important}
+[data-bs-theme=dark] .case-v5{--case-primary:#69c7a9;--case-primary-soft:#1e3933;--case-border:#384850;background:#17232c!important}
+@media(max-width:1250px){.v5-board{grid-template-columns:225px minmax(0,1fr)}.v5-board>.v5-case-modules{grid-column:1/-1}.case-v5 .v5-case-modules .right-modules-grid{display:grid!important;grid-template-columns:repeat(3,minmax(0,1fr))}}
+@media(max-width:767px){.case-detail-page-shell.case-v5{padding:12px}.v5-board{display:flex;flex-direction:column;gap:14px}.v5-board>main{order:0;width:100%}.v5-board>.v5-case-facts{order:1;width:100%}.v5-board>.v5-case-modules{order:2;width:100%}.case-v5 .case-header-main{flex-direction:column;align-items:stretch;gap:12px}.case-v5 .case-header-todo{max-width:none}.case-v5 #sag-titel-text{font-size:22px!important}.v5-action-options{grid-template-columns:repeat(2,minmax(0,1fr))}.case-v5 .v5-case-modules .right-modules-grid{display:flex!important}.case-v5 .comment-toolbar-search{width:100%!important}.case-v5 .comment-send{width:100%!important}.v5-version-line{flex-wrap:wrap}}
+.case-v5 #caseTabs .nav-link{color:var(--text-color,#56677a)!important}
+.case-v5 #caseTabs .nav-link.active{color:var(--case-primary)!important}
+.case-v5 .v5-hero-status{display:flex;flex-direction:column;gap:7px;min-width:190px;max-width:240px}
+.case-v5 .v5-hero-status label{color:#c8dce8!important;font-size:12px!important;margin-right:10px}
+.case-v5 .v5-hero-status select{width:auto!important;min-width:130px;border:0;background:#e6f2ed!important;color:#176b56;font-size:13px}
+.case-v5 .comment-composer textarea{height:135px;min-height:100px;padding-right:12px!important}
+.case-v5 .v5-composer-actions{margin-top:10px}
+@media(max-width:767px){.case-v5 .v5-hero-status{max-width:none;flex-direction:row;flex-wrap:wrap}.case-v5 .v5-hero-status>div{flex:1;min-width:155px}}
+.case-v5 .v5-composer-actions .comment-send{margin-left:auto;align-self:center}
+@media(max-width:767px){.case-v5 .v5-composer-actions{flex-wrap:wrap}.case-v5 .v5-composer-actions .comment-send{margin-left:0}}
diff --git a/static/js/case-create.js b/static/js/case-create.js
index c5a069c..dc46e21 100644
--- a/static/js/case-create.js
+++ b/static/js/case-create.js
@@ -162,7 +162,7 @@
opgave: ['relations'],
abonnement: ['relations'],
}[type] || ['relations'];
- const availableModules = visiblePanels.filter(key => key !== 'relations');
+ const availableModules = visiblePanels;
const moduleAction = $('typeModuleAction');
if (showTypeFields) state.openModules[type] = [...availableModules];
const openModules = state.openModules[type] || [];
@@ -172,8 +172,8 @@
admin: 'Adminoplysninger', projekt: 'Projektoplysninger', postit: 'Notat og påmindelse',
intern_ticket: 'Intern opgave', bug_report: 'Fejlrapport'
};
- const moduleLabels = {'type-details': typeDetailsLabels[type] || 'Oplysninger til sagstypen', hardware:'Hardware', pipeline:'Pipeline', order:'Indkøb og salg'};
- const moduleIcons = {'type-details':'bi-card-text', hardware:'bi-pc-display', pipeline:'bi-graph-up-arrow', order:'bi-bag'};
+ const moduleLabels = {relations:'Kunde og kontakt', 'type-details': typeDetailsLabels[type] || 'Oplysninger til sagstypen', hardware:'Hardware', pipeline:'Pipeline', order:'Indkøb og salg'};
+ const moduleIcons = {relations:'bi-people', 'type-details':'bi-card-text', hardware:'bi-pc-display', pipeline:'bi-graph-up-arrow', order:'bi-bag'};
moduleAction.classList.toggle('d-none', !availableModules.length);
moduleAction.innerHTML = availableModules.map(key => `
`).join('');
moduleAction.querySelectorAll('[data-case-module]').forEach(button => {
@@ -184,6 +184,9 @@
};
});
}
+ const relationsSection = $('caseRelationsSection');
+ relationsSection?.classList.toggle('d-none', !openModules.includes('relations') || !visiblePanels.includes('relations'));
+ $('caseGroupField')?.classList.toggle('d-none', !openModules.includes('relations') || !visiblePanels.includes('relations'));
['relations', 'type-details', 'hardware', 'pipeline', 'order'].forEach(key => state.panels[key]?.classList.toggle('d-none', !openModules.includes(key) || !visiblePanels.includes(key)));
const sectionIds = {hardware: 'hardwareSection', pipeline: 'pipelineSection', order: 'orderSection'};
$('typeDetailsSection')?.classList.toggle('d-none', !openModules.includes('type-details') || !(typeFieldSets[type] || []).length);
@@ -332,6 +335,10 @@
};
const originalRender = window.renderSelections;
window.renderSelections = () => { originalRender(); if (state.ready) scheduleSave(); };
+ $('caseTagsButton')?.addEventListener('click', () => {
+ if (!window.tagPicker?.showSelection) return;
+ window.tagPicker.showSelection(addTag);
+ });
const actions = $('submitBtn').parentElement; actions.classList.add('cc-actions'); actions.insertAdjacentHTML('afterbegin','
Klargør kladde…');
// Core flow stays visible: type → required fields → chosen type's fields.
const typeSection = form.querySelector('.cc-type-section');
diff --git a/static/js/case-v5.js b/static/js/case-v5.js
new file mode 100644
index 0000000..46256be
--- /dev/null
+++ b/static/js/case-v5.js
@@ -0,0 +1,269 @@
+/* V5 composes the established V4 controls without copying their implementations. */
+(() => {
+ 'use strict';
+ const config = JSON.parse(document.getElementById('caseV5Data').textContent);
+ const byId = id => document.getElementById(id);
+ const shell = document.querySelector('.case-detail-page-shell');
+ shell.classList.add('case-v5');
+ shell.prepend(byId('caseV5Tools').content.cloneNode(true));
+ const board = document.createElement('div');
+ board.className = 'v5-board';
+ const facts = document.createElement('aside');
+ facts.className = 'v5-case-facts';
+ facts.setAttribute('aria-label', 'Kunde og sagsoplysninger');
+ const center = document.createElement('main');
+ const modules = document.createElement('aside');
+ modules.className = 'v5-case-modules';
+ modules.setAttribute('aria-label', 'Todo, filer og relationer');
+ const tabs = byId('caseTabs');
+ tabs.before(board);
+ board.append(facts, center, modules);
+ const widgets = [...shell.querySelectorAll('.case-v4-widget-dashboard .row > div > .card')];
+ [3, 1, 0, 2].forEach(index => { if (widgets[index]) facts.append(widgets[index]); });
+ const heroStatus = document.createElement('div');
+ heroStatus.className = 'v5-hero-status';
+ ['topbarStatusSelect', 'topbarPrioritySelect'].forEach(id => {
+ const row = byId(id)?.parentElement;
+ if (row) heroStatus.append(row);
+ });
+ shell.querySelector('.case-header-main').insertBefore(heroStatus, shell.querySelector('.case-header-todo'));
+ center.append(tabs, byId('timeActiveBanner'), byId('caseTabsContent'));
+ // Normalize legacy closing tags: browsers can parse later panes outside
+ // their tab-content. Every V5 pane belongs to the same center column.
+ tabs.querySelectorAll('[data-bs-target]').forEach(button => {
+ const pane = document.querySelector(button.getAttribute('data-bs-target'));
+ if (pane && pane.parentElement !== byId('caseTabsContent')) byId('caseTabsContent').append(pane);
+ });
+ const planner = byId('planner');
+ if (planner) { planner.hidden = true; planner.style.setProperty('display', 'none', 'important'); }
+ byId('planner-tab')?.closest('.nav-item')?.remove();
+ // Keep planner markup for existing dialogs, but never expose its pane in V5.
+ if (new URLSearchParams(location.search).get('tab') === 'planner') {
+ const url = new URL(location.href);
+ url.searchParams.set('tab', 'details');
+ history.replaceState(null, '', url);
+ }
+ const details = byId('details');
+ const left = byId('case-left-column');
+ const right = byId('case-right-column');
+ const detailsRow = document.createElement('div');
+ detailsRow.className = 'case-v4-details-columns';
+ details.prepend(detailsRow);
+ detailsRow.append(left);
+ modules.append(right);
+ const rightGrid = right.querySelector('.right-modules-grid');
+ ['todo-steps', 'files', 'relations', 'hardware', 'time'].reverse().forEach(key => {
+ const card = shell.querySelector(`.right-module-card[data-module="${key}"]`);
+ if (card) rightGrid.prepend(card);
+ });
+ // V4 refreshes this hook after async module loads. Keep its columns in V5.
+ window.ensureCaseDetailsColumns = () => detailsRow;
+ window.updateRightColumnVisibility = () => right.classList.remove('d-none');
+ const originalApplyLayout = window.applyViewLayout;
+ window.applyViewLayout = function (...args) {
+ originalApplyLayout(...args);
+ right.classList.remove('d-none');
+ };
+
+ const actions = [
+ ['assign', 'Tildel sag', 'person-check', null],
+ ['reply_customer', 'Svar kunden', 'envelope', 'commentActionReplyCustomer'],
+ ['log_time', 'Registrer tid', 'clock-history', 'commentActionLogTime'],
+ ['solution', 'Gem som løsning', 'lightbulb', 'commentActionSolution'],
+ ['close_case', 'Luk sag', 'check-circle', 'commentActionCloseCase'],
+ ['await_customer', 'Afventer kunde', 'hourglass-split', 'commentActionAwaitCustomer'],
+ ['append_case', 'Tilføj til sag', 'file-earmark-plus', 'commentActionAppendCase']
+ ];
+ const form = byId('comment-form');
+ const legacyChips = form.querySelector('.comment-action-chips');
+ legacyChips.before(byId('caseV5ComposerTools').content.cloneNode(true));
+ byId('v5SelectedActions').parentElement.append(form.querySelector('button[type="submit"]'));
+ let assignSelected = false;
+ let groupId = config.groupId;
+ let userId = config.userId;
+ let members = [];
+ let memberRequest = 0;
+ let membersLoading = false;
+ let busy = false;
+ let pending = null;
+ const feedback = (message, error = false) => {
+ const el = byId('v5SubmitFeedback');
+ el.textContent = message;
+ el.classList.toggle('text-danger', error);
+ };
+ const selected = action => action[0] === 'assign' ? assignSelected : Boolean(byId(action[3])?.checked);
+ function renderActions() {
+ const host = byId('v5SelectedActions');
+ host.replaceChildren();
+ actions.forEach(action => {
+ const active = selected(action);
+ byId(`v5Option-${action[0]}`).setAttribute('aria-pressed', String(active));
+ if (!active) return;
+ const chip = document.createElement('button');
+ chip.type = 'button';
+ let label = action[1];
+ if (action[0] === 'assign') {
+ const target = members.find(member => member.user_id === userId)?.display_name
+ || config.groups.find(group => group.id === groupId)?.name || 'Vælg modtager';
+ label = `Tildel: ${target}`;
+ }
+ if (action[0] === 'log_time') label = `Tid: ${byId('commentQuickTimeMinutes').value || '0'} min`;
+ chip.textContent = `${label} ×`;
+ chip.setAttribute('aria-label', `Fjern ${action[1]}`);
+ chip.addEventListener('click', () => toggleAction(action));
+ chip.disabled = busy || Boolean(pending);
+ host.append(chip);
+ });
+ byId('v5AssignmentFields').hidden = !assignSelected;
+ form.querySelector('button[type="submit"]').textContent = pending ? 'Fortsæt resterende handlinger' : assignSelected ? 'Gem og tildel' : actions.some(selected) ? 'Gem med handlinger' : 'Send';
+ }
+ function toggleAction(action) {
+ if (busy || pending) return;
+ if (action[0] === 'assign') {
+ assignSelected = !assignSelected;
+ if (assignSelected && groupId) loadMembers();
+ } else {
+ const checkbox = byId(action[3]);
+ checkbox.checked = !checkbox.checked;
+ checkbox.dispatchEvent(new Event('change', {bubbles: true}));
+ }
+ renderActions();
+ }
+ actions.forEach(action => {
+ const button = document.createElement('button');
+ button.id = `v5Option-${action[0]}`;
+ button.type = 'button';
+ button.innerHTML = `
`;
+ button.append(document.createTextNode(action[1]));
+ button.addEventListener('click', () => toggleAction(action));
+ byId('v5ActionOptions').append(button);
+ byId(action[3])?.addEventListener('change', renderActions);
+ });
+ const toggle = byId('v5ActionToggle');
+ function closeMenu() { byId('v5ActionMenu').hidden = true; toggle.setAttribute('aria-expanded', 'false'); }
+ toggle.addEventListener('click', () => {
+ const menu = byId('v5ActionMenu');
+ menu.hidden = !menu.hidden;
+ toggle.setAttribute('aria-expanded', String(!menu.hidden));
+ });
+ document.addEventListener('click', event => {
+ if (!byId('v5ActionMenu').contains(event.target) && !toggle.contains(event.target)) closeMenu();
+ });
+ form.addEventListener('keydown', event => { if (event.key === 'Escape') { closeMenu(); toggle.focus(); } });
+ function renderPicker(host, items, current, choose, search) {
+ host.replaceChildren();
+ const filtered = items.filter(item => item.name.toLocaleLowerCase('da').includes(search.toLocaleLowerCase('da')));
+ filtered.forEach(item => {
+ const button = document.createElement('button');
+ button.type = 'button'; button.textContent = item.name;
+ button.setAttribute('aria-pressed', String(item.id === current));
+ button.disabled = busy || Boolean(pending);
+ button.addEventListener('click', () => choose(item.id));
+ host.append(button);
+ });
+ if (!filtered.length) host.textContent = 'Ingen match';
+ }
+ function renderGroups() {
+ renderPicker(byId('v5GroupOptions'), config.groups, groupId, id => {
+ groupId = id; userId = null; members = []; renderGroups(); loadMembers();
+ }, byId('v5GroupSearch').value);
+ }
+ function renderUsers() {
+ const items = [{id: null, name: 'Kun gruppen'}, ...members.map(member => ({id: member.user_id, name: member.display_name}))];
+ renderPicker(byId('v5UserOptions'), items, userId, id => { userId = id; renderUsers(); }, byId('v5UserSearch').value);
+ renderActions();
+ }
+ async function loadMembers() {
+ const sequence = ++memberRequest;
+ membersLoading = true;
+ byId('v5UserSearch').disabled = true;
+ byId('v5UserOptions').textContent = 'Henter medarbejdere…';
+ try {
+ const response = await fetch(`/api/v1/sag/${config.caseId}/assignments?group_id=${groupId}`, {credentials: 'include'});
+ if (!response.ok) throw new Error('Kunne ikke hente gruppens medarbejdere. Vælg gruppen igen for at prøve igen.');
+ const data = await response.json();
+ if (sequence !== memberRequest) return;
+ members = data.members || [];
+ if (!members.some(member => member.user_id === userId)) userId = null;
+ byId('v5UserSearch').disabled = false;
+ byId('v5UserSearch').placeholder = 'Find medarbejder…';
+ renderUsers(); feedback('');
+ } catch (error) {
+ if (sequence === memberRequest) { byId('v5UserOptions').textContent = ''; feedback(error.message, true); }
+ } finally { if (sequence === memberRequest) membersLoading = false; }
+ }
+ byId('v5GroupSearch').addEventListener('input', renderGroups);
+ byId('v5UserSearch').addEventListener('input', renderUsers);
+ byId('commentQuickTimeMinutes').addEventListener('input', renderActions);
+ function lock(locked) {
+ form.querySelectorAll('input, textarea, select, button').forEach(el => { el.disabled = locked; });
+ // Failed submissions remain locked except for retry, avoiding an altered
+ // draft being attached to an already persisted comment.
+ if (pending && !busy) {
+ form.querySelector('button[type="submit"]').disabled = false;
+ if (pending.remaining.includes('log_time')) {
+ byId('commentQuickTimeRow').querySelectorAll('input, select').forEach(el => { el.disabled = false; });
+ toggleCommentQuickTimeStartField();
+ }
+ }
+ }
+ form.removeAttribute('onsubmit');
+ form.addEventListener('submit', async event => {
+ event.preventDefault();
+ if (busy) return;
+ const textarea = form.querySelector('textarea[name="indhold"]');
+ const content = textarea.value.trim();
+ const reason = byId('v5AssignmentReason').value.trim();
+ const chosen = getSelectedCommentActions();
+ if (!pending) {
+ if (!content) { feedback('Skriv en kommentar først.', true); return; }
+ if (assignSelected && (!groupId || !reason || membersLoading || byId('v5UserSearch').disabled)) {
+ feedback('Vælg gruppe, vent på medarbejderlisten og skriv en begrundelse.', true); return;
+ }
+ if (chosen.includes('log_time') && (Number(byId('commentQuickTimeMinutes').value) <= 0 || !byId('commentQuickTimeDate').value)) {
+ feedback('Vælg dato og et positivt minuttal før tidsregistrering.', true); return;
+ }
+ if (chosen.includes('close_case')) {
+ busy = true; lock(true);
+ let confirmed = false;
+ try { confirmed = await confirmCaseClosureIfNeeded({timeWillBeRegistered: chosen.includes('log_time')}); }
+ finally { busy = false; lock(false); }
+ if (!confirmed) return;
+ }
+ }
+ busy = true; lock(true); feedback('Gemmer…');
+ try {
+ if (!pending) {
+ const url = `/api/v1/sag/${config.caseId}/${assignSelected ? 'assign-with-reason' : 'kommentarer'}`;
+ const internal = assignSelected || chosen.includes('solution') || chosen.includes('log_time');
+ const body = assignSelected ? {group_id: groupId, primary_user_id: userId, reason, content} : {indhold: content, er_intern: internal};
+ const response = await fetch(url, {method: 'POST', credentials: 'include', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(body)});
+ const result = await response.json();
+ if (!response.ok) throw new Error(typeof result.detail === 'string' ? result.detail : 'Kunne ikke gemme kommentaren.');
+ const comment = assignSelected ? result.comment : result;
+ pending = {content, remaining: chosen.slice(), assigned: assignSelected};
+ let visibleContent = comment.indhold || content;
+ if (assignSelected) {
+ const decoder = document.createElement('textarea');
+ decoder.innerHTML = visibleContent;
+ visibleContent = decoder.value;
+ }
+ injectCreatedComment(comment, visibleContent, internal);
+ }
+ // Finish one action at a time. Retry never creates another comment
+ // or repeats actions that already returned successfully.
+ while (pending.remaining.length) {
+ await applyCommentActionPrefill(pending.remaining[0], pending.content);
+ pending.remaining.shift();
+ }
+ pending = null;
+ textarea.value = ''; byId('v5AssignmentReason').value = '';
+ assignSelected = false; clearSelectedCommentActions();
+ feedback('Kommentar og handlinger gemt.');
+ reloadCasePreservingContext();
+ } catch (error) {
+ feedback(pending ? `Kommentaren er gemt. En handling mangler: ${error.message}. Brug Fortsæt for at prøve den igen.` : error.message, true);
+ } finally { busy = false; lock(Boolean(pending)); renderActions(); }
+ });
+ renderGroups(); renderActions();
+})();
diff --git a/tests/test_mission_call_state.py b/tests/test_mission_call_state.py
new file mode 100644
index 0000000..47f079e
--- /dev/null
+++ b/tests/test_mission_call_state.py
@@ -0,0 +1,58 @@
+from app.dashboard.backend.mission_service import MissionService
+
+
+def test_stale_ringing_timeout_uses_last_update(monkeypatch):
+ calls = []
+ monkeypatch.setattr(MissionService, "_table_exists", staticmethod(lambda _table: True))
+ monkeypatch.setattr(MissionService, "get_ring_timeout_seconds", staticmethod(lambda: 120))
+ monkeypatch.setattr(
+ "app.dashboard.backend.mission_service.execute_query",
+ lambda query, params=(): calls.append((query, params)),
+ )
+
+ MissionService.expire_stale_ringing_calls()
+
+ assert len(calls) == 1
+ query, params = calls[0]
+ assert "updated_at < (NOW() - make_interval(secs => %s))" in query
+ assert "started_at < (NOW() - make_interval(secs => %s))" not in query
+ assert params == (120,)
+
+
+def test_repeated_ringing_event_refreshes_call_start_time():
+ from pathlib import Path
+
+ source = Path("app/dashboard/backend/mission_router.py").read_text(encoding="utf-8")
+ assert "started_at = EXCLUDED.started_at" in source
+ assert "started_at = LEAST(mission_call_state.started_at, EXCLUDED.started_at)" not in source
+
+
+def test_yealink_incoming_url_uses_local_value_for_recipient_and_queue():
+ from pathlib import Path
+
+ source = Path("app/settings/frontend/settings.html").read_text(encoding="utf-8")
+ assert "queue_name=$local&recipient_extension=$local" in source
+
+
+def test_case_location_list_is_customer_scoped_and_prioritizes_customer_address():
+ from pathlib import Path
+
+ template = Path("app/modules/sag/templates/detail_v4.html").read_text(encoding="utf-8")
+ assert "/api/v1/locations/by-customer/${encodeURIComponent(caseAddCustomerId)}" in template
+ assert "visible = customerLocations.filter" in template
+ assert "customer.address" in template
+ assert "normalizeAddress" in template
+
+
+def test_active_calls_are_not_expired_during_state_read():
+ import inspect
+
+ source = inspect.getsource(MissionService.get_active_calls)
+ assert "expire_stale_ringing_calls" not in source
+
+
+def test_active_call_read_failure_preserves_client_state():
+ from pathlib import Path
+
+ source = Path("app/dashboard/frontend/mission_control_v2.html").read_text(encoding="utf-8")
+ assert '"active_calls", MissionService' not in source
diff --git a/tests/test_planner.py b/tests/test_planner.py
index c349a9e..2fb61b4 100644
--- a/tests/test_planner.py
+++ b/tests/test_planner.py
@@ -104,6 +104,8 @@ def test_case_planner_exposes_case_specific_allocations_endpoint():
assert '"remaining_minutes"' in source
assert '"deferred"' in source
assert '"subcases"' in source
+ assert "allocation_summary.planned_minutes" in source
+ assert "COALESCE(allocation_summary.items, '[]'::json) AS allocations" in source
def test_closed_case_is_checked_before_allocation(monkeypatch):
diff --git a/tests/test_sag_module.py b/tests/test_sag_module.py
index 83fc88b..c838ef7 100644
--- a/tests/test_sag_module.py
+++ b/tests/test_sag_module.py
@@ -534,10 +534,10 @@ def test_v4_relation_tree_is_own_tab_with_search_filters_and_inline_module_conte
assert 'id="relationTreeType"' in template
assert 'id="relationTreePosition"' in template
assert 'id="relationTreeStatusFilter"' in template
- assert 'data-relation-module="comments"' in template
- assert 'data-relation-module="todos"' in template
- assert 'data-relation-module="files"' in template
- assert 'data-relation-module="relations"' in template
+ assert "['comments', 'Kommentarer'" in template
+ assert "['todos', 'Opgaver'" in template
+ assert "['files', 'Filer'" in template
+ assert "['relations', 'Relationer'" in template
def test_case_route_access_requires_edit_for_nested_post():
@@ -659,6 +659,28 @@ def test_v4_compact_time_module_is_moved_below_tags_and_creates_drafts():
assert "medarbejder_ids: [null]" in template
+def test_new_case_starts_with_only_core_fields_and_reveals_type_modules():
+ template = Path("app/modules/sag/templates/create.html").read_text()
+ script = Path("static/js/case-create.js").read_text()
+
+ assert 'id="caseRelationsSection" class="d-none"' in template
+ assert 'id="caseGroupField"' in template
+ assert 'id="caseTagsSection"' in template
+ assert 'id="caseTagsButton"' in template
+ assert "window.tagPicker.showSelection(addTag);" in script
+ assert "const availableModules = visiblePanels;" in script
+ assert "relationsSection?.classList.toggle('d-none'" in script
+ assert "addEventListener('change', () => updateCaseTypeSections(true))" in template
+
+
+def test_global_search_enter_opens_exact_hash_case_number_in_v4():
+ template = Path("app/shared/frontend/base.html").read_text()
+
+ assert "if (navigateToCaseNumber(globalSearchInput.value))" in template
+ assert "match(/^#\\s*(\\d+)$/)" in template
+ assert "window.location.href = `/sag/${sagId}/v4`;" in template
+
+
def test_postit_keeps_tags_time_and_full_time_tab_available():
template = Path("app/modules/sag/templates/detail_v4.html").read_text()
@@ -771,6 +793,12 @@ def test_v4_embeds_case_planner_with_direct_subcase_creation():
assert "function shiftEmbeddedPlannerWeek(direction)" in template
assert "Klik på en dag for at allokere tid" in template
assert 'id="casePlannerSlotModal"' in template
+ assert "function renderEmbeddedPlannerSubcaseCalendar()" in template
+ assert "function openEmbeddedPlannerSubcaseSlot(dateKey)" in template
+ assert "async function saveEmbeddedPlannerSubcaseSlot(event)" in template
+ assert "subcase.allocations || []" in template
+ assert "Tid er allokeret til undersag" in template
+ assert 'id="casePlannerSubcaseWeek"' in template
assert "function openEmbeddedPlannerSlot(dateKey)" in template
assert "async function saveEmbeddedPlannerSlot(event)" in template
assert "Dette er planlagt tid" in template
@@ -826,6 +854,48 @@ def test_v4_uses_fixed_case_statuses_and_blocks_unfinished_time_on_close():
assert "const closesCase = nextStatus.toLowerCase() === 'lukket';" in template
assert "Sagen kan kun lukkes, når alle tidsregistreringer er færdigbehandlede" in template
assert "showToast(e.message || 'Kunne ikke gemme status', 'danger');" in template
+ assert "select.value = previousStatus;" in template
+
+
+def test_v4_does_not_add_legacy_current_status_to_trigger_options():
+ views = Path("app/modules/sag/frontend/views.py").read_text()
+ v4_block = views.split('template_name = "modules/sag/templates/detail_v4.html"', 1)[0].rsplit(
+ "status_options = _fetch_case_status_options()", 1
+ )[1].split('is_deadline_overdue', 1)[0]
+
+ assert "status_options.append(current_status)" not in v4_block
+
+
+def test_v4_uses_a_scoped_visual_system_for_consistent_case_ui():
+ template = Path("app/modules/sag/templates/detail_v4.html").read_text()
+
+ assert "--case-primary: #0f4c75;" in template
+ assert "--case-primary-soft: #eef6fa;" in template
+ assert ".case-detail-page-shell .case-v4-widget-dashboard .card" in template
+ assert ".case-detail-page-shell .btn-outline-success" in template
+ assert "#caseTabsContent .tab-pane:not(#details) .history-timeline-shell" in template
+ assert "#caseTabsContent .tab-pane:not(#details) .table-hover > tbody > tr:hover" in template
+ assert ".case-relation-hero { display:flex" in template
+ assert "background:var(--case-primary-soft, #eef6fa);" in template
+
+
+def test_v4_relation_tree_can_create_related_cases_from_the_tree():
+ template = Path("app/modules/sag/templates/detail_v4.html").read_text()
+
+ assert 'onclick="showCreateRelatedModal()">
Opret relateret sag' in template
+ assert 'data-relation-create="${safeId}"' in template
+ assert "window.showCreateRelatedModalForCase" in template
+ assert "relatedCaseSourceId = Number(sourceCaseId) || caseId;" in template
+ assert "const sourceCaseId = Number(relatedCaseSourceId) || caseId;" in template
+
+
+def test_v4_relation_tree_exposes_module_content_as_a_clear_preview():
+ template = Path("app/modules/sag/templates/detail_v4.html").read_text()
+
+ assert "Indhold på sag #${safeId}" in template
+ assert "loadRelationModuleOverview" in template
+ assert "const nonEmpty = results.filter(Boolean);" in template
+ assert "relation-tree-module-section" in template
def test_sag_list_has_smart_toolbar_search_and_employee_quick_filters():
diff --git a/tests/test_sag_solution_knowledge.py b/tests/test_sag_solution_knowledge.py
index bab74b8..9002986 100644
--- a/tests/test_sag_solution_knowledge.py
+++ b/tests/test_sag_solution_knowledge.py
@@ -74,3 +74,12 @@ def test_customer_articles_are_excluded_without_customer_scope(monkeypatch):
assert result["total"] == 0
assert "ka.visibility IN ('general','internal')" in captured[0][0]
assert "visibility='customer'" not in captured[0][0]
+
+
+def test_solution_management_supports_setting_the_case_customer():
+ template = (Path(__file__).resolve().parent.parent / "app/modules/sag/templates/solutions_management.html").read_text()
+ source = (Path(__file__).resolve().parent.parent / "app/modules/sag/backend/solutions.py").read_text()
+ assert 'id="editCustomerSearch"' in template
+ assert '/api/v1/search/customers' in template
+ assert '/customer/replace' in template
+ assert 'sg.customer_id, c.name AS customer_name' in source
diff --git a/tests/test_sag_v5.py b/tests/test_sag_v5.py
new file mode 100644
index 0000000..e905eaf
--- /dev/null
+++ b/tests/test_sag_v5.py
@@ -0,0 +1,163 @@
+"""V5 assignment persistence is atomic and leaves the existing APIs untouched."""
+import asyncio
+from collections import deque
+from unittest.mock import Mock
+
+import pytest
+from fastapi import HTTPException
+from starlette.requests import Request
+
+# Reuse the established test module's optional-auth isolation.
+from test_sag_module import sag_router
+
+
+class Cursor:
+ def __init__(self, rows, fail_insert=False):
+ self.rows = deque(rows)
+ self.calls = []
+ self.fail_insert = fail_insert
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *_):
+ return False
+
+ def execute(self, sql, params):
+ self.calls.append((sql, params))
+ if self.fail_insert and 'INSERT INTO sag_kommentarer' in sql:
+ raise RuntimeError('database failure')
+
+ def fetchone(self):
+ return self.rows.popleft()
+
+
+def setup_transaction(monkeypatch, *, target=True, fail_insert=False):
+ rows = [
+ {'id': 12, 'ansvarlig_bruger_id': 2, 'assigned_group_id': 3},
+ {'name': 'Support'},
+ {'name': 'Maria'} if target else None,
+ {'name': 'Christian'}, {'name': 'Peter'}, {'name': 'Drift'},
+ {'id': 91, 'indhold': 'intern note', 'er_intern': True},
+ ]
+ cursor = Cursor(rows, fail_insert)
+ conn = Mock()
+ conn.cursor.return_value = cursor
+ release = Mock()
+ monkeypatch.setattr(sag_router, 'get_db_connection', lambda: conn)
+ monkeypatch.setattr(sag_router, 'release_db_connection', release)
+ monkeypatch.setattr(sag_router, 'table_has_column', lambda *_: True)
+ monkeypatch.setattr(sag_router, '_get_user_id_from_request', lambda _: 1)
+ return conn, cursor, release
+
+
+def run_assignment(**kwargs):
+ payload = sag_router.CaseAssignmentWithReason(
+ group_id=4, primary_user_id=5, reason=kwargs.pop('reason', ' Adgang til serveren '),
+ content=kwargs.pop('content', ' Netværk kontrolleret '), **kwargs,
+ )
+ request = Request({'type': 'http', 'method': 'POST', 'path': '/api/v1/sag/12/assign-with-reason', 'headers': []})
+ return asyncio.run(sag_router.assign_case_with_reason(12, payload, request, {"id": 1}))
+
+
+def test_assignment_and_internal_reason_commit_together(monkeypatch):
+ conn, cursor, release = setup_transaction(monkeypatch)
+ result = run_assignment()
+ assert result['comment']['er_intern'] is True
+ conn.commit.assert_called_once()
+ conn.rollback.assert_not_called()
+ release.assert_called_once_with(conn)
+ insert = next(params for sql, params in cursor.calls if 'INSERT INTO sag_kommentarer' in sql)
+ assert insert[:2] == (12, 'Christian')
+ assert 'Tildeling: Peter → Maria' in insert[2]
+ assert 'Gruppe: Drift → Support' in insert[2]
+ assert 'Begrundelse: Adgang til serveren' in insert[2]
+ assert insert[2].startswith('Netværk kontrolleret\n')
+ # Compatible colleagues survive; the new primary is added without duplicates.
+ assert any('NOT EXISTS' in sql and 'DELETE FROM sag_case_assignees' in sql for sql, _ in cursor.calls)
+ assert any('ON CONFLICT' in sql for sql, _ in cursor.calls)
+
+
+def test_invalid_group_member_does_not_change_assignment(monkeypatch):
+ conn, cursor, release = setup_transaction(monkeypatch, target=False)
+ with pytest.raises(HTTPException) as error:
+ run_assignment()
+ assert error.value.status_code == 400
+ assert not any('UPDATE sag_sager' in sql for sql, _ in cursor.calls)
+ conn.commit.assert_not_called()
+ conn.rollback.assert_called_once()
+ release.assert_called_once_with(conn)
+
+
+def test_comment_failure_rolls_back_assignment(monkeypatch):
+ conn, _, release = setup_transaction(monkeypatch, fail_insert=True)
+ with pytest.raises(HTTPException) as error:
+ run_assignment()
+ assert error.value.status_code == 500
+ conn.commit.assert_not_called()
+ conn.rollback.assert_called_once()
+ release.assert_called_once_with(conn)
+
+
+def test_assignment_reason_is_plain_text_in_legacy_comment_renderer(monkeypatch):
+ _, cursor, _ = setup_transaction(monkeypatch)
+ run_assignment(reason='

', content='')
+ note = next(params[2] for sql, params in cursor.calls if 'INSERT INTO sag_kommentarer' in sql)
+ assert '
![]()