2026-09-09 10:34:49 +02:00
( ( ) => {
const root = document . querySelector ( '.planner-page' ) ; if ( ! root ) return ;
2026-09-20 03:33:10 +02:00
const state = { view : 'week' , scope : 'mine' , anchor : new Date ( ) , meta : null , data : null , cases : [ ] , overdueCases : [ ] , quick : 'unplanned' , selected : null , caseRefresh : 0 } ;
2026-09-09 10:34:49 +02:00
const $ = s => document . querySelector ( s ) , pad = n => String ( n ) . padStart ( 2 , '0' ) ;
const localISO = d => ` ${ d . getFullYear ( ) } - ${ pad ( d . getMonth ( ) + 1 ) } - ${ pad ( d . getDate ( ) ) } T ${ pad ( d . getHours ( ) ) } : ${ pad ( d . getMinutes ( ) ) } ` ;
const dateKey = d => ` ${ d . getFullYear ( ) } - ${ pad ( d . getMonth ( ) + 1 ) } - ${ pad ( d . getDate ( ) ) } ` ;
const esc = v => String ( v ? ? '' ) . replace ( /[&<>"']/g , c => ( { '&' : '&' , '<' : '<' , '>' : '>' , '"' : '"' , "'" : ''' } [ c ] ) ) ;
const fmtMin = m => m ? ` ${ Math . floor ( m / 60 ) } t ${ m % 60 ? ' ' + m % 60 + 'm' : '' } ` : '0t' ;
const api = async ( url , opt = { } ) => { const r = await fetch ( url , { headers : { 'Content-Type' : 'application/json' } , ... opt } ) ; let b ; try { b = await r . json ( ) } catch { b = { detail : 'Ukendt serverfejl' } } if ( ! r . ok ) { const e = new Error ( typeof b . detail === 'string' ? b . detail : b . detail ? . message || 'Handlingen mislykkedes' ) ; e . status = r . status ; e . body = b ; throw e } return b } ;
function range ( ) { let a = new Date ( state . anchor ) , b ; if ( state . view === 'day' ) { a . setHours ( 0 , 0 , 0 , 0 ) ; b = new Date ( a ) ; b . setDate ( b . getDate ( ) + 1 ) } else if ( state . view === 'week' ) { a . setHours ( 0 , 0 , 0 , 0 ) ; a . setDate ( a . getDate ( ) - ( ( a . getDay ( ) + 6 ) % 7 ) ) ; b = new Date ( a ) ; b . setDate ( b . getDate ( ) + 7 ) } else { a = new Date ( a . getFullYear ( ) , a . getMonth ( ) , 1 ) ; b = new Date ( a . getFullYear ( ) , a . getMonth ( ) + 1 , 1 ) } return [ a , b ] }
2026-10-20 07:18:57 +02:00
function selectedUsers ( ) { return state . scope === 'mine' ? [ Number ( root . dataset . currentUser ) ] : Array . from ( $ ( '#employeeFilter' ) . selectedOptions , o => Number ( o . value ) ) }
async function init ( ) { state . meta = await api ( '/api/v1/planner/metadata' ) ; for ( const u of state . meta . users ) { $ ( '#responsibleFilter' ) . insertAdjacentHTML ( 'beforeend' , ` <option value=" ${ u . id } "> ${ esc ( u . name ) } </option> ` ) } for ( const d of state . meta . departments || [ ] ) { $ ( '#departmentFilter' ) . insertAdjacentHTML ( 'beforeend' , ` <option value=" ${ d . id } "> ${ esc ( d . name ) } </option> ` ) } for ( const u of state . meta . users ) { $ ( '#employeeFilter' ) . insertAdjacentHTML ( 'beforeend' , ` <option value=" ${ u . id } " selected> ${ esc ( u . name ) } </option> ` ) } if ( new URLSearchParams ( location . search ) . has ( 'embedded' ) ) { state . scope = 'team' ; document . querySelectorAll ( '[data-scope]' ) . forEach ( b => b . classList . toggle ( 'active' , b . dataset . scope === 'team' ) ) } bind ( ) ; $ ( '#employeeFilter' ) . onchange = ( ) => { state . scope = 'team' ; document . querySelectorAll ( '[data-scope]' ) . forEach ( b => b . classList . toggle ( 'active' , b . dataset . scope === 'team' ) ) ; loadPeriod ( ) } ; await Promise . all ( [ loadCases ( ) , loadPeriod ( ) ] ) ; setInterval ( ( ) => { if ( ! document . hidden ) loadPeriod ( ) } , 30000 ) }
2026-09-09 10:34:49 +02:00
function bind ( ) { let timer ; $ ( '#caseSearch' ) . addEventListener ( 'input' , ( ) => { clearTimeout ( timer ) ; timer = setTimeout ( loadCases , 250 ) } ) ; [ 'statusFilter' , 'priorityFilter' , 'typeFilter' , 'responsibleFilter' , 'departmentFilter' , 'sortFilter' ] . forEach ( id => $ ( '#' + id ) . addEventListener ( 'change' , loadCases ) ) ; document . querySelectorAll ( '[data-quick]' ) . forEach ( b => b . onclick = ( ) => { state . quick = state . quick === b . dataset . quick ? '' : b . dataset . quick ; document . querySelectorAll ( '[data-quick]' ) . forEach ( x => x . classList . toggle ( 'active' , x . dataset . quick === state . quick ) ) ; loadCases ( ) } ) ; document . querySelectorAll ( '[data-view]' ) . forEach ( b => b . onclick = ( ) => { state . view = b . dataset . view ; document . querySelectorAll ( '[data-view]' ) . forEach ( x => x . classList . toggle ( 'active' , x === b ) ) ; loadPeriod ( ) } ) ; document . querySelectorAll ( '[data-scope]' ) . forEach ( b => b . onclick = ( ) => { state . scope = b . dataset . scope ; document . querySelectorAll ( '[data-scope]' ) . forEach ( x => x . classList . toggle ( 'active' , x === b ) ) ; loadPeriod ( ) } ) ; $ ( '#prevPeriod' ) . onclick = ( ) => shift ( - 1 ) ; $ ( '#nextPeriod' ) . onclick = ( ) => shift ( 1 ) ; $ ( '#todayBtn' ) . onclick = ( ) => { state . anchor = new Date ( ) ; loadPeriod ( ) } ; $ ( '.collapse-cases' ) . onclick = ( ) => $ ( '#casePanel' ) . classList . toggle ( 'collapsed' ) ; $ ( '#saveAllocation' ) . onclick = saveSelected ; $ ( '#deleteAllocation' ) . onclick = deleteSelected ; $ ( '#copyAllocation' ) . onclick = copySelected }
function shift ( n ) { if ( state . view === 'month' ) state . anchor . setMonth ( state . anchor . getMonth ( ) + n ) ; else state . anchor . setDate ( state . anchor . getDate ( ) + n * ( state . view === 'week' ? 7 : 1 ) ) ; loadPeriod ( ) }
2026-09-20 03:33:10 +02:00
async function loadCases ( ) { const p = new URLSearchParams ( { q : $ ( '#caseSearch' ) . value , planning : [ 'unplanned' , 'partial' , 'overdue' ] . includes ( state . quick ) ? state . quick : 'all' , sort : $ ( '#sortFilter' ) . value } ) ; if ( $ ( '#statusFilter' ) . value ) p . append ( 'status' , $ ( '#statusFilter' ) . value ) ; if ( $ ( '#priorityFilter' ) . value ) p . append ( 'priority' , $ ( '#priorityFilter' ) . value ) ; if ( $ ( '#typeFilter' ) . value ) p . append ( 'case_type' , $ ( '#typeFilter' ) . value ) ; if ( $ ( '#responsibleFilter' ) . value ) p . set ( 'responsible_id' , $ ( '#responsibleFilter' ) . value ) ; if ( $ ( '#departmentFilter' ) . value ) p . set ( 'department_id' , $ ( '#departmentFilter' ) . value ) ; if ( state . quick === 'mine' ) p . set ( 'mine' , 'true' ) ; if ( state . quick === 'urgent' ) p . set ( 'urgent' , 'true' ) ; try { const current = await api ( '/api/v1/planner/cases?' + p ) ; let overdueItems = [ ] ; try { overdueItems = ( await api ( '/api/v1/planner/cases?planning=overdue&sort=priority' ) ) . items || [ ] } catch ( error ) { console . warn ( 'Kunne ikke hente overskredne planer' , error ) } state . cases = current . items ; state . overdueCases = overdueItems ; const types = [ ... new Set ( state . cases . map ( c => c . type ) . filter ( Boolean ) ) ] ; const currentType = $ ( '#typeFilter' ) . value ; $ ( '#typeFilter' ) . innerHTML = '<option value="">Alle sagstyper</option>' + types . map ( x => ` <option ${ x === currentType ? 'selected' : '' } > ${ esc ( x ) } </option> ` ) . join ( '' ) ; renderCases ( ) } catch ( e ) { $ ( '#caseList' ) . innerHTML = ` <div class="empty text-danger"> ${ esc ( e . message ) } </div> ` } }
2026-09-09 10:34:49 +02:00
function setCompactDragImage ( e , el ) { const ghost = document . createElement ( 'div' ) ; ghost . textContent = el . querySelector ( 'strong' ) ? . textContent || 'Planlæg sag' ; ghost . style . cssText = 'position:fixed;left:-9999px;top:-9999px;width:210px;padding:7px 10px;border-radius:7px;border-left:4px solid #2563eb;background:#dbeafe;color:#101828;font-size:12px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;box-shadow:0 5px 14px #0003' ; document . body . appendChild ( ghost ) ; e . dataTransfer . setDragImage ( ghost , 18 , 16 ) ; setTimeout ( ( ) => ghost . remove ( ) , 0 ) }
2026-09-20 03:33:10 +02:00
function caseCardHtml ( c ) { const overdue = Boolean ( c . overdue _planned ) , overdueDate = overdue && c . overdue _planned _until ? new Date ( c . overdue _planned _until ) . toLocaleDateString ( 'da-DK' ) : '' ; return ` <article class="case-card ${ overdue ? 'overdue-planned' : '' } " draggable="true" data-case=" ${ c . id } " data-priority=" ${ esc ( c . priority ) } "><strong># ${ c . id } · ${ esc ( c . title ) } </strong><div class="customer"> ${ esc ( c . customer _name ) } · ${ esc ( c . responsible _name || 'Ikke tildelt' ) } </div> ${ overdue ? ` <div class="case-overdue-label"><i class="bi bi-exclamation-triangle-fill"></i>Planlagt afsluttet ${ esc ( overdueDate ) } · stadig åben</div> ` : '' } <div class="case-meta"><span> ${ esc ( c . priority ) } </span><button type="button" class="btn btn-link btn-sm p-0 estimate-time" data-estimate-case=" ${ c . id } " title="Sæt forventet tid"><i class="bi bi-hourglass-split"></i> Forventet ${ fmtMin ( c . estimated _minutes ) } </button></div><div class="case-meta"><span> ${ fmtMin ( c . planned _minutes ) } planlagt</span><span> ${ fmtMin ( c . remaining _minutes ) } tilbage</span></div><button type="button" class="btn btn-link btn-sm p-0 mt-1 used-time" data-time-case=" ${ c . id } " title="Se hvem der registrerede tid og hvornår"><i class="bi bi-stopwatch"></i> ${ fmtMin ( c . used _minutes ) } brugt ${ c . latest _registration _at ? ' · senest ' + new Date ( c . latest _registration _at ) . toLocaleDateString ( 'da-DK' ) : '' } </button><div class="progress"><div class="progress-bar" style="width: ${ c . estimated _minutes ? Math . min ( 100 , c . planned _minutes * 100 / c . estimated _minutes ) : 0 } %"></div></div></article> ` }
function renderCases ( ) { const overdueHtml = state . quick !== 'overdue' && state . overdueCases . length ? ` <section class="overdue-case-section"><div class="overdue-case-heading"><i class="bi bi-exclamation-triangle-fill"></i> ${ state . overdueCases . length } åben ${ state . overdueCases . length === 1 ? ' sag er' : ' sager er' } planlagt før i dag</div> ${ state . overdueCases . map ( caseCardHtml ) . join ( '' ) } </section> ` : '' ; const casesHtml = state . cases . length ? state . cases . map ( caseCardHtml ) . join ( '' ) : '<div class="empty">Ingen sager matcher filtrene</div>' ; $ ( '#caseList' ) . innerHTML = overdueHtml + casesHtml ; document . querySelectorAll ( '.case-card' ) . forEach ( el => el . ondragstart = e => { e . dataTransfer . setData ( 'application/x-case' , el . dataset . case ) ; setCompactDragImage ( e , el ) } ) ; document . querySelectorAll ( '[data-time-case]' ) . forEach ( el => el . onclick = e => { e . stopPropagation ( ) ; showTimeEntries ( Number ( el . dataset . timeCase ) ) } ) ; document . querySelectorAll ( '[data-estimate-case]' ) . forEach ( el => el . onclick = e => { e . stopPropagation ( ) ; setEstimate ( Number ( el . dataset . estimateCase ) ) } ) }
2026-09-09 10:34:49 +02:00
async function setEstimate ( caseId ) { const c = state . cases . find ( x => x . id === caseId ) , answer = prompt ( 'Forventet tidsforbrug i timer (fx 2,5):' , c ? . estimated _minutes ? String ( c . estimated _minutes / 60 ) . replace ( '.' , ',' ) : '' ) ; if ( answer === null ) return ; const hours = Number ( answer . replace ( ',' , '.' ) ) ; if ( ! Number . isFinite ( hours ) || hours < 0 ) { toast ( 'Angiv et gyldigt antal timer' ) ; return } try { await api ( ` /api/v1/planner/cases/ ${ caseId } /estimate ` , { method : 'PATCH' , body : JSON . stringify ( { estimated _minutes : Math . round ( hours * 60 ) } ) } ) ; toast ( 'Forventet tid er gemt' ) ; await loadCases ( ) } catch ( e ) { toast ( e . message ) } }
async function showTimeEntries ( caseId ) { const c = state . cases . find ( x => x . id === caseId ) ; $ ( '#timeDialogTitle' ) . textContent = ` # ${ caseId } · ${ c ? . title || 'Sag' } ` ; $ ( '#timeDialogTotal' ) . textContent = 'Henter tidsregistreringer…' ; $ ( '#timeEntryList' ) . innerHTML = '' ; $ ( '#timeDialogCaseLink' ) . href = ` /sag/ ${ caseId } /v3 ` ; $ ( '#timeDialog' ) . showModal ( ) ; try { const data = await api ( ` /api/v1/planner/cases/ ${ caseId } /time-entries ` ) ; $ ( '#timeDialogTotal' ) . textContent = ` I alt ${ fmtMin ( data . total _minutes ) } brugt ` ; $ ( '#timeEntryList' ) . innerHTML = data . items . length ? data . items . map ( x => { const when = x . start _tid ? new Date ( x . start _tid ) . toLocaleString ( 'da-DK' ) : ( x . worked _date ? new Date ( x . worked _date + 'T12:00' ) . toLocaleDateString ( 'da-DK' ) : 'Ukendt tidspunkt' ) ; return ` <div class="border rounded p-2"><div class="d-flex justify-content-between"><strong> ${ esc ( x . employee _name ) } </strong><span> ${ fmtMin ( x . minutes ) } </span></div><small class="text-muted"> ${ esc ( when ) } ${ x . slut _tid ? ' – ' + new Date ( x . slut _tid ) . toLocaleTimeString ( 'da-DK' , { hour : '2-digit' , minute : '2-digit' } ):''}</small> ${ x . description ? ` <div class="small mt-1"> ${ esc ( x . description ) } </div> ` : '' } </div> ` } ) . join ( '' ) : '<div class="text-muted">Ingen tid registreret endnu.</div>' } catch ( e ) { $ ( '#timeDialogTotal' ) . textContent = e . message } }
async function loadPeriod ( ) { const [ a , b ] = range ( ) , p = new URLSearchParams ( { starts _at : a . toISOString ( ) , ends _at : b . toISOString ( ) } ) ; selectedUsers ( ) . forEach ( id => p . append ( 'user_id' , id ) ) ; $ ( '#timeline' ) . innerHTML = '<div class="empty">Henter plan…</div>' ; try { state . data = await api ( '/api/v1/planner/period?' + p ) ; render ( a , b ) } catch ( e ) { $ ( '#timeline' ) . innerHTML = ` <div class="empty text-danger"> ${ esc ( e . message ) } </div> ` } }
function render ( a , b ) { const opts = { day : 'numeric' , month : 'short' } ; $ ( '#periodLabel' ) . textContent = state . view === 'month' ? a . toLocaleDateString ( 'da-DK' , { month : 'long' , year : 'numeric' } ) : ` ${ a . toLocaleDateString ( 'da-DK' , opts ) } – ${ new Date ( b - 1 ) . toLocaleDateString ( 'da-DK' , { ... opts , year : 'numeric' } )} ` ; state . view === 'month' ? renderMonth ( a ) : renderTimeline ( a , b ) }
function cap ( uid , key ) { return state . data . capacity . find ( c => c . user _id === uid && c . date === key ) || { state : 'gray' , label : 'Ingen arbejdstid' } }
function canEdit ( uid ) { return ( state . meta . editable _user _ids || [ ] ) . includes ( Number ( uid ) ) }
function integrationWarning ( ) { return ( state . data . integration _statuses || [ ] ) . some ( x => x . status === 'degraded' ) ? '<div class="alert alert-warning m-2 py-2"><i class="bi bi-cloud-slash me-1"></i>Ekstern travl tid kan mangle. Intern planlægning virker fortsat.</div>' : '' }
function blockStyle ( start , end , lane = { index : 0 , count : 1 } ) { const first = 7 * 60 , last = 18 * 60 , total = last - first , s = new Date ( start ) , e = new Date ( end ) , sm = s . getHours ( ) * 60 + s . getMinutes ( ) , em = e . getHours ( ) * 60 + e . getMinutes ( ) , top = Math . max ( 0 , ( sm - first ) / total * 660 ) , height = Math . max ( 24 , ( Math . min ( last , em ) - Math . max ( first , sm ) ) / total * 660 ) , left = lane . index * ( 100 / lane . count ) , leftPx = 26 - lane . index * ( 29 / lane . count ) , width = 100 / lane . count , widthPx = 29 / lane . count ; return ` top: ${ top } px;height: ${ height } px;left:calc( ${ left } % + ${ leftPx } px);right:auto;width:calc( ${ width } % - ${ widthPx } px) ` }
function overlapLayout ( planned , registered ) { const all = [ ... planned . map ( item => ( { key : ` p: ${ item . id } ` , item } ) ) , ... registered . map ( item => ( { key : ` r: ${ item . id } ` , item } ) ) ] . sort ( ( a , b ) => new Date ( a . item . starts _at ) - new Date ( b . item . starts _at ) ) ; const result = new Map ( ) ; let group = [ ] , groupEnd = 0 ; const flush = ( ) => { if ( ! group . length ) return ; const laneEnds = [ ] ; for ( const event of group ) { const start = + new Date ( event . item . starts _at ) ; let lane = laneEnds . findIndex ( end => end <= start ) ; if ( lane < 0 ) lane = laneEnds . length ; laneEnds [ lane ] = + new Date ( event . item . ends _at ) ; event . lane = lane } for ( const event of group ) result . set ( event . key , { index : event . lane , count : laneEnds . length } ) ; group = [ ] ; groupEnd = 0 } ; for ( const event of all ) { const start = + new Date ( event . item . starts _at ) , end = + new Date ( event . item . ends _at ) ; if ( group . length && start >= groupEnd ) flush ( ) ; group . push ( event ) ; groupEnd = Math . max ( groupEnd , end ) } flush ( ) ; return result }
function hourMarks ( ) { return Array . from ( { length : 12 } , ( _ , i ) => ` <span class="hour-mark" style="top: ${ i * 60 } px"> ${ pad ( i + 7 ) } :00</span> ` ) . join ( '' ) }
2026-10-08 08:58:02 +02:00
function registeredHtml ( x , lane ) { const s = new Date ( x . starts _at ) , e = new Date ( x . ends _at ) ; return ` <div class="registered-time ${ ! x . sag _id ? 'quick-registered' : '' } " style=" ${ blockStyle ( x . starts _at , x . ends _at , lane ) } "><div class="time"><i class="bi bi-stopwatch me-1"></i> ${ pad ( s . getHours ( ) ) } : ${ pad ( s . getMinutes ( ) ) } – ${ pad ( e . getHours ( ) ) } : ${ pad ( e . getMinutes ( ) ) } · registreret</div><strong> ${ x . sag _id ? '#' + x . sag _id + ' ' : 'Kun tid · ' } ${ esc ( x . case _title ) } </strong><div> ${ fmtMin ( x . minutes ) } brugt</div></div> ` }
2026-09-09 10:34:49 +02:00
function untimedHtml ( items ) { return items . length ? ` <div class="untimed-registers"> ${ items . map ( x => ` <div class="untimed-register" title=" ${ esc ( x . case _title ) } "><i class="bi bi-stopwatch me-1"></i>Uden tidspunkt · # ${ x . sag _id } · ${ fmtMin ( x . minutes ) } registreret</div> ` ) . join ( '' ) } </div> ` : '' }
2026-10-20 07:18:57 +02:00
function absencesOnDay ( day , userId = null ) { const start = new Date ( day ) ; start . setHours ( 0 , 0 , 0 , 0 ) ; const end = new Date ( start ) ; end . setDate ( end . getDate ( ) + 1 ) ; return ( state . data . absences || [ ] ) . filter ( x => ( userId === null || x . user _id === userId ) && new Date ( x . starts _at ) < end && new Date ( x . ends _at ) > start ) }
function absenceHtml ( item , day ) { const start = new Date ( day ) , end = new Date ( day ) ; start . setHours ( 7 , 0 , 0 , 0 ) ; end . setHours ( 18 , 0 , 0 , 0 ) ; const a = new Date ( Math . max ( start , new Date ( item . starts _at ) ) ) , b = new Date ( Math . min ( end , new Date ( item . ends _at ) ) ) ; if ( b <= a ) return '' ; const label = item . kind _label || 'Fravær' ; return ` <div class="planner-absence" style=" ${ blockStyle ( a , b ) } ${ a . getHours ( ) === 7 && a . getMinutes ( ) === 0 ? 'padding-top:32px;' : '' } " title=" ${ esc ( label ) } "><strong><i class="bi bi-calendar-minus" aria-hidden="true"></i> ${ esc ( label ) } </strong></div> ` }
function absenceOverview ( start , end ) { const rows = ( state . data . absences || [ ] ) . filter ( x => selectedUsers ( ) . includes ( x . user _id ) && new Date ( x . starts _at ) < end && new Date ( x . ends _at ) > start ) , format = d => d . toLocaleDateString ( 'da-DK' , { day : 'numeric' , month : 'short' } ) ; return ` <section class="planner-absence-overview" aria-label="Fravær i perioden"><strong><i class="bi bi-calendar-minus" aria-hidden="true"></i> Fravær i perioden</strong><div> ${ rows . length ? rows . map ( x => { const a = new Date ( Math . max ( start , new Date ( x . starts _at ) ) ) , b = new Date ( Math . min ( end , new Date ( x . ends _at ) ) - 1 ) ; return ` <button type="button" class="planner-absence-link" data-absence-user=" ${ x . user _id } " data-absence-date=" ${ dateKey ( a ) } " title="Vis medarbejderens kalender"><strong> ${ esc ( state . meta . users . find ( u => u . id === x . user _id ) ? . name || '' ) } </strong> · ${ esc ( x . kind _label || 'Fravær' ) } · ${ format ( a ) } ${ dateKey ( a ) === dateKey ( b ) ? '' : ' – ' + format ( b ) } </button> ` } ).join(''):'<span class="small text-muted">Intet fravær for de valgte medarbejdere i perioden.</span>'}</div></section> ` }
function renderTimeline ( a , b ) { const users = state . meta . users . filter ( u => selectedUsers ( ) . includes ( u . id ) ) , days = [ ] ; for ( let d = new Date ( a ) ; d < b ; d . setDate ( d . getDate ( ) + 1 ) ) days . push ( new Date ( d ) ) ; const cols = ` 150px repeat( ${ days . length } ,minmax(130px,1fr)) ` ; let html = integrationWarning ( ) + absenceOverview ( a , b ) + ` <div class="week-grid" style="grid-template-columns: ${ cols } "><div class="grid-cell grid-head employee-head">Medarbejder</div> ${ days . map ( d => ` <div class="grid-cell grid-head"> ${ d . toLocaleDateString ( 'da-DK' , { weekday : 'short' , day : 'numeric' , month : 'short' } )}</div> ` ) . join ( '' ) } ` ; for ( const u of users ) { html += ` <div class="grid-cell employee-head" data-user=" ${ u . id } "> ${ esc ( u . name ) } <div class="small text-muted mt-2">Arbejdsdag 07– 18</div></div> ` ; for ( const d of days ) { const key = dateKey ( d ) , c = cap ( u . id , key ) , items = state . data . allocations . filter ( x => x . user _id === u . id && dateKey ( new Date ( x . starts _at ) ) === key ) , standalone = ( state . data . standalone _plans || [ ] ) . filter ( x => x . user _id === u . id && dateKey ( new Date ( x . starts _at ) ) === key ) , allRegistered = ( state . data . time _entries || [ ] ) . filter ( x => x . user _id === u . id && dateKey ( new Date ( x . starts _at ) ) === key ) , registered = allRegistered . filter ( x => x . has _time ) , untimed = allRegistered . filter ( x => ! x . has _time ) , busy = state . data . external _busy . filter ( x => x . user _id === u . id && dateKey ( new Date ( x . starts _at ) ) === key ) , lanes = overlapLayout ( [ ... items , ... standalone . map ( x => ( { ... x , id : ` quick: ${ x . id } ` } ) ) ] , registered ) ; html += ` <div class="grid-cell drop-cell" data-user=" ${ u . id } " data-date=" ${ key } "> ${ hourMarks ( ) } <span class="capacity-pill"><i class="dot ${ c . state } "></i> ${ esc ( c . label ) } · ${ esc ( c . registered _label || '0 timer registreret' ) } </span> ${ absencesOnDay ( d , u . id ) . map ( x => absenceHtml ( x , d ) ) . join ( '' ) } ${ busy . map ( x => ` <div class="external-busy" style=" ${ blockStyle ( x . starts _at , x . ends _at ) } ">Ekstern travl tid</div> ` ) . join ( '' ) } ${ registered . map ( x => registeredHtml ( x , lanes . get ( ` r: ${ x . id } ` ) ) ) . join ( '' ) } ${ items . map ( x => allocationHtml ( x , lanes . get ( ` p: ${ x . id } ` ) ) ) . join ( '' ) } ${ standalone . map ( x => ` <a class="allocation quick-planned" style=" ${ blockStyle ( x . starts _at , x . ends _at , lanes . get ( ` p:quick: ${ x . id } ` ) ) } " href="/sag/new?quick_plan= ${ Number ( x . id ) } "><div class="time"> ${ new Date ( x . starts _at ) . toLocaleTimeString ( 'da-DK' , { hour : '2-digit' , minute : '2-digit' } )} · Kun tid</div><strong> ${ esc ( x . description ) } </strong><div> ${ esc ( x . customer _name ) } · Registrér →</div></a> ` ) . join ( '' ) } ${ untimedHtml ( untimed ) } </div> ` } } html += '</div>' ; $ ( '#timeline' ) . innerHTML = html ; bindCalendar ( ) }
function allocationHtml ( x , lane ) { const s = new Date ( x . starts _at ) , e = new Date ( x . ends _at ) ; return ` <div class="allocation ${ x . conflict _status !== 'none' ? 'conflict' : '' } ${ x . locked ? 'locked' : '' } " style=" ${ blockStyle ( x . starts _at , x . ends _at , lane ) } " draggable=" ${ x . locked ? 'false' : 'true' } " data-allocation=" ${ x . id } "><div class="time"> ${ x . locked ? '<i class="bi bi-lock-fill me-1"></i>' : '' } ${ pad ( s . getHours ( ) ) } : ${ pad ( s . getMinutes ( ) ) } – ${ pad ( e . getHours ( ) ) } : ${ pad ( e . getMinutes ( ) ) } </div><strong> ${ x . sag _id ? '#' + x . sag _id + ' ' : '' } ${ esc ( x . case _title ) } </strong><div> ${ esc ( x . customer _name ) } </div></div> ` }
function renderMonth ( a ) { const start = new Date ( a ) ; start . setDate ( 1 - ( ( start . getDay ( ) + 6 ) % 7 ) ) ; let html = integrationWarning ( ) + absenceOverview ( new Date ( a . getFullYear ( ) , a . getMonth ( ) , 1 ) , new Date ( a . getFullYear ( ) , a . getMonth ( ) + 1 , 1 ) ) + '<div class="month-grid">' + [ 'Man' , 'Tir' , 'Ons' , 'Tor' , 'Fre' , 'Lør' , 'Søn' ] . map ( x => ` <div class="grid-cell grid-head"> ${ x } </div> ` ) . join ( '' ) ; for ( let i = 0 ; i < 42 ; i ++ ) { const d = new Date ( start ) ; d . setDate ( d . getDate ( ) + i ) ; const key = dateKey ( d ) , caps = selectedUsers ( ) . map ( uid => cap ( uid , key ) ) , alloc = state . data . allocations . filter ( x => dateKey ( new Date ( x . starts _at ) ) === key ) , planned = caps . reduce ( ( n , c ) => n + c . allocated _minutes , 0 ) , registered = caps . reduce ( ( n , c ) => n + ( c . registered _minutes || 0 ) , 0 ) , available = caps . reduce ( ( n , c ) => n + c . available _minutes , 0 ) ; html += ` <div class="month-day ${ d . getMonth ( ) !== a . getMonth ( ) ? 'other' : '' } " data-date=" ${ key } "><span class="day-num"> ${ d . getDate ( ) } </span><div class="month-cap"> ${ caps . map ( c => ` <span title=" ${ esc ( c . label ) } "><i class="dot ${ c . state } "></i></span> ` ) . join ( '' ) } ${ fmtMin ( planned ) } af ${ fmtMin ( available ) } planlagt<br><span style="color:#067647"><i class="bi bi-stopwatch"></i> ${ fmtMin ( registered ) } registreret</span></div> ${ absencesOnDay ( d ) . filter ( x => selectedUsers ( ) . includes ( x . user _id ) ) . map ( x => ` <div class="planner-absence-month"><i class="bi bi-calendar-minus" aria-hidden="true"></i> ${ esc ( x . kind _label || 'Fravær' ) } · ${ esc ( state . meta . users . find ( u => u . id === x . user _id ) ? . name || '' ) } </div> ` ) . join ( '' ) } ${ alloc . slice ( 0 , 3 ) . map ( x => ` <div class="allocation" data-allocation=" ${ x . id } "> ${ x . sag _id ? '#' + x . sag _id + ' ' : '' } ${ esc ( x . case _title ) } </div> ` ) . join ( '' ) } ${ ( state . data . time _entries || [ ] ) . filter ( x => ! x . sag _id && dateKey ( new Date ( x . starts _at ) ) === key ) . map ( x => ` <div class="allocation quick-registered">Brugt tid · ${ esc ( x . description ) } · ${ fmtMin ( x . minutes ) } </div> ` ) . join ( '' ) } ${ ( state . data . standalone _plans || [ ] ) . filter ( x => dateKey ( new Date ( x . starts _at ) ) === key ) . map ( x => ` <a class="allocation quick-planned" href="/sag/new?quick_plan= ${ Number ( x . id ) } ">Kun tid · ${ esc ( x . description ) } </a> ` ) . join ( '' ) } </div> ` } html += '</div>' ; $ ( '#timeline' ) . innerHTML = html ; document . querySelectorAll ( '.month-day' ) . forEach ( el => el . onclick = e => { if ( e . target . closest ( '.allocation' ) ) return ; state . anchor = new Date ( el . dataset . date + 'T12:00' ) ; state . view = 'day' ; document . querySelectorAll ( '[data-view]' ) . forEach ( x => x . classList . toggle ( 'active' , x . dataset . view === 'day' ) ) ; loadPeriod ( ) } ) ; bindCalendar ( ) }
function bindCalendar ( ) { document . querySelectorAll ( '[data-absence-user]' ) . forEach ( button => button . onclick = ( ) => { const target = document . querySelector ( ` .employee-head[data-user=" ${ Number ( button . dataset . absenceUser ) } "] ` ) || document . querySelector ( ` .month-day[data-date=" ${ button . dataset . absenceDate } "] ` ) ; if ( target ) { const timeline = $ ( '#timeline' ) , top = target . getBoundingClientRect ( ) . top - timeline . getBoundingClientRect ( ) . top + timeline . scrollTop ; timeline . scrollTo ( { top : Math . max ( 0 , top - 48 ) , behavior : 'smooth' } ) ; } } ) ; document . querySelectorAll ( '.drop-cell' ) . forEach ( el => { if ( canEdit ( el . dataset . user ) ) { el . ondragover = e => e . preventDefault ( ) ; el . ondrop = e => drop ( e , el ) } } ) ; document . querySelectorAll ( '[data-allocation]' ) . forEach ( el => { const item = state . data . allocations . find ( x => x . id === Number ( el . dataset . allocation ) ) ; el . onclick = ( ) => openAllocation ( Number ( el . dataset . allocation ) ) ; el . draggable = Boolean ( item && canEdit ( item . user _id ) && ! item . locked ) ; el . ondragstart = e => { if ( item && canEdit ( item . user _id ) && ! item . locked ) e . dataTransfer . setData ( 'application/x-allocation' , el . dataset . allocation ) } } ) }
2026-09-09 10:34:49 +02:00
async function drop ( e , cell ) { e . preventDefault ( ) ; const caseId = e . dataTransfer . getData ( 'application/x-case' ) , allocId = e . dataTransfer . getData ( 'application/x-allocation' ) ; const rect = cell . getBoundingClientRect ( ) , ratio = Math . max ( 0 , Math . min ( 1 , ( e . clientY - rect . top ) / rect . height ) ) , minutes = Math . round ( ( 7 * 60 + ratio * 11 * 60 ) / 15 ) * 15 ; const start = new Date ( cell . dataset . date + 'T00:00' ) ; start . setMinutes ( minutes ) ; if ( caseId ) { const c = state . cases . find ( x => x . id === Number ( caseId ) ) , defaultMins = Math . max ( 15 , Number ( state . meta . settings . default _allocation _minutes ) || 60 ) , remainingMins = Math . max ( 15 , Number ( c ? . remaining _minutes ) || defaultMins ) , mins = Math . min ( remainingMins , defaultMins ) ; await mutate ( '/api/v1/planner/allocations' , 'POST' , { sag _id : Number ( caseId ) , user _id : Number ( cell . dataset . user ) , starts _at : start . toISOString ( ) , ends _at : new Date ( start . getTime ( ) + mins * 60000 ) . toISOString ( ) , source : 'manual' } ) } else if ( allocId ) { const x = state . data . allocations . find ( a => a . id === Number ( allocId ) ) , dur = new Date ( x . ends _at ) - new Date ( x . starts _at ) ; await mutate ( ` /api/v1/planner/allocations/ ${ x . id } ` , 'PATCH' , { user _id : Number ( cell . dataset . user ) , starts _at : start . toISOString ( ) , ends _at : new Date ( start . getTime ( ) + dur ) . toISOString ( ) , version : x . version } ) } }
2026-10-20 07:18:57 +02:00
function openAllocation ( id ) { const x = state . data . allocations . find ( a => a . id === id ) ; if ( ! x ) return ; window . BMCPlanningDates ? . reset ( $ ( '#allocationDialog' ) ) ; state . selected = x ; const editable = canEdit ( x . user _id ) ; $ ( '#saveAllocation' ) . classList . toggle ( 'd-none' , ! editable ) ; $ ( '#deleteAllocation' ) . classList . toggle ( 'd-none' , ! editable || x . locked ) ; $ ( '#copyAllocation' ) . classList . toggle ( 'd-none' , ! editable || ! x . sag _id ) ; $ ( '#dialogTitle' ) . textContent = ` ${ x . sag _id ? '#' + x . sag _id + ' · ' : '' } ${ x . case _title } ` ; $ ( '#dialogCustomer' ) . textContent = ` ${ x . customer _name } · ${ x . user _name } ` ; $ ( '#dialogStart' ) . value = localISO ( new Date ( x . starts _at ) ) ; $ ( '#dialogEnd' ) . value = localISO ( new Date ( x . ends _at ) ) ; $ ( '#dialogNote' ) . value = x . note || '' ; $ ( '#dialogLocked' ) . checked = Boolean ( x . locked ) ; $ ( '#dialogStart' ) . disabled = ! editable ; $ ( '#dialogEnd' ) . disabled = ! editable ; $ ( '#dialogNote' ) . disabled = ! editable ; $ ( '#dialogLocked' ) . disabled = ! editable ; $ ( '#dialogCaseLink' ) . href = ` /sag/ ${ x . sag _id } /v3 ` ; $ ( '#dialogCaseLink' ) . classList . toggle ( 'd-none' , ! x . sag _id ) ; $ ( '#dialogWarning' ) . classList . add ( 'd-none' ) ; window . BMCPlanningDates ? . mount ( $ ( '#allocationDialog' ) ) ; $ ( '#allocationDialog' ) . showModal ( ) }
2026-09-09 10:34:49 +02:00
async function saveSelected ( ) { const x = state . selected ; await mutate ( ` /api/v1/planner/allocations/ ${ x . id } ` , 'PATCH' , { starts _at : new Date ( $ ( '#dialogStart' ) . value ) . toISOString ( ) , ends _at : new Date ( $ ( '#dialogEnd' ) . value ) . toISOString ( ) , note : $ ( '#dialogNote' ) . value , locked : $ ( '#dialogLocked' ) . checked , version : x . version } , true ) }
async function deleteSelected ( ) { const x = state . selected ; if ( ! confirm ( 'Slet denne tidsblok? Sagen bevares.' ) ) return ; try { const result = await api ( ` /api/v1/planner/allocations/ ${ x . id } ?version= ${ x . version } ` , { method : 'DELETE' } ) ; $ ( '#allocationDialog' ) . close ( ) ; const a = result . allocation ; toast ( 'Tidsblokken blev slettet' , 'Fortryd' , ( ) => undoMutation ( a . id , a . version ) ) ; await refresh ( ) } catch ( e ) { toast ( e . message ) } }
async function copySelected ( ) { const x = state . selected , start = new Date ( x . starts _at ) ; start . setDate ( start . getDate ( ) + 1 ) ; await mutate ( ` /api/v1/planner/allocations/ ${ x . id } /copy?starts_at= ${ encodeURIComponent ( start . toISOString ( ) ) } ` , 'POST' , null , true ) }
async function mutate ( url , method , body , close = false ) { try { const result = await api ( url , { method , body : body ? JSON . stringify ( body ) : undefined } ) ; if ( close ) $ ( '#allocationDialog' ) . close ( ) ; toast ( 'Planen er gemt' , 'Fortryd' , result ? . id && result ? . version ? ( ) => undoMutation ( result . id , result . version ) : null ) ; await refresh ( ) } catch ( e ) { if ( e . status === 409 && e . body ? . detail ? . conflict _status && confirm ( ` ${ e . message } . Vil du gemme alligevel? ` ) ) { body . confirm _conflicts = true ; return mutate ( url , method , body , close ) } toast ( e . message ) ; await loadPeriod ( ) } }
async function undoMutation ( id , version ) { try { await api ( ` /api/v1/planner/allocations/ ${ id } /undo?version= ${ version } ` , { method : 'POST' } ) ; toast ( 'Ændringen blev fortrudt' ) ; await refresh ( ) } catch ( e ) { toast ( e . message ) ; await loadPeriod ( ) } }
async function refresh ( ) { await Promise . all ( [ loadCases ( ) , loadPeriod ( ) ] ) }
function toast ( message , action , fn ) { const el = document . createElement ( 'div' ) ; el . className = 'planner-toast' ; el . textContent = message ; if ( action && fn ) { const b = document . createElement ( 'button' ) ; b . textContent = action ; b . onclick = ( ) => { fn ( ) ; el . remove ( ) } ; el . append ( b ) } $ ( '#toastStack' ) . append ( el ) ; setTimeout ( ( ) => el . remove ( ) , 6000 ) }
init ( ) . catch ( e => { $ ( '#timeline' ) . innerHTML = ` <div class="empty text-danger"> ${ esc ( e . message ) } </div> ` } ) ;
} ) ( ) ;