2026-10-03 14:06:51 +02:00
/* 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' ) ;
2026-10-08 08:58:02 +02:00
const billingHost = document . createElement ( 'div' ) ;
billingHost . className = 'col-12' ;
billingHost . innerHTML = '<label for="v5QuickBilling" class="form-label small">Afregning</label><select id="v5QuickBilling" class="form-select form-select-sm" disabled><option>Indlæser afregning…</option></select><div id="v5BillingHint" class="form-text">Kladder kontrolleres af en chef før afregning.</div>' ;
byId ( 'commentQuickTimeRow' ) . querySelector ( '.row' ) . append ( billingHost ) ;
const compactBillingHost = billingHost . cloneNode ( true ) ;
compactBillingHost . querySelector ( 'label' ) . htmlFor = 'v5CompactBilling' ;
compactBillingHost . querySelector ( 'select' ) . id = 'v5CompactBilling' ;
compactBillingHost . querySelector ( '.form-text' ) . removeAttribute ( 'id' ) ;
byId ( 'caseQuickTimeDraftForm' ) . querySelector ( '.row' ) . insertBefore ( compactBillingHost , byId ( 'caseQuickTimeSaveButton' ) . parentElement ) ;
byId ( 'caseQuickTimeSaveButton' ) . innerHTML = '<i class="bi bi-plus-lg me-1"></i>Gem tid' ;
2026-10-08 21:36:42 +02:00
function setAutomaticRegistrationControls ( workflowActive = false ) {
const text = workflowActive ? 'Afregning vælges automatisk' : 'Tid gemmes som kladde. Afregning vælges i Tidsregistrering.' ;
for ( const select of [ byId ( 'v5QuickBilling' ) , byId ( 'v5CompactBilling' ) , byId ( 'time_billing_method' ) , byId ( 'quickTimeBillingMethod' ) ] . filter ( Boolean ) ) {
select . replaceChildren ( new Option ( 'Automatisk / kladde' , 'draft' ) ) ;
select . value = 'draft' ; select . disabled = true ; select . hidden = true ; select . dataset . billingReady = 'true' ;
const label = select . parentElement . querySelector ( ` label[for=" ${ select . id } "] ` ) ; if ( label ) label . hidden = true ;
}
for ( const host of [ billingHost , compactBillingHost ] ) host . querySelector ( '.form-text' ) . textContent = text ;
for ( const id of [ 'time_internal' , 'sol_time_internal' ] ) { const checkbox = byId ( id ) ; if ( checkbox ) { checkbox . checked = false ; checkbox . disabled = true ; const wrapper = checkbox . closest ( '.form-check' ) ; if ( wrapper ) wrapper . hidden = true ; } }
}
setAutomaticRegistrationControls ( ) ;
2026-10-08 23:06:55 +02:00
const previewFields = [
{ host : compactBillingHost , minutes : 'caseQuickTimeMinutes' , description : 'caseQuickTimeDescription' , type : 'caseQuickTimeType' } ,
{ host : billingHost , minutes : 'commentQuickTimeMinutes' , description : 'commentText' , type : 'commentQuickTimeType' }
] ;
let previewTimer ;
async function refreshBillingPreview ( ) {
const names = { subscription : 'Fastpris' , prepaid : 'Klippekort' , invoice : 'Faktura' , internal : 'Intern tid' , non _billable : 'Ikke fakturerbar' , draft : 'Kladde' } ;
await Promise . all ( previewFields . map ( async fields => {
const sequence = ( fields . sequence || 0 ) + 1 ; fields . sequence = sequence ;
const hint = fields . host . querySelector ( '.form-text' ) ;
const params = new URLSearchParams ( { minutes : Math . max ( 1 , Number ( byId ( fields . minutes ) ? . value ) || 30 ) , description : ( byId ( fields . description ) || document . querySelector ( 'textarea[name=indhold]' ) ) ? . value || '' , time _type : byId ( fields . type ) ? . value || 'support' } ) ;
try {
const response = await fetch ( ` /api/v1/timetracking/cases/ ${ config . caseId } /billing-preview? ${ params } ` , { credentials : 'include' } ) ;
const data = await response . json ( ) ; if ( ! response . ok ) throw new Error ( 'Preview failed' ) ;
if ( sequence !== fields . sequence ) return ;
hint . textContent = 'Forslag: ' + ( data . status === 'manual_review' ? 'Manuel vurdering' : names [ data . method ] || 'Manuel vurdering' ) ;
} catch ( error ) { if ( sequence === fields . sequence ) hint . textContent = 'Afregningsforslag kunne ikke beregnes.' ; }
} ) ) ;
}
function scheduleBillingPreview ( ) { clearTimeout ( previewTimer ) ; previewTimer = setTimeout ( refreshBillingPreview , 250 ) ; }
for ( const fields of previewFields ) for ( const id of [ fields . minutes , fields . description , fields . type ] ) {
const input = byId ( id ) || ( id === 'commentText' ? document . querySelector ( 'textarea[name=indhold]' ) : null ) ; if ( input ) { input . addEventListener ( 'input' , scheduleBillingPreview ) ; input . addEventListener ( 'change' , scheduleBillingPreview ) ; input . addEventListener ( 'focus' , scheduleBillingPreview ) ; }
}
const tagList = byId ( 'case-tags-module' ) ;
if ( tagList ) new MutationObserver ( scheduleBillingPreview ) . observe ( tagList , { childList : true , subtree : true } ) ;
2026-10-08 08:58:02 +02:00
async function loadBillingOptions ( ) {
try {
const response = await fetch ( ` /api/v1/timetracking/cases/ ${ config . caseId } /billing-options ` , { credentials : 'include' } ) ;
const data = await response . json ( ) ;
if ( ! response . ok ) throw new Error ( data . detail || 'Afregningsmuligheder kunne ikke hentes' ) ;
2026-10-08 21:36:42 +02:00
setAutomaticRegistrationControls ( data . workflow _active === true ) ;
2026-10-08 23:06:55 +02:00
await refreshBillingPreview ( ) ;
2026-10-08 21:36:42 +02:00
const anchor = byId ( 'timeV1Date' ) ;
if ( anchor && ! byId ( 'v5TabBilling' ) ) {
const host = document . createElement ( 'div' ) ; host . className = 'col-12' ;
const label = document . createElement ( 'label' ) ; label . htmlFor = 'v5TabBilling' ; label . className = 'form-label small' ; label . textContent = 'Afregning (valgfrit)' ;
const select = document . createElement ( 'select' ) ; select . id = 'v5TabBilling' ; select . className = 'form-select form-select-sm' ;
select . add ( new Option ( 'Kladde / automatisk' , 'draft' ) ) ;
if ( data . workflow _active !== true ) { if ( data . has _customer ) select . add ( new Option ( 'Faktura' , 'invoice' ) ) ; ( data . prepaid _cards || [ ] ) . forEach ( c => select . add ( new Option ( 'Klippekort ' + ( c . card _number || c . id ) , 'card_' + c . id ) ) ) ; ( data . agreements || [ ] ) . forEach ( a => select . add ( new Option ( 'Fastpris ' + ( a . agreement _number || a . id ) , 'fpa_' + a . id ) ) ) ; select . add ( new Option ( 'Intern tid' , 'internal' ) ) ; select . add ( new Option ( 'Ikke fakturerbar' , 'non_billable' ) ) ; }
const hint = document . createElement ( 'div' ) ; hint . className = 'form-text' ; hint . textContent = data . workflow _active ? 'Afregningen vælges af workflows. Brug afregningsbeslutningen nedenfor for en begrundet ændring.' : 'Manuel afregning kan kun vælges her i Tidsregistrering.' ;
host . append ( label , select , hint ) ; anchor . closest ( '.row' ) ? . append ( host ) ;
2026-10-08 08:58:02 +02:00
}
2026-10-08 21:36:42 +02:00
} catch ( error ) { byId ( 'v5BillingHint' ) . textContent = 'Tid kan stadig registreres som kladde. Afregningsmuligheder kunne ikke hentes.' ; }
2026-10-08 08:58:02 +02:00
}
if ( document . readyState === 'loading' ) document . addEventListener ( 'DOMContentLoaded' , loadBillingOptions , { once : true } ) ; else loadBillingOptions ( ) ;
const deferredInput = byId ( 'topbarDeferredInput' ) ;
const deadlineInput = byId ( 'topbarDeadlineInput' ) ;
function syncDateLimits ( ) {
if ( deadlineInput . value ) deferredInput . max = deadlineInput . value ;
else deferredInput . removeAttribute ( 'max' ) ;
if ( deferredInput . value ) deadlineInput . min = deferredInput . value ;
else deadlineInput . removeAttribute ( 'min' ) ;
}
[ deferredInput , deadlineInput ] . forEach ( input => input . addEventListener ( 'change' , event => {
if ( deferredInput . value && deadlineInput . value && deferredInput . value > deadlineInput . value ) {
event . stopImmediatePropagation ( ) ;
input . value = input . defaultValue ;
showCaseFeedback ( 'Udsat må ikke ligge efter deadline' , 'danger' ) ;
}
syncDateLimits ( ) ;
} , true ) ) ;
syncDateLimits ( ) ;
2026-10-03 14:06:51 +02:00
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' ;
2026-10-08 08:58:02 +02:00
modules . setAttribute ( 'aria-label' , 'Todo, filer og moduler' ) ;
2026-10-03 14:06:51 +02:00
const tabs = byId ( 'caseTabs' ) ;
tabs . before ( board ) ;
board . append ( facts , center , modules ) ;
const widgets = [ ... shell . querySelectorAll ( '.case-v4-widget-dashboard .row > div > .card' ) ] ;
2026-10-08 08:58:02 +02:00
[ 3 , 1 , 0 ] . forEach ( index => { if ( widgets [ index ] ) facts . append ( widgets [ index ] ) ; } ) ;
const factsDisclosure = document . createElement ( 'details' ) ;
factsDisclosure . className = 'v5-facts-disclosure' ;
const factsSummary = document . createElement ( 'summary' ) ;
factsSummary . textContent = 'Kunde, kontakt og sagsoplysninger' ;
const factsContent = document . createElement ( 'div' ) ;
factsContent . className = 'v5-facts-content' ;
factsContent . append ( ... facts . children ) ;
factsDisclosure . append ( factsSummary , factsContent ) ;
facts . append ( factsDisclosure ) ;
const compactScreen = window . matchMedia ( '(max-width: 1400px)' ) ;
function adaptFacts ( event ) { factsDisclosure . open = ! event . matches ; }
adaptFacts ( compactScreen ) ;
compactScreen . addEventListener ( 'change' , adaptFacts ) ;
const anyDeskButton = document . createElement ( 'button' ) ;
anyDeskButton . id = 'v5AnyDeskOpenButton' ;
anyDeskButton . type = 'button' ;
anyDeskButton . className = 'v5-anydesk-button' ;
anyDeskButton . title = 'Forbind med AnyDesk' ;
anyDeskButton . innerHTML = '<i class="bi bi-display" aria-hidden="true"></i><span>AnyDesk</span>' ;
anyDeskButton . addEventListener ( 'click' , ( ) => window . openCaseAnyDeskModal ( ) ) ;
shell . querySelector ( '.case-header-identity' ) ? . append ( anyDeskButton ) ;
2026-10-03 14:06:51 +02:00
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' ) ) ;
2026-10-08 08:58:02 +02:00
const heroTodo = shell . querySelector ( '.case-header-todo' ) ;
if ( heroTodo ) {
const addTodo = document . createElement ( 'button' ) ;
addTodo . type = 'button' ;
addTodo . className = 'v5-hero-add-todo' ;
addTodo . title = 'Opret todo på sagen' ;
addTodo . setAttribute ( 'aria-label' , 'Opret todo på sagen' ) ;
addTodo . innerHTML = '<i class="bi bi-plus-lg" aria-hidden="true"></i>' ;
addTodo . addEventListener ( 'click' , ( ) => window . openRelTodoModal ( config . caseId , byId ( 'sag-titel-text' ) ? . textContent . trim ( ) || '' ) ) ;
heroTodo . append ( addTodo ) ;
}
const timeline = byId ( 'topbarStartDateInput' ) ? . closest ( '.card' ) ;
if ( timeline ) {
timeline . classList . add ( 'v5-hero-timeline' ) ;
shell . querySelector ( '.case-header-main' ) . append ( timeline ) ;
}
2026-10-03 14:06:51 +02:00
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' ) ;
2026-10-08 08:58:02 +02:00
[ 'todo-steps' , 'files' , 'hardware' , 'time' ] . reverse ( ) . forEach ( key => {
2026-10-03 14:06:51 +02:00
const card = shell . querySelector ( ` .right-module-card[data-module=" ${ key } "] ` ) ;
if ( card ) rightGrid . prepend ( card ) ;
} ) ;
2026-10-08 08:58:02 +02:00
const relations = shell . querySelector ( '.right-module-card[data-module="relations"]' ) ;
if ( relations ) {
relations . classList . add ( 'v5-center-relations' ) ;
const commentsCard = byId ( 'comment-form' ) . closest ( '.left-module-card' ) ;
commentsCard . after ( relations ) ;
}
2026-10-03 14:06:51 +02:00
// 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 ;
2026-10-08 08:58:02 +02:00
const memberCache = new Map ( ) ;
const disabledBeforeLock = new Map ( ) ;
2026-10-03 14:06:51 +02:00
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' ;
2026-10-08 08:58:02 +02:00
window . CaseCommandComposer ? . updateSubmitLabel ( ) ;
2026-10-03 14:06:51 +02:00
}
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 ( ) ;
2026-10-08 08:58:02 +02:00
closeMenu ( ) ;
2026-10-03 14:06:51 +02:00
}
actions . forEach ( action => {
const button = document . createElement ( 'button' ) ;
button . id = ` v5Option- ${ action [ 0 ] } ` ;
button . type = 'button' ;
button . innerHTML = ` <i class="bi bi- ${ action [ 2 ] } me-1"></i> ` ;
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 ( ) {
2026-10-08 08:58:02 +02:00
const requestedGroupId = groupId ;
2026-10-03 14:06:51 +02:00
const sequence = ++ memberRequest ;
membersLoading = true ;
byId ( 'v5UserSearch' ) . disabled = true ;
byId ( 'v5UserOptions' ) . textContent = 'Henter medarbejdere…' ;
try {
2026-10-08 08:58:02 +02:00
let data = memberCache . get ( requestedGroupId ) ;
if ( ! data ) {
const response = await fetch ( ` /api/v1/sag/ ${ config . caseId } /assignments?group_id= ${ requestedGroupId } ` , { credentials : 'include' } ) ;
if ( ! response . ok ) throw new Error ( 'Kunne ikke hente gruppens medarbejdere. Vælg gruppen igen for at prøve igen.' ) ;
data = await response . json ( ) ;
memberCache . set ( requestedGroupId , data ) ;
}
2026-10-03 14:06:51 +02:00
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 ) ;
2026-10-08 08:58:02 +02:00
form . querySelector ( 'textarea[name="indhold"]' ) . addEventListener ( 'input' , renderActions ) ;
2026-10-03 14:06:51 +02:00
function lock ( locked ) {
2026-10-08 08:58:02 +02:00
form . querySelectorAll ( 'input, textarea, select, button' ) . forEach ( el => {
if ( locked ) {
if ( ! disabledBeforeLock . has ( el ) ) disabledBeforeLock . set ( el , el . disabled ) ;
el . disabled = true ;
} else if ( disabledBeforeLock . has ( el ) ) {
el . disabled = disabledBeforeLock . get ( el ) ;
disabledBeforeLock . delete ( el ) ;
}
} ) ;
2026-10-03 14:06:51 +02:00
// 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 ( ) ;
}
}
}
2026-10-08 08:58:02 +02:00
form . querySelector ( 'textarea[name="indhold"]' ) . addEventListener ( 'keydown' , event => {
if ( event . key !== 'Enter' || ( ! event . metaKey && ! event . ctrlKey ) || event . isComposing ) return ;
event . preventDefault ( ) ;
event . stopImmediatePropagation ( ) ;
const submit = form . querySelector ( 'button[type="submit"]' ) ;
if ( ! busy && submit && ! submit . disabled ) form . requestSubmit ( submit ) ;
} ) ;
form . querySelector ( 'button[type="submit"]' ) . title = 'Gem kommentar · Cmd+Enter / Ctrl+Enter' ;
2026-10-03 14:06:51 +02:00
form . removeAttribute ( 'onsubmit' ) ;
form . addEventListener ( 'submit' , async event => {
event . preventDefault ( ) ;
if ( busy ) return ;
2026-10-08 08:58:02 +02:00
const containsCommand = /^\s*\//m . test ( form . querySelector ( 'textarea[name="indhold"]' ) . value ) ;
if ( containsCommand && ! window . CaseCommandComposer ) {
feedback ( 'Kommandofeltet indlæses. Vent et øjeblik og prøv igen.' , true ) ;
return ;
}
if ( window . CaseCommandComposer ? . hasCommands ( ) ) {
if ( pending || assignSelected || getSelectedCommentActions ( ) . length ) {
feedback ( 'Fjern handlingerne fra plusmenuen, eller skriv dem som kommandoer, før du gemmer.' , true ) ;
return ;
}
busy = true ; lock ( true ) ; feedback ( 'Gemmer kommentar og kommandoer…' ) ;
try {
const result = await window . CaseCommandComposer . submit ( ) ;
feedback ( result . message ) ;
window . dispatchEvent ( new CustomEvent ( 'bmc:timer-state-changed' ) ) ;
if ( result . reload ) reloadCasePreservingContext ( ) ;
else {
if ( typeof loadTimeTrackingTab === 'function' ) loadTimeTrackingTab ( ) ;
if ( typeof loadTodoSteps === 'function' ) loadTodoSteps ( ) ;
}
} catch ( error ) { feedback ( error . message , true ) ; }
finally {
busy = false ; lock ( false ) ; renderActions ( ) ;
window . CaseCommandComposer . updateSubmitLabel ( ) ;
}
return ;
}
2026-10-03 14:06:51 +02:00
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 ;
}
2026-10-08 08:58:02 +02:00
if ( chosen . includes ( 'log_time' ) && byId ( 'v5QuickBilling' ) . dataset . billingReady !== 'true' ) { feedback ( 'Afregningsmuligheder er ikke indlæst. Genindlæs siden.' , true ) ; return ; }
2026-10-03 14:06:51 +02:00
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.' ) ;
2026-10-08 08:58:02 +02:00
window . CaseCommandComposer ? . reset ( ) ;
2026-10-03 14:06:51 +02:00
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 ( ) ;
2026-10-08 08:58:02 +02:00
applyCommentFilters ( ) ;
2026-10-03 14:06:51 +02:00
} ) ( ) ;