2026-04-12 02:27:01 +02:00
( function ( ) {
let latestSections = { } ;
let latestContextActions = { global : [ ] , context : [ ] } ;
let activeKey = 'timer' ;
let overviewFilter = null ;
let ws = null ;
let pollTimer = null ;
let wsReconnectTimer = null ;
let latestNotificationCount = 0 ;
let latestNotifications = [ ] ;
2026-04-24 11:28:12 +02:00
let switchCaseModalInstance = null ;
let quickNoteDraft = '' ;
let quickNoteHintState = {
message : 'Tip: gemmer som kommentar på aktiv/åben sag.' ,
level : 'muted'
} ;
let switchCaseState = {
activeTimer : null ,
decision : 'unchanged' ,
timers : { active : [ ] , paused : [ ] } ,
recentCases : [ ] ,
unassignedCases : [ ]
} ;
let noteEditorState = {
editingId : 0 ,
title : '' ,
content : ''
} ;
let noteTargetModalInstance = null ;
let noteTargetState = {
target : 'case' ,
noteId : 0
} ;
const LOCAL _NOTES _KEY = 'bmc_bottom_bar_notes_v1' ;
let notesApiUnavailable = false ;
2026-04-12 02:27:01 +02:00
function byId ( id ) {
return document . getElementById ( id ) ;
}
function escapeHtml ( value ) {
return String ( value )
. replace ( /&/g , '&' )
. replace ( /</g , '<' )
. replace ( />/g , '>' )
. replace ( /"/g , '"' )
. replace ( /'/g , ''' ) ;
}
2026-04-24 11:28:12 +02:00
function loadLocalNotes ( ) {
try {
const raw = window . localStorage . getItem ( LOCAL _NOTES _KEY ) ;
const parsed = raw ? JSON . parse ( raw ) : [ ] ;
return Array . isArray ( parsed ) ? parsed : [ ] ;
} catch ( e ) {
return [ ] ;
}
}
function saveLocalNotes ( notes ) {
try {
window . localStorage . setItem ( LOCAL _NOTES _KEY , JSON . stringify ( Array . isArray ( notes ) ? notes : [ ] ) ) ;
} catch ( e ) {
// Ignore storage failures (private mode/quota)
}
}
function hydrateNotesFromLocalIfNeeded ( sections ) {
const target = sections || { } ;
const notes = target . notes || { list : [ ] , count : 0 } ;
const localNotes = loadLocalNotes ( ) ;
const remoteList = Array . isArray ( notes . list ) ? notes . list : [ ] ;
if ( ! notesApiUnavailable && remoteList . length > 0 ) {
return target ;
}
target . notes = {
count : localNotes . length ,
list : localNotes
. slice ( )
. sort ( function ( a , b ) {
return Number ( b . is _pinned || 0 ) - Number ( a . is _pinned || 0 )
|| String ( b . updated _at || '' ) . localeCompare ( String ( a . updated _at || '' ) ) ;
} )
} ;
return target ;
}
2026-04-12 02:27:01 +02:00
async function fetchBottomBarState ( ) {
const contextPath = encodeURIComponent ( window . location . pathname || '/' ) ;
const response = await fetch ( '/api/v1/bottom-bar/state?context=' + contextPath , {
credentials : 'same-origin' ,
headers : {
'Accept' : 'application/json'
}
} ) ;
if ( ! response . ok ) {
throw new Error ( 'Could not load bottom bar state' ) ;
}
return response . json ( ) ;
}
function applyState ( data ) {
if ( data && data . enabled ) {
latestSections = data . sections || { } ;
2026-04-24 11:28:12 +02:00
latestSections = hydrateNotesFromLocalIfNeeded ( latestSections ) ;
2026-04-12 02:27:01 +02:00
latestContextActions = ( latestSections . context _actions || { global : [ ] , context : [ ] } ) ;
latestNotificationCount = Number ( ( ( ( data || { } ) . notifications || { } ) . count ) || 0 ) ;
latestNotifications = ( ( ( data || { } ) . notifications || { } ) . items || [ ] ) ;
syncBossTabVisibility ( ) ;
updateBar ( latestSections ) ;
updateActivityZone ( ) ;
2026-04-24 11:28:12 +02:00
const focusedId = document . activeElement && document . activeElement . id ;
const keepCurrentRender = activeKey === 'notes' && ( focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput' ) ;
if ( ! keepCurrentRender ) {
renderTabPanel ( ) ;
}
2026-04-12 02:27:01 +02:00
setVisibility ( true ) ;
return ;
}
setVisibility ( false ) ;
}
function setVisibility ( enabled ) {
const shell = byId ( 'globalBottomBar' ) ;
if ( ! shell ) {
return ;
}
if ( enabled ) {
shell . hidden = false ;
window . requestAnimationFrame ( function ( ) {
shell . classList . add ( 'is-visible' ) ;
} ) ;
} else {
shell . classList . remove ( 'is-visible' ) ;
window . setTimeout ( function ( ) {
if ( ! shell . classList . contains ( 'is-visible' ) ) {
shell . hidden = true ;
}
} , 320 ) ;
}
document . body . classList . toggle ( 'bottom-bar-visible' , ! ! enabled ) ;
if ( ! enabled ) {
document . body . classList . remove ( 'bottom-bar-expanded' ) ;
}
}
function setExpanded ( expanded ) {
const shell = byId ( 'globalBottomBar' ) ;
const toggle = byId ( 'bbSheetToggle' ) ;
const panel = byId ( 'bbSheetPanel' ) ;
if ( ! shell || ! toggle || ! panel ) {
return ;
}
shell . classList . toggle ( 'is-expanded' , ! ! expanded ) ;
document . body . classList . toggle ( 'bottom-bar-expanded' , ! ! expanded ) ;
toggle . setAttribute ( 'aria-expanded' , expanded ? 'true' : 'false' ) ;
panel . setAttribute ( 'aria-hidden' , expanded ? 'false' : 'true' ) ;
}
function syncBossTabVisibility ( ) {
const bossBtn = document . querySelector ( '.bb-tab-btn[data-bb-tab="boss"]' ) ;
if ( ! bossBtn ) {
return ;
}
bossBtn . classList . remove ( 'd-none' ) ;
}
function getCounts ( sections ) {
const mail = sections . mail || { } ;
const cases = sections . cases || { } ;
const urgent = sections . urgent || { } ;
const timer = sections . timer || { } ;
const kuma = sections . kuma || { } ;
const eset = sections . eset || { } ;
const unassigned = sections . unassigned || { } ;
return {
mail : Number ( mail . unread || 0 ) ,
cases : Number ( cases . open || 0 ) ,
urgent : Number ( urgent . count || 0 ) ,
unassigned : Number ( unassigned . count || 0 ) ,
timer : Number ( timer . active _count || 0 ) ,
kuma : Number ( kuma . down || 0 ) ,
eset : Number ( eset . incidents || 0 )
} ;
}
function detailTextFor ( key , sections ) {
const counts = getCounts ( sections ) ;
const nameMap = {
mail : 'Ubesvarede mails' ,
cases : 'Åbne sager' ,
urgent : 'Hastesager' ,
unassigned : 'Sager uden ansvarlig' ,
timer : 'Aktive timere' ,
kuma : 'Kuma alerts' ,
eset : 'ESET incidents'
} ;
const val = counts [ key ] || 0 ;
return nameMap [ key ] + ': ' + val ;
}
function severityClassFor ( key , value ) {
const val = Number ( value || 0 ) ;
if ( key === 'urgent' ) {
return val > 0 ? 'sev-critical' : 'sev-ok' ;
}
if ( key === 'unassigned' ) {
if ( val >= 3 ) return 'sev-critical' ;
if ( val > 0 ) return 'sev-warn' ;
return 'sev-ok' ;
}
if ( key === 'mail' ) {
if ( val >= 10 ) return 'sev-critical' ;
if ( val > 0 ) return 'sev-warn' ;
return 'sev-ok' ;
}
return val > 0 ? 'sev-warn' : 'sev-ok' ;
}
function listFor ( key , sections ) {
const mail = sections . mail || { } ;
const cases = sections . cases || { } ;
const urgent = sections . urgent || { } ;
2026-04-24 11:28:12 +02:00
const unassigned = sections . unassigned || { } ;
2026-04-12 02:27:01 +02:00
const timer = sections . timer || { } ;
const kuma = sections . kuma || { } ;
const eset = sections . eset || { } ;
const messages = sections . messages || { } ;
const tasks = sections . tasks || { } ;
2026-04-24 11:28:12 +02:00
const notes = sections . notes || { } ;
2026-04-12 02:27:01 +02:00
const boss = sections . boss || { } ;
function esc ( str ) {
return String ( str ) . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) ;
}
2026-04-24 11:28:12 +02:00
const quickNoteValue = esc ( quickNoteDraft || '' ) ;
2026-04-12 02:27:01 +02:00
if ( key === 'overview' ) {
2026-04-24 11:28:12 +02:00
if ( overviewFilter === 'urgent' ) return urgent . list ? urgent . list . map ( u => '<div><strong class="text-danger"><i class="bi bi-exclamation-octagon"></i> Hastesag:</strong> ' + esc ( u . title ) + ' <br><button class="btn btn-sm btn-outline-danger mt-2" data-bb-open-case="' + Number ( u . id || 0 ) + '">Vis sag</button></div>' ) : [ 'Ingen hastesager.' ] ;
2026-04-12 02:27:01 +02:00
if ( overviewFilter === 'kuma' ) return kuma . list ? kuma . list . map ( k => '<div class="d-flex justify-content-between align-items-center"><span>📉 ' + esc ( k ) + '</span> <div><button class="btn btn-sm btn-outline-primary me-1">Opret Sag</button> <button class="btn btn-sm btn-outline-secondary">Ignorer</button></div></div>' ) : [ 'Alle systemer oppe.' ] ;
if ( overviewFilter === 'eset' ) return eset . list ? eset . list . map ( e => '<div class="d-flex justify-content-between align-items-center"><span>🔐 ' + esc ( e ) + '</span> <button class="btn btn-sm btn-outline-primary">Håndter</button></div>' ) : [ 'Ingen ESET incidents.' ] ;
2026-04-24 11:28:12 +02:00
if ( overviewFilter === 'cases' ) return cases . list ? cases . list . map ( c => '<div><i class="bi bi-folder2-open text-primary"></i> ' + esc ( c . title ) + ' <button class="btn btn-sm btn-outline-primary mt-2" data-bb-open-case="' + Number ( c . id || 0 ) + '">Vis sag</button></div>' ) : [ 'Ingen åbne sager.' ] ;
2026-04-12 02:27:01 +02:00
if ( overviewFilter === 'mail' ) return [ '<div>📧 <strong>' + mail . unread + '</strong> ulæste mails. <br>💬 <strong>' + mail . customer _reply _needed + '</strong> kræver kundesvar. <button class="btn btn-sm btn-outline-primary mt-2">Åbn indbakke</button></div>' ] ;
2026-04-24 11:28:12 +02:00
if ( overviewFilter === 'unassigned' ) return unassigned . list ? unassigned . list . map ( u => '<div><i class="bi bi-person-x text-warning"></i> ' + esc ( u . title || ( 'Sag #' + ( u . id || '' ) ) ) + ' <button class="btn btn-sm btn-outline-primary mt-2" data-bb-open-case="' + Number ( u . id || 0 ) + '">Åbn sag</button></div>' ) : [ 'Ingen åbne sager uden ansvarlig.' ] ;
2026-04-12 02:27:01 +02:00
let out = [ ] ;
if ( urgent . count > 0 ) out . push ( '<div><i class="bi bi-exclamation-octagon text-danger"></i> Hastesager: <strong>' + urgent . count + '</strong> aktive</div>' ) ;
if ( mail . unread > 0 ) out . push ( '<div><i class="bi bi-envelope text-primary"></i> Ubesvarede mails: <strong>' + mail . unread + '</strong></div>' ) ;
if ( cases . open > 0 ) out . push ( '<div><i class="bi bi-folder2-open text-primary"></i> Åbne sager i alt: <strong>' + cases . open + '</strong></div>' ) ;
if ( kuma . down > 0 ) out . push ( '<div><i class="bi bi-activity text-warning"></i> Uptime Kuma nedetid: <strong>' + kuma . down + '</strong> enheder</div>' ) ;
if ( eset . incidents > 0 ) out . push ( '<div><i class="bi bi-shield-lock text-danger"></i> ESET incidents: <strong>' + eset . incidents + '</strong></div>' ) ;
if ( out . length === 0 ) {
out . push ( '<div>🎉 Alt ser grønt ud! Intet kritisk lige nu.</div>' ) ;
}
// Add quick note button on overview
2026-04-24 11:28:12 +02:00
out . push ( '<div class="mt-3 pt-3 border-top"><div class="input-group"><input type="text" id="bbQuickNoteInput" class="form-control form-control-sm" placeholder="Skriv en quick note..." value="' + quickNoteValue + '"><button class="btn btn-outline-secondary btn-sm" id="bbQuickNoteSaveBtn"><i class="bi bi-pencil"></i> Gem note</button></div><div id="bbQuickNoteHint" class="small text-muted mt-2">' + esc ( quickNoteHintState . message || 'Tip: gemmer som kommentar på aktiv/åben sag.' ) + '</div></div>' ) ;
2026-04-12 02:27:01 +02:00
return out ;
}
if ( key === 'timer' ) {
if ( timer . active _count > 0 ) {
return ( timer . list || [ ] ) . map ( t => {
const elapsedText = t . elapsed _hhmmss || ( String ( t . elapsed || 0 ) + 's' ) ;
2026-04-24 11:28:12 +02:00
return '<div class="d-flex justify-content-between align-items-center"><span><i class="bi bi-stopwatch text-success"></i> ' + esc ( t . desc ) + ' (' + esc ( elapsedText ) + ')</span> <button class="btn btn-sm btn-danger" data-bb-stop-time="' + Number ( t . id || t . time _entry _id || 0 ) + '"><i class="bi bi-stop-fill"></i> Stop</button></div>' ;
2026-04-12 02:27:01 +02:00
} ) ;
}
return [ 'Ingen aktive timere lige nu.' ] ;
}
if ( key === 'messages' ) {
if ( messages . count > 0 ) {
return ( messages . list || [ ] ) . map ( m => '<div><strong class="' + ( m . from === 'System' ? 'text-primary' : 'text-accent' ) + '">' + esc ( m . from ) + ':</strong> ' + esc ( m . text ) + '</div>' ) ;
}
return [ 'Ingen nye beskeder.' ] ;
}
if ( key === 'tasks' ) {
if ( tasks . count > 0 ) {
return ( tasks . list || [ ] ) . map ( t => '<div><i class="bi bi-calendar-check text-success"></i> <strong>' + esc ( t . title ) + '</strong> <span class="badge bg-secondary ms-2">' + esc ( t . deadline ) + '</span></div>' ) ;
}
return [ 'Ingen aktuelle opgaver.' ] ;
}
2026-04-24 11:28:12 +02:00
if ( key === 'notes' ) {
const noteItems = Array . isArray ( notes . list ) ? notes . list : [ ] ;
const editorTitle = esc ( noteEditorState . title || '' ) ;
const editorContent = esc ( noteEditorState . content || '' ) ;
const editingId = Number ( noteEditorState . editingId || 0 ) ;
const out = [
'<div class="border rounded p-2 mb-3 bg-body-tertiary">' +
'<div class="small text-muted mb-2">Egne noter (vises i bundbar)</div>' +
'<input type="text" id="bbNoteTitleInput" class="form-control form-control-sm mb-2" placeholder="Titel (valgfri)" value="' + editorTitle + '">' +
'<textarea id="bbNoteContentInput" class="form-control form-control-sm" rows="4" placeholder="Skriv note...">' + editorContent + '</textarea>' +
'<div class="d-flex gap-2 mt-2">' +
'<button class="btn btn-sm btn-primary" id="bbNoteSaveBtn" data-note-edit-id="' + editingId + '"><i class="bi bi-save me-1"></i>' + ( editingId > 0 ? 'Gem ændringer' : 'Opret note' ) + '</button>' +
'<button class="btn btn-sm btn-outline-secondary" id="bbNoteClearBtn"><i class="bi bi-x-circle me-1"></i>Ryd</button>' +
'</div>' +
'</div>'
] ;
if ( ! noteItems . length ) {
out . push ( '<div class="text-muted">Ingen noter endnu.</div>' ) ;
return out ;
}
noteItems . forEach ( function ( note ) {
const noteId = Number ( note . id || 0 ) ;
const noteTitle = esc ( ( note . title || '' ) . trim ( ) || ( 'Note #' + noteId ) ) ;
const content = String ( note . content || '' ) ;
const preview = esc ( content . length > 220 ? ( content . slice ( 0 , 220 ) + '...' ) : content ) ;
const pinned = ! ! note . is _pinned ;
out . push (
'<div class="border rounded p-2 mb-2">' +
'<div class="d-flex justify-content-between align-items-start gap-2">' +
'<div><strong>' + noteTitle + '</strong>' + ( pinned ? ' <span class="badge text-bg-warning">Pinned</span>' : '' ) + '</div>' +
'<div class="btn-group btn-group-sm">' +
'<button class="btn btn-outline-secondary" data-note-edit="' + noteId + '"><i class="bi bi-pencil"></i></button>' +
'<button class="btn btn-outline-secondary" data-note-pin="' + noteId + '"><i class="bi bi-pin-angle' + ( pinned ? '-fill' : '' ) + '"></i></button>' +
'<button class="btn btn-outline-danger" data-note-delete="' + noteId + '"><i class="bi bi-trash"></i></button>' +
'</div>' +
'</div>' +
'<div class="small text-muted mt-2" style="white-space: pre-wrap;">' + preview + '</div>' +
'<div class="d-flex flex-wrap gap-2 mt-2">' +
'<button class="btn btn-sm btn-outline-primary" data-note-to-case="' + noteId + '"><i class="bi bi-chat-left-text me-1"></i>Til sag-kommentar</button>' +
'<button class="btn btn-sm btn-outline-primary" data-note-to-contact="' + noteId + '"><i class="bi bi-person-lines-fill me-1"></i>Til kontakt</button>' +
'<button class="btn btn-sm btn-outline-primary" data-note-to-customer="' + noteId + '"><i class="bi bi-building me-1"></i>Til firma</button>' +
'</div>' +
'</div>'
) ;
} ) ;
return out ;
}
2026-04-12 02:27:01 +02:00
if ( key === 'boss' ) {
const stats = boss . stats || { } ;
const workload = Array . isArray ( boss . team _workload ) ? boss . team _workload : [ ] ;
const techniciansToday = Array . isArray ( boss . technicians _today ) ? boss . technicians _today : [ ] ;
const escalations = Array . isArray ( boss . escalations ) ? boss . escalations : [ ] ;
const unassigned = Array . isArray ( boss . unassigned _cases ) ? boss . unassigned _cases : [ ] ;
const out = [
'<div class="row g-2">' +
'<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Åbne sager</div><div class="fw-bold">' + Number ( stats . open _cases || 0 ) + '</div></div></div>' +
'<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Hastesager</div><div class="fw-bold text-danger">' + Number ( stats . urgent _cases || 0 ) + '</div></div></div>' +
'<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Uden ansvarlig</div><div class="fw-bold text-warning">' + Number ( stats . unassigned || 0 ) + '</div></div></div>' +
'<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Stale >24t</div><div class="fw-bold text-danger">' + Number ( stats . stale _urgent _cases || 0 ) + '</div></div></div>' +
'</div>' ,
'<div class="d-flex gap-2 flex-wrap mt-2">' +
'<button class="btn btn-sm btn-primary" data-boss-action="auto_assign_next"><i class="bi bi-magic me-1"></i>Auto-fordel næste</button>' +
'<button class="btn btn-sm btn-outline-primary" data-boss-action="open_unassigned"><i class="bi bi-person-x me-1"></i>Fordel ufordelte</button>' +
'<button class="btn btn-sm btn-outline-danger" data-boss-action="open_escalations"><i class="bi bi-exclamation-octagon me-1"></i>Se eskaleringer</button>' +
'<button class="btn btn-sm btn-outline-secondary" data-boss-action="open_team"><i class="bi bi-people me-1"></i>Team-overblik</button>' +
'</div>'
] ;
if ( workload . length > 0 ) {
out . push ( '<div class="small text-muted mt-3 mb-1">Team-belastning</div>' ) ;
workload . slice ( 0 , 5 ) . forEach ( function ( w ) {
out . push (
'<div class="d-flex justify-content-between align-items-center border rounded p-2">' +
'<div><strong>' + esc ( w . owner _name || 'Ukendt' ) + '</strong><div class="small text-muted">Åbne: ' + Number ( w . open _cases || 0 ) + ' • Haste: ' + Number ( w . urgent _cases || 0 ) + '</div></div>' +
'<button class="btn btn-sm btn-outline-primary" data-boss-action="open_owner" data-owner-id="' + Number ( w . user _id || 0 ) + '">Åbn</button>' +
'</div>'
) ;
} ) ;
}
if ( techniciansToday . length > 0 ) {
out . push ( '<div class="small text-muted mt-3 mb-1">Teknikernes opgaver i dag</div>' ) ;
techniciansToday . slice ( 0 , 6 ) . forEach ( function ( tech ) {
const todayTasks = Array . isArray ( tech . today _tasks ) ? tech . today _tasks : [ ] ;
let tasksHtml = '<div class="small text-muted mt-1">Ingen opgaver i dag.</div>' ;
if ( todayTasks . length > 0 ) {
tasksHtml = '<div class="small mt-1">' + todayTasks . slice ( 0 , 3 ) . map ( function ( task ) {
return '<div><i class="bi bi-dot"></i> ' + esc ( task . title || ( 'Sag #' + task . id ) ) + '</div>' ;
} ) . join ( '' ) + '</div>' ;
}
out . push (
'<div class="border rounded p-2">' +
'<div class="d-flex justify-content-between align-items-center">' +
'<div><strong>' + esc ( tech . owner _name || 'Tekniker' ) + '</strong><div class="small text-muted">I dag: ' + Number ( tech . due _today _cases || 0 ) + ' • Åbne: ' + Number ( tech . open _cases || 0 ) + '</div></div>' +
'<div class="d-flex gap-1">' +
'<button class="btn btn-sm btn-outline-primary" data-boss-action="open_owner" data-owner-id="' + Number ( tech . user _id || 0 ) + '">Vis</button>' +
'<button class="btn btn-sm btn-primary" data-boss-action="assign_next_to_owner" data-owner-id="' + Number ( tech . user _id || 0 ) + '">Tildel næste</button>' +
'</div>' +
'</div>' +
tasksHtml +
'</div>'
) ;
} ) ;
}
if ( escalations . length > 0 ) {
out . push ( '<div class="small text-muted mt-3 mb-1">Eskaleringer</div>' ) ;
escalations . slice ( 0 , 4 ) . forEach ( function ( c ) {
const ageHours = Math . floor ( Number ( c . age _seconds || 0 ) / 3600 ) ;
out . push (
'<div class="d-flex justify-content-between align-items-center border rounded p-2">' +
'<div><strong>' + esc ( c . title || 'Sag' ) + '</strong><div class="small text-muted">' + esc ( c . owner _name || 'Ikke tildelt' ) + ' • ' + ageHours + 't siden opdatering</div></div>' +
'<button class="btn btn-sm btn-outline-danger" data-boss-action="open_case" data-case-id="' + Number ( c . id || 0 ) + '">Åbn</button>' +
'</div>'
) ;
} ) ;
}
if ( unassigned . length > 0 ) {
out . push ( '<div class="small text-muted mt-3 mb-1">Ufordelte sager</div>' ) ;
unassigned . slice ( 0 , 4 ) . forEach ( function ( c ) {
out . push (
'<div class="d-flex justify-content-between align-items-center border rounded p-2">' +
'<div><strong>' + esc ( c . title || 'Sag' ) + '</strong><div class="small text-muted">Prioritet: ' + esc ( c . priority || 'normal' ) + '</div></div>' +
'<button class="btn btn-sm btn-outline-warning" data-boss-action="open_case" data-case-id="' + Number ( c . id || 0 ) + '">Åbn</button>' +
'</div>'
) ;
} ) ;
}
return out ;
}
return [ 'Klik rundt i menuen for at se data.' ] ;
}
function updateBar ( sections ) {
const counts = getCounts ( sections ) ;
const keys = Object . keys ( counts ) ;
for ( let i = 0 ; i < keys . length ; i ++ ) {
const key = keys [ i ] ;
const chipText = document . querySelector ( '.bb-chip[data-bb-key="' + key + '"] .bb-chip-text' ) ;
2026-04-21 18:59:30 +02:00
const chipLabel = document . querySelector ( '.bb-chip[data-bb-key="' + key + '"] .bb-chip-label' ) ;
const chipBubble = document . querySelector ( '.bb-chip[data-bb-key="' + key + '"] .bb-chip-bubble' ) ;
2026-04-12 02:27:01 +02:00
const chip = document . querySelector ( '.bb-chip[data-bb-key="' + key + '"]' ) ;
if ( chipText && chip ) {
const val = counts [ key ] ;
const labels = {
mail : 'Ulæste mails' ,
cases : 'Sager' ,
urgent : 'Hastesager' ,
unassigned : 'Uden ansvarlig' ,
timer : 'Timere' ,
kuma : 'Kuma' ,
eset : 'ESET'
} ;
2026-04-21 18:59:30 +02:00
if ( chipLabel ) {
chipLabel . textContent = labels [ key ] ;
}
if ( chipBubble ) {
chipBubble . textContent = String ( val ) ;
}
2026-04-12 02:27:01 +02:00
chipText . textContent = labels [ key ] + ': ' + val ;
chip . classList . toggle ( 'has-items' , val > 0 ) ;
chip . classList . remove ( 'sev-ok' , 'sev-warn' , 'sev-critical' ) ;
chip . classList . add ( severityClassFor ( key , val ) ) ;
chip . setAttribute ( 'title' , detailTextFor ( key , sections ) ) ;
chip . setAttribute ( 'aria-label' , detailTextFor ( key , sections ) ) ;
}
}
}
function bindChipHoverPreview ( ) {
const chips = document . querySelectorAll ( '.bb-chip' ) ;
const detail = byId ( 'bbCountDetail' ) ;
if ( ! detail || ! chips . length ) {
return ;
}
chips . forEach ( function ( chip ) {
chip . addEventListener ( 'mouseenter' , function ( ) {
const key = chip . getAttribute ( 'data-bb-key' ) ;
if ( ! key ) return ;
detail . innerHTML = '<i class="bi bi-eye me-1 text-accent"></i> ' + detailTextFor ( key , latestSections ) ;
} ) ;
chip . addEventListener ( 'mouseleave' , function ( ) {
detail . innerHTML = '<i class="bi bi-info-circle me-1 opacity-75"></i> Klik på en kategori for at se detaljer' ;
} ) ;
} ) ;
}
function updateActivityZone ( ) {
const timerChip = byId ( 'bbActiveTimerChip' ) ;
const timerText = byId ( 'bbActiveTimerText' ) ;
const notifCount = byId ( 'bbNotificationsCount' ) ;
const timer = ( ( latestSections || { } ) . timer || { } ) . active || { } ;
const hasActiveTimer = ! ! timer . active ;
if ( timerChip && timerText ) {
timerChip . classList . toggle ( 'is-hidden' , ! hasActiveTimer ) ;
if ( hasActiveTimer ) {
const elapsed = timer . elapsed _hhmmss || '00:00:00' ;
const name = timer . sag _navn || ( 'Sag #' + ( timer . sag _id || '' ) ) ;
timerText . textContent = name + ' - ' + elapsed ;
}
}
if ( notifCount ) {
const computed = Number ( latestNotificationCount || ( ( latestSections . messages || { } ) . count || 0 ) ) ;
notifCount . textContent = String ( computed ) ;
}
}
function renderTabPanel ( ) {
const titleContainer = byId ( 'bbTabTitle' ) ;
const innerContent = byId ( 'bbTabInnerContent' ) ;
if ( ! titleContainer || ! innerContent ) {
return ;
}
const titleText = titleContainer . querySelector ( '.bb-tab-title-text' ) ;
const titleByKey = {
overview : 'Overblik' ,
timer : 'Timere' ,
messages : 'Beskeder' ,
tasks : 'Opgaver' ,
2026-04-24 11:28:12 +02:00
notes : 'Noter' ,
2026-04-12 02:27:01 +02:00
boss : 'Chef Dashboard'
} ;
const iconByKey = {
overview : 'bi-bell' ,
timer : 'bi-stopwatch' ,
messages : 'bi-chat-dots' ,
tasks : 'bi-calendar-check' ,
2026-04-24 11:28:12 +02:00
notes : 'bi-journal-text' ,
2026-04-12 02:27:01 +02:00
boss : 'bi-person-workspace'
} ;
const activeTitle = titleByKey [ activeKey ] || 'Info' ;
if ( titleText ) {
titleText . textContent = activeTitle ;
} else {
titleContainer . textContent = activeTitle ;
}
const iconSpan = titleContainer . querySelector ( '.bi' ) ;
if ( iconSpan ) {
iconSpan . className = 'bi ' + ( iconByKey [ activeKey ] || 'bi-info-circle' ) + ' me-2 text-accent' ;
}
// Render rich UI lists
const lines = listFor ( activeKey , latestSections ) ;
const ul = document . createElement ( 'ul' ) ;
ul . className = 'bb-tab-list' ;
lines . forEach ( function ( line ) {
const li = document . createElement ( 'li' ) ;
// Allow rich HTML (buttons, inputs) - assuming listFor provides sanitized data wrapped in markup
li . innerHTML = line ;
ul . appendChild ( li ) ;
} ) ;
innerContent . innerHTML = '' ;
// Add specific headers/controls based on active tab
if ( activeKey === 'tasks' ) {
const topBar = document . createElement ( 'div' ) ;
topBar . className = 'bb-task-actions mb-3' ;
topBar . innerHTML = '<button class="btn btn-primary btn-sm w-100 fw-bold shadow-sm" id="btnNextTask"><i class="bi bi-box-arrow-in-down-right"></i> Giv mig næste opgave</button>' ;
innerContent . appendChild ( topBar ) ;
}
if ( activeKey === 'messages' ) {
const chatContainer = document . createElement ( 'div' ) ;
chatContainer . className = 'd-flex flex-column h-100' ;
ul . classList . add ( 'flex-grow-1' , 'mb-3' ) ;
const replyBox = document . createElement ( 'div' ) ;
replyBox . className = 'mt-2 border-top pt-2 border-primary-subtle' ;
replyBox . innerHTML = `
< div class = "input-group input-group-sm mb-1" >
< span class = "input-group-text bg-light text-muted border-0" > < i class = "bi bi-person" > < / i > < / s p a n >
< select id = "chatRecipient" class = "form-select border-0 bg-light" >
< option value = "all" > Indlæser brugere ... < / o p t i o n >
< / s e l e c t >
< / d i v >
< div class = "input-group" >
< input type = "text" id = "chatInputQuick" class = "form-control form-control-sm" placeholder = "Skriv en besked..." >
< button class = "btn btn-outline-primary btn-sm" id = "btnSendMsg" > < i class = "bi bi-send" > < / i > < / b u t t o n >
< / d i v >
` ;
chatContainer . appendChild ( ul ) ;
chatContainer . appendChild ( replyBox ) ;
innerContent . appendChild ( chatContainer ) ;
// Fetch users dynamically
fetch ( '/api/v1/users?is_active=true' , { credentials : 'include' } )
. then ( r => r . json ( ) )
. then ( payload => {
const users = Array . isArray ( payload ) ? payload : ( ( payload && payload . data && Array . isArray ( payload . data ) ) ? payload . data : [ ] ) ;
const sel = document . getElementById ( 'chatRecipient' ) ;
if ( sel ) {
sel . innerHTML = '<option value="all">Alle på vagt</option><option value="system">System (Bot)</option>' ;
users . forEach ( u => {
sel . innerHTML += ` <option value=" ${ u . id } "> ${ u . full _name || u . username || u . email || ( 'Bruger #' + u . id ) } </option> ` ;
} ) ;
}
} )
. catch ( e => console . error ( "Error fetching users for chat:" , e ) ) ;
} else {
innerContent . appendChild ( ul ) ;
}
}
function bindSideTabs ( ) {
const buttons = document . querySelectorAll ( '.bb-tab-btn' ) ;
for ( let i = 0 ; i < buttons . length ; i ++ ) {
buttons [ i ] . addEventListener ( 'click' , function ( e ) {
// Clear filter on direct human click of the button, unless we programmatically called click()
if ( e . isTrusted ) overviewFilter = null ;
for ( let j = 0 ; j < buttons . length ; j ++ ) {
buttons [ j ] . classList . remove ( 'is-active' ) ;
buttons [ j ] . setAttribute ( 'aria-selected' , 'false' ) ;
}
this . classList . add ( 'is-active' ) ;
this . setAttribute ( 'aria-selected' , 'true' ) ;
activeKey = this . getAttribute ( 'data-bb-tab' ) ;
renderTabPanel ( ) ;
const detail = byId ( 'bbCountDetail' ) ;
if ( detail ) {
detail . innerHTML = '<i class="bi bi-info-circle me-1 opacity-75"></i> Viser: ' + ( activeKey . charAt ( 0 ) . toUpperCase ( ) + activeKey . slice ( 1 ) ) ;
}
} ) ;
}
}
function bindChipClicks ( ) {
const chips = document . querySelectorAll ( '.bb-chip' ) ;
for ( let i = 0 ; i < chips . length ; i ++ ) {
chips [ i ] . addEventListener ( 'click' , function ( ) {
const key = this . getAttribute ( 'data-bb-key' ) ;
if ( ! key ) return ;
const detail = byId ( 'bbCountDetail' ) ;
if ( detail ) {
detail . innerHTML = '<i class="bi bi-check-circle me-1 text-accent"></i> ' + detailTextFor ( key , latestSections ) ;
}
const routes = {
mail : '/emails' ,
urgent : '/sag?priority=urgent' ,
timer : '/timetracking' ,
cases : '/sag'
} ;
2026-04-24 11:28:12 +02:00
if ( key === 'unassigned' ) {
openUnassignedCasesPanel ( ) ;
return ;
}
2026-04-12 02:27:01 +02:00
const route = routes [ key ] || '/dashboard' ;
window . location . href = route ;
} ) ;
}
}
function bindSheetToggle ( ) {
const toggle = byId ( 'bbSheetToggle' ) ;
if ( ! toggle ) return ;
toggle . addEventListener ( 'click' , function ( ) {
const shell = byId ( 'globalBottomBar' ) ;
if ( ! shell ) return ;
const isExp = shell . classList . contains ( 'is-expanded' ) ;
setExpanded ( ! isExp ) ;
} ) ;
}
function stopPolling ( ) {
if ( pollTimer ) {
window . clearTimeout ( pollTimer ) ;
pollTimer = null ;
}
}
function pollOnce ( ) {
fetchBottomBarState ( ) . then ( function ( data ) {
applyState ( data ) ;
} ) . catch ( function ( err ) {
console . warn ( 'Bottom bar poll failed' , err ) ;
} ) . finally ( function ( ) {
pollTimer = window . setTimeout ( pollOnce , 15000 ) ;
} ) ;
}
function startPollingFallback ( ) {
stopPolling ( ) ;
pollOnce ( ) ;
}
function updateFromRealtimeEvent ( payload ) {
if ( ! payload || ! payload . event ) {
return ;
}
if ( payload . event === 'timer_tick' ) {
const timer = payload . data || { } ;
latestSections . timer = latestSections . timer || { } ;
latestSections . timer . active = timer ;
latestSections . timer . active _count = timer . active ? 1 : 0 ;
latestSections . timer . list = timer . active ? [ {
id : timer . time _entry _id ,
sag _id : timer . sag _id ,
desc : timer . sag _navn || ( 'Sag #' + ( timer . sag _id || '' ) ) ,
elapsed : timer . elapsed ,
elapsed _hhmmss : timer . elapsed _hhmmss
} ] : [ ] ;
}
if ( payload . event === 'status_delta' ) {
const status = payload . data || { } ;
latestSections . mail = latestSections . mail || { } ;
latestSections . cases = latestSections . cases || { } ;
latestSections . urgent = latestSections . urgent || { } ;
latestSections . unassigned = latestSections . unassigned || { } ;
latestSections . boss = latestSections . boss || { stats : { } } ;
latestSections . mail . unread = Number ( status . mails _unread || 0 ) ;
latestSections . mail . customer _reply _needed = Number ( status . mails _unread || 0 ) ;
latestSections . cases . open = Number ( status . sager _open || 0 ) ;
latestSections . urgent . count = Number ( status . sager _urgent || 0 ) ;
latestSections . unassigned . count = Number ( status . sager _unassigned || 0 ) ;
latestSections . boss . stats = latestSections . boss . stats || { } ;
latestSections . boss . stats . unassigned = Number ( status . sager _unassigned || 0 ) ;
}
if ( payload . event === 'notification_delta' ) {
const notifications = payload . data || { } ;
const items = Array . isArray ( notifications . items ) ? notifications . items : [ ] ;
latestSections . messages = latestSections . messages || { } ;
latestSections . tasks = latestSections . tasks || { } ;
latestSections . messages . count = items . length ;
latestSections . messages . list = items . slice ( 0 , 5 ) . map ( function ( item ) {
return {
from : ( item . type || 'System' ) . toString ( ) ,
text : ( item . title || item . message || 'Notifikation' ) . toString ( )
} ;
} ) ;
latestSections . tasks . count = items . length ;
latestSections . tasks . list = items . slice ( 0 , 5 ) . map ( function ( item ) {
return {
title : item . title || 'Notifikation' ,
deadline : item . severity || 'info'
} ;
} ) ;
latestNotificationCount = Number ( notifications . count || items . length || 0 ) ;
latestNotifications = items ;
}
syncBossTabVisibility ( ) ;
updateBar ( latestSections ) ;
updateActivityZone ( ) ;
2026-04-24 11:28:12 +02:00
const focusedId = document . activeElement && document . activeElement . id ;
const quickNoteFocused = activeKey === 'overview' && focusedId === 'bbQuickNoteInput' ;
const noteEditorFocused = activeKey === 'notes' && ( focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput' ) ;
if ( ! quickNoteFocused && ! noteEditorFocused ) {
renderTabPanel ( ) ;
}
2026-04-12 02:27:01 +02:00
}
function scheduleWsReconnect ( ) {
if ( wsReconnectTimer ) {
window . clearTimeout ( wsReconnectTimer ) ;
}
wsReconnectTimer = window . setTimeout ( connectRealtime , 3000 ) ;
}
function connectRealtime ( ) {
if ( ws && ( ws . readyState === WebSocket . OPEN || ws . readyState === WebSocket . CONNECTING ) ) {
return ;
}
const protocol = window . location . protocol === 'https:' ? 'wss:' : 'ws:' ;
const wsUrl = protocol + '//' + window . location . host + '/api/v1/bottom-bar/ws' ;
try {
ws = new WebSocket ( wsUrl ) ;
} catch ( err ) {
console . warn ( 'Bottom bar websocket init failed' , err ) ;
startPollingFallback ( ) ;
scheduleWsReconnect ( ) ;
return ;
}
ws . addEventListener ( 'open' , function ( ) {
stopPolling ( ) ;
} ) ;
ws . addEventListener ( 'message' , function ( event ) {
try {
const payload = JSON . parse ( event . data || '{}' ) ;
updateFromRealtimeEvent ( payload ) ;
} catch ( err ) {
console . warn ( 'Bottom bar websocket parse error' , err ) ;
}
} ) ;
ws . addEventListener ( 'close' , function ( ) {
startPollingFallback ( ) ;
scheduleWsReconnect ( ) ;
} ) ;
ws . addEventListener ( 'error' , function ( err ) {
console . warn ( 'Bottom bar websocket error' , err ) ;
startPollingFallback ( ) ;
} ) ;
}
function stopActiveTimer ( ) {
const active = ( ( latestSections || { } ) . timer || { } ) . active || { } ;
if ( ! active . time _entry _id ) {
return Promise . resolve ( ) ;
}
return fetch ( '/api/v1/timetracking/time/stop' , {
method : 'POST' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { time _id : active . time _entry _id } )
} ) . catch ( function ( err ) {
console . warn ( 'Failed stopping active timer' , err ) ;
} ) ;
}
2026-04-24 11:28:12 +02:00
function pauseActiveTimer ( ) {
return fetch ( '/api/v1/timetracking/time/pause' , {
method : 'POST' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : '{}'
} ) . then ( function ( res ) {
if ( ! res . ok ) {
throw new Error ( 'Kunne ikke pause timer' ) ;
}
return res . json ( ) . catch ( function ( ) { return { } ; } ) ;
} ) ;
}
function resumeTimer ( timeId ) {
const payload = { } ;
if ( Number ( timeId || 0 ) > 0 ) {
payload . time _id = Number ( timeId ) ;
}
return fetch ( '/api/v1/timetracking/time/resume' , {
method : 'POST' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( payload )
} ) . then ( function ( res ) {
if ( ! res . ok ) {
throw new Error ( 'Kunne ikke genoptage timer' ) ;
}
return res . json ( ) . catch ( function ( ) { return { } ; } ) ;
} ) ;
}
function normalizeSwitchableTimerPayload ( payload ) {
const out = { active : [ ] , paused : [ ] } ;
if ( ! payload || typeof payload !== 'object' ) {
return out ;
}
if ( Array . isArray ( payload . active ) || Array . isArray ( payload . paused ) ) {
out . active = Array . isArray ( payload . active ) ? payload . active : [ ] ;
out . paused = Array . isArray ( payload . paused ) ? payload . paused : [ ] ;
return out ;
}
const active = payload . active || { } ;
const paused = Array . isArray ( payload . paused ) ? payload . paused : [ ] ;
out . active = active && active . active ? [ active ] : [ ] ;
out . paused = paused ;
return out ;
}
async function fetchSwitchableTimers ( ) {
const primary = await fetch ( '/api/v1/timetracking/time/my-switchable' , {
credentials : 'include' ,
headers : { 'Accept' : 'application/json' }
} ) ;
if ( primary . ok ) {
const payload = await primary . json ( ) ;
return normalizeSwitchableTimerPayload ( payload ) ;
}
const fallback = await fetch ( '/api/v1/bottom-bar/timers/own?paused_limit=10' , {
credentials : 'include' ,
headers : { 'Accept' : 'application/json' }
} ) ;
if ( ! fallback . ok ) {
throw new Error ( 'Kunne ikke hente timeroversigt' ) ;
}
const payload = await fallback . json ( ) ;
return normalizeSwitchableTimerPayload ( payload ) ;
}
async function fetchRecentCases ( ) {
const response = await fetch ( '/api/v1/sag/recent?limit=10' , {
credentials : 'include' ,
headers : { 'Accept' : 'application/json' }
} ) ;
if ( ! response . ok ) {
throw new Error ( 'Kunne ikke hente seneste sager' ) ;
}
const payload = await response . json ( ) ;
return Array . isArray ( payload ) ? payload : [ ] ;
}
function getSwitchCaseModal ( ) {
const modalEl = byId ( 'bbSwitchCaseModal' ) ;
if ( ! modalEl || ! window . bootstrap || ! window . bootstrap . Modal ) {
return null ;
}
if ( ! switchCaseModalInstance ) {
switchCaseModalInstance = window . bootstrap . Modal . getOrCreateInstance ( modalEl ) ;
}
return switchCaseModalInstance ;
}
function switchCaseStatusMessage ( html ) {
const statusEl = byId ( 'bbSwitchCaseStatus' ) ;
if ( statusEl ) {
statusEl . innerHTML = html ;
}
}
function timerDisplayName ( timer ) {
const sagId = Number ( ( timer && timer . sag _id ) || 0 ) ;
const title = ( timer && ( timer . sag _navn || timer . title || timer . beskrivelse || timer . desc ) ) || '' ;
if ( title ) {
return escapeHtml ( title ) ;
}
return sagId > 0 ? ( 'Sag #' + sagId ) : 'Ukendt sag' ;
}
function renderSwitchCaseLists ( ) {
const timersEl = byId ( 'bbSwitchTimersList' ) ;
const recentEl = byId ( 'bbSwitchRecentCasesList' ) ;
const actionsEl = byId ( 'bbSwitchTimerActions' ) ;
if ( ! timersEl || ! recentEl ) {
return ;
}
const active = Array . isArray ( switchCaseState . timers . active ) ? switchCaseState . timers . active : [ ] ;
const paused = Array . isArray ( switchCaseState . timers . paused ) ? switchCaseState . timers . paused : [ ] ;
const recentCases = Array . isArray ( switchCaseState . recentCases ) ? switchCaseState . recentCases : [ ] ;
const unassignedCases = Array . isArray ( switchCaseState . unassignedCases ) ? switchCaseState . unassignedCases : [ ] ;
const showUnassigned = unassignedCases . length > 0 ;
if ( actionsEl ) {
actionsEl . classList . toggle ( 'd-none' , ! switchCaseState . activeTimer ) ;
}
if ( ! active . length && ! paused . length ) {
timersEl . innerHTML = '<div class="list-group-item text-muted">Ingen aktive eller pausede timere.</div>' ;
} else {
let timerItems = '' ;
active . forEach ( function ( t ) {
const timeId = Number ( ( t && ( t . id || t . time _entry _id ) ) || 0 ) ;
timerItems +=
'<div class="list-group-item d-flex justify-content-between align-items-start">' +
'<div><span class="badge text-bg-success me-2">Aktiv</span>' + timerDisplayName ( t ) + '</div>' +
'<button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + Number ( ( t && t . sag _id ) || 0 ) + '">Åbn sag</button>' +
'</div>' ;
if ( timeId > 0 ) {
timerItems +=
'<div class="list-group-item small text-muted border-top-0 pt-0">Timer ID: ' + timeId + '</div>' ;
}
} ) ;
paused . forEach ( function ( t ) {
timerItems +=
'<div class="list-group-item d-flex justify-content-between align-items-start">' +
'<div><span class="badge text-bg-warning me-2">Pauset</span>' + timerDisplayName ( t ) + '</div>' +
'<button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + Number ( ( t && t . sag _id ) || 0 ) + '">Åbn sag</button>' +
'</div>' ;
} ) ;
timersEl . innerHTML = timerItems ;
}
const sourceCases = showUnassigned ? unassignedCases : recentCases ;
const titleEl = byId ( 'bbSwitchCaseModalLabel' ) ;
if ( titleEl ) {
titleEl . innerHTML = showUnassigned
? '<i class="bi bi-person-x me-2"></i>Uden ansvarlig (åbne sager)'
: '<i class="bi bi-arrow-left-right me-2"></i>Skift sag' ;
}
if ( ! sourceCases . length ) {
recentEl . innerHTML = '<div class="list-group-item text-muted">Ingen sager at vise.</div>' ;
return ;
}
recentEl . innerHTML = sourceCases . map ( function ( row ) {
const caseId = Number ( ( row && ( row . sag _id || row . id ) ) || 0 ) ;
const title = escapeHtml ( ( row && ( row . titel || row . title ) ) || ( caseId > 0 ? ( 'Sag #' + caseId ) : 'Ukendt sag' ) ) ;
const prefix = showUnassigned ? '<span class="badge text-bg-warning me-2">Uden ansvarlig</span>' : '<span class="badge text-bg-light border me-2">Senest</span>' ;
return (
'<div class="list-group-item d-flex justify-content-between align-items-start gap-2">' +
'<div class="me-2">' + prefix + title + '</div>' +
'<div class="d-flex gap-1">' +
'<button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + caseId + '">Åbn</button>' +
'<button class="btn btn-sm btn-primary" data-bb-start-case="' + caseId + '">Start timer</button>' +
'</div>' +
'</div>'
) ;
} ) . join ( '' ) ;
}
async function loadSwitchCaseData ( options ) {
const opts = options || { } ;
switchCaseState . decision = 'unchanged' ;
switchCaseState . activeTimer = ( ( ( latestSections || { } ) . timer || { } ) . active || { } ) . active
? ( ( latestSections || { } ) . timer || { } ) . active
: null ;
switchCaseState . unassignedCases = [ ] ;
switchCaseState . recentCases = [ ] ;
switchCaseState . timers = { active : [ ] , paused : [ ] } ;
if ( opts . onlyUnassigned ) {
const unassigned = ( ( ( latestSections || { } ) . unassigned || { } ) . list || [ ] ) ;
switchCaseState . unassignedCases = Array . isArray ( unassigned ) ? unassigned . slice ( 0 , 25 ) : [ ] ;
switchCaseStatusMessage ( '<i class="bi bi-info-circle me-1"></i>Viser kun åbne sager uden ansvarlig.' ) ;
renderSwitchCaseLists ( ) ;
return ;
}
switchCaseStatusMessage ( '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Henter timer og seneste sager...' ) ;
const results = await Promise . allSettled ( [ fetchSwitchableTimers ( ) , fetchRecentCases ( ) ] ) ;
const timersResult = results [ 0 ] ;
const casesResult = results [ 1 ] ;
if ( timersResult . status === 'fulfilled' ) {
switchCaseState . timers = timersResult . value ;
}
if ( casesResult . status === 'fulfilled' ) {
switchCaseState . recentCases = casesResult . value ;
}
const failedParts = [ ] ;
if ( timersResult . status === 'rejected' ) failedParts . push ( 'timere' ) ;
if ( casesResult . status === 'rejected' ) failedParts . push ( 'seneste sager' ) ;
if ( failedParts . length ) {
switchCaseStatusMessage ( '<i class="bi bi-exclamation-triangle me-1 text-warning"></i>Kunne ikke hente ' + escapeHtml ( failedParts . join ( ' + ' ) ) + '. Viser det vi har.' ) ;
} else if ( switchCaseState . activeTimer ) {
const name = timerDisplayName ( switchCaseState . activeTimer ) ;
switchCaseStatusMessage ( '<i class="bi bi-stopwatch me-1"></i>Aktiv timer: ' + name + '. Vælg handling før du starter en ny timer.' ) ;
} else {
switchCaseStatusMessage ( '<i class="bi bi-check-circle me-1 text-success"></i>Klar til skift af sag.' ) ;
}
renderSwitchCaseLists ( ) ;
}
async function openSwitchCaseModal ( options ) {
const modal = getSwitchCaseModal ( ) ;
if ( ! modal ) {
window . location . href = '/sag' ;
return ;
}
modal . show ( ) ;
try {
await loadSwitchCaseData ( options ) ;
} catch ( err ) {
console . warn ( 'Switch case modal load failed' , err ) ;
switchCaseStatusMessage ( '<i class="bi bi-exclamation-triangle me-1 text-danger"></i>Kunne ikke hente data til skift af sag.' ) ;
renderSwitchCaseLists ( ) ;
}
}
function openUnassignedCasesPanel ( ) {
window . location . href = '/sag?unassigned=1' ;
}
async function startTimerForCase ( caseId ) {
const validCaseId = Number ( caseId || 0 ) ;
if ( validCaseId <= 0 ) {
return ;
}
const hasActiveTimer = ! ! ( switchCaseState . activeTimer && switchCaseState . activeTimer . active ) ;
if ( hasActiveTimer && switchCaseState . decision === 'unchanged' ) {
switchCaseStatusMessage ( '<i class="bi bi-info-circle me-1 text-warning"></i>Du har en aktiv timer. Vælg Pause nu, Stop nu eller Fortsæt uændret først.' ) ;
return ;
}
try {
const response = await fetch ( '/api/v1/timetracking/time/start' , {
method : 'POST' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { sag _id : validCaseId } )
} ) ;
if ( ! response . ok ) {
const payload = await response . json ( ) . catch ( function ( ) { return { } ; } ) ;
const detail = ( payload && payload . detail ) ? payload . detail : ( 'HTTP ' + response . status ) ;
throw new Error ( typeof detail === 'string' ? detail : 'Kunne ikke starte timer' ) ;
}
const modal = getSwitchCaseModal ( ) ;
if ( modal ) {
modal . hide ( ) ;
}
window . location . href = '/sag/' + validCaseId ;
} catch ( err ) {
switchCaseStatusMessage ( '<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml ( err . message || 'Kunne ikke starte timer for sag.' ) ) ;
}
}
function openCaseDetail ( caseId ) {
const validCaseId = Number ( caseId || 0 ) ;
if ( validCaseId <= 0 ) {
return ;
}
const modal = getSwitchCaseModal ( ) ;
if ( modal ) {
modal . hide ( ) ;
}
window . location . href = '/sag/' + validCaseId ;
}
function resolveQuickNoteCaseId ( ) {
const match = ( window . location . pathname || '' ) . match ( /^\/sag\/(\d+)$/ ) ;
if ( match && match [ 1 ] ) {
return Number ( match [ 1 ] ) ;
}
const active = ( ( ( latestSections || { } ) . timer || { } ) . active || { } ) ;
const activeSagId = Number ( active . sag _id || 0 ) ;
if ( activeSagId > 0 ) {
return activeSagId ;
}
const recent = ( ( ( latestSections || { } ) . recent _cases || { } ) . items || [ ] ) ;
const recentFirst = Number ( ( ( ( recent [ 0 ] || { } ) . id ) || ( ( recent [ 0 ] || { } ) . sag _id ) || 0 ) ) ;
return recentFirst > 0 ? recentFirst : 0 ;
}
function saveQuickNote ( noteText , caseId ) {
return fetch ( '/api/v1/sag/' + Number ( caseId ) + '/kommentarer' , {
method : 'POST' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { indhold : noteText } )
} ) . then ( function ( res ) {
if ( ! res . ok ) {
throw new Error ( 'Kunne ikke gemme note' ) ;
}
return res . json ( ) . catch ( function ( ) { return { } ; } ) ;
} ) ;
}
function noteById ( noteId ) {
const notes = ( ( ( latestSections || { } ) . notes || { } ) . list || [ ] ) ;
return notes . find ( function ( row ) { return Number ( ( row || { } ) . id || 0 ) === Number ( noteId || 0 ) ; } ) || null ;
}
async function readApiError ( response , fallbackMessage ) {
let payload = { } ;
try {
payload = await response . json ( ) ;
} catch ( e ) {
payload = { } ;
}
const detail = payload && payload . detail ? payload . detail : null ;
if ( typeof detail === 'string' && detail . trim ( ) ) {
return detail ;
}
return fallbackMessage ;
}
async function fetchWithNotesFallback ( url , options ) {
const response = await fetch ( url , options ) ;
if ( response . status !== 404 || ! /\/api\/v1\/bottom-bar\/notes(\/|$)/ . test ( url ) ) {
return response ;
}
const altUrl = url . endsWith ( '/' ) ? url . slice ( 0 , - 1 ) : ( url + '/' ) ;
return fetch ( altUrl , options ) ;
}
async function createUserNote ( title , content ) {
const endpoint = '/api/v1/bottom-bar/notes' ;
const res = await fetchWithNotesFallback ( endpoint , {
method : 'POST' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { title : title || '' , content : content || '' } )
} ) ;
if ( res . status === 404 ) {
notesApiUnavailable = true ;
const now = new Date ( ) . toISOString ( ) ;
const local = loadLocalNotes ( ) ;
const created = {
id : Date . now ( ) ,
title : String ( title || '' ) . trim ( ) ,
content : String ( content || '' ) . trim ( ) ,
is _pinned : false ,
is _archived : false ,
created _at : now ,
updated _at : now ,
_local _only : true
} ;
local . unshift ( created ) ;
saveLocalNotes ( local ) ;
return created ;
}
if ( ! res . ok ) {
throw new Error ( await readApiError ( res , 'Kunne ikke oprette note (' + endpoint + ')' ) ) ;
}
notesApiUnavailable = false ;
return res . json ( ) . catch ( function ( ) { return { } ; } ) ;
}
async function updateUserNote ( noteId , payload ) {
const endpoint = '/api/v1/bottom-bar/notes/' + Number ( noteId || 0 ) ;
const res = await fetchWithNotesFallback ( endpoint , {
method : 'PATCH' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( payload || { } )
} ) ;
if ( res . status === 404 ) {
notesApiUnavailable = true ;
const local = loadLocalNotes ( ) ;
const idNum = Number ( noteId || 0 ) ;
const updated = local . map ( function ( row ) {
if ( Number ( ( row || { } ) . id || 0 ) !== idNum ) return row ;
return {
... row ,
... payload ,
updated _at : new Date ( ) . toISOString ( ) ,
_local _only : true
} ;
} ) ;
saveLocalNotes ( updated ) ;
return updated . find ( function ( row ) { return Number ( ( row || { } ) . id || 0 ) === idNum ; } ) || { } ;
}
if ( ! res . ok ) {
throw new Error ( await readApiError ( res , 'Kunne ikke opdatere note (' + endpoint + ')' ) ) ;
}
notesApiUnavailable = false ;
return res . json ( ) . catch ( function ( ) { return { } ; } ) ;
}
async function deleteUserNote ( noteId ) {
const endpoint = '/api/v1/bottom-bar/notes/' + Number ( noteId || 0 ) ;
const res = await fetchWithNotesFallback ( endpoint , {
method : 'DELETE' ,
credentials : 'include'
} ) ;
if ( res . status === 404 ) {
notesApiUnavailable = true ;
const idNum = Number ( noteId || 0 ) ;
const local = loadLocalNotes ( ) . filter ( function ( row ) {
return Number ( ( row || { } ) . id || 0 ) !== idNum ;
} ) ;
saveLocalNotes ( local ) ;
return { status : 'deleted' , note _id : idNum , _local _only : true } ;
}
if ( ! res . ok ) {
throw new Error ( await readApiError ( res , 'Kunne ikke slette note (' + endpoint + ')' ) ) ;
}
notesApiUnavailable = false ;
return res . json ( ) . catch ( function ( ) { return { } ; } ) ;
}
async function noteToCaseComment ( noteId , caseId , excerpt ) {
const endpoint = '/api/v1/bottom-bar/notes/' + Number ( noteId || 0 ) + '/actions/sag-comment' ;
const res = await fetchWithNotesFallback ( endpoint , {
method : 'POST' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { sag _id : Number ( caseId || 0 ) , excerpt : excerpt || null } )
} ) ;
if ( ! res . ok ) {
throw new Error ( await readApiError ( res , 'Kunne ikke indsætte i sag-kommentar (' + endpoint + ')' ) ) ;
}
return res . json ( ) . catch ( function ( ) { return { } ; } ) ;
}
async function noteToContact ( noteId , contactId , field , value , mode ) {
const endpoint = '/api/v1/bottom-bar/notes/' + Number ( noteId || 0 ) + '/actions/contact-update' ;
const res = await fetchWithNotesFallback ( endpoint , {
method : 'POST' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( {
contact _id : Number ( contactId || 0 ) ,
field : String ( field || '' ) ,
value : value || '' ,
mode : mode || 'append'
} )
} ) ;
if ( ! res . ok ) {
throw new Error ( await readApiError ( res , 'Kunne ikke opdatere kontakt fra note (' + endpoint + ')' ) ) ;
}
return res . json ( ) . catch ( function ( ) { return { } ; } ) ;
}
async function noteToCustomer ( noteId , customerId , field , value , mode ) {
const endpoint = '/api/v1/bottom-bar/notes/' + Number ( noteId || 0 ) + '/actions/customer-update' ;
const res = await fetchWithNotesFallback ( endpoint , {
method : 'POST' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( {
customer _id : Number ( customerId || 0 ) ,
field : String ( field || '' ) ,
value : value || '' ,
mode : mode || 'append'
} )
} ) ;
if ( ! res . ok ) {
throw new Error ( await readApiError ( res , 'Kunne ikke opdatere firma fra note (' + endpoint + ')' ) ) ;
}
return res . json ( ) . catch ( function ( ) { return { } ; } ) ;
}
function getNoteTargetModal ( ) {
const modalEl = byId ( 'bbNoteTargetModal' ) ;
if ( ! modalEl || ! window . bootstrap || ! window . bootstrap . Modal ) {
return null ;
}
if ( ! noteTargetModalInstance ) {
noteTargetModalInstance = window . bootstrap . Modal . getOrCreateInstance ( modalEl ) ;
}
return noteTargetModalInstance ;
}
function updateNoteTargetStatus ( message , isError ) {
const statusEl = byId ( 'bbNoteTargetStatus' ) ;
if ( ! statusEl ) {
return ;
}
statusEl . textContent = String ( message || '' ) ;
statusEl . classList . remove ( 'text-muted' , 'text-success' , 'text-danger' ) ;
if ( isError === true ) {
statusEl . classList . add ( 'text-danger' ) ;
return ;
}
if ( isError === false ) {
statusEl . classList . add ( 'text-success' ) ;
return ;
}
statusEl . classList . add ( 'text-muted' ) ;
}
function renderNoteTargetFieldOptions ( target ) {
const wrap = byId ( 'bbNoteTargetFieldWrap' ) ;
const label = byId ( 'bbNoteTargetFieldLabel' ) ;
const select = byId ( 'bbNoteTargetFieldSelect' ) ;
if ( ! wrap || ! label || ! select ) {
return ;
}
if ( target === 'case' ) {
wrap . classList . add ( 'd-none' ) ;
select . innerHTML = '' ;
return ;
}
const options = target === 'contact'
? [
{ value : 'mobile' , label : 'Mobile' } ,
{ value : 'phone' , label : 'Telefon' } ,
{ value : 'email' , label : 'Email' } ,
{ value : 'title' , label : 'Titel' } ,
{ value : 'department' , label : 'Afdeling' }
]
: [
{ value : 'note' , label : 'Firma note' } ,
{ value : 'mobile_phone' , label : 'Mobil' } ,
{ value : 'phone' , label : 'Telefon' } ,
{ value : 'email' , label : 'Email' } ,
{ value : 'address' , label : 'Adresse' } ,
{ value : 'invoice_email' , label : 'Faktura-email' }
] ;
wrap . classList . remove ( 'd-none' ) ;
label . textContent = target === 'contact' ? 'Kontaktfelt' : 'Firmafelt' ;
select . innerHTML = options . map ( function ( item ) {
return '<option value="' + escapeHtml ( item . value ) + '">' + escapeHtml ( item . label ) + '</option>' ;
} ) . join ( '' ) ;
}
function openNoteTargetModal ( target , noteId ) {
const modal = getNoteTargetModal ( ) ;
if ( ! modal ) {
return ;
}
const note = noteById ( noteId ) ;
noteTargetState = {
target : String ( target || 'case' ) ,
noteId : Number ( noteId || 0 )
} ;
const titleEl = byId ( 'bbNoteTargetModalLabel' ) ;
const idLabel = byId ( 'bbNoteTargetIdLabel' ) ;
const idInput = byId ( 'bbNoteTargetIdInput' ) ;
const textInput = byId ( 'bbNoteTargetTextInput' ) ;
const submitBtn = byId ( 'bbNoteTargetSubmitBtn' ) ;
if ( titleEl ) {
const targetTitle = noteTargetState . target === 'contact'
? 'kontakt'
: ( noteTargetState . target === 'customer' ? 'firma' : 'sag-kommentar' ) ;
titleEl . innerHTML = '<i class="bi bi-journal-plus me-2"></i>Indsæt note i ' + escapeHtml ( targetTitle ) ;
}
if ( idLabel ) {
if ( noteTargetState . target === 'contact' ) {
idLabel . textContent = 'Kontakt ID' ;
} else if ( noteTargetState . target === 'customer' ) {
idLabel . textContent = 'Firma ID' ;
} else {
idLabel . textContent = 'Sag ID' ;
}
}
if ( idInput ) {
const defaultCaseId = resolveQuickNoteCaseId ( ) ;
const defaultId = noteTargetState . target === 'case' && defaultCaseId > 0 ? defaultCaseId : 0 ;
idInput . value = defaultId > 0 ? String ( defaultId ) : '' ;
}
if ( textInput ) {
textInput . value = String ( ( note && note . content ) || '' ) ;
}
if ( submitBtn ) {
submitBtn . disabled = false ;
submitBtn . dataset . target = noteTargetState . target ;
submitBtn . dataset . noteId = String ( noteTargetState . noteId ) ;
}
renderNoteTargetFieldOptions ( noteTargetState . target ) ;
updateNoteTargetStatus ( 'Vælg mål og indsæt tekst.' , null ) ;
modal . show ( ) ;
}
function updateQuickNoteHint ( message , isError ) {
const hint = byId ( 'bbQuickNoteHint' ) ;
quickNoteHintState . message = message ;
quickNoteHintState . level = isError ? 'danger' : 'success' ;
if ( ! hint ) {
return ;
}
hint . textContent = message ;
hint . classList . remove ( 'text-muted' ) ;
hint . classList . toggle ( 'text-danger' , ! ! isError ) ;
hint . classList . toggle ( 'text-success' , ! isError ) ;
}
2026-04-12 02:27:01 +02:00
function bindHeaderActions ( ) {
2026-04-24 23:12:51 +02:00
const backBtn = byId ( 'bbBackBtn' ) ;
2026-04-12 02:27:01 +02:00
const searchBtn = byId ( 'bbSearchBtn' ) ;
const notificationsBtn = byId ( 'bbNotificationsBtn' ) ;
const pauseBtn = byId ( 'bbTimerPauseBtn' ) ;
const stopBtn = byId ( 'bbTimerStopBtn' ) ;
const switchBtn = byId ( 'bbTimerSwitchBtn' ) ;
const timerChip = byId ( 'bbActiveTimerChip' ) ;
2026-04-24 23:12:51 +02:00
if ( backBtn ) {
backBtn . addEventListener ( 'click' , function ( ) {
if ( window . history . length > 1 ) {
window . history . back ( ) ;
} else {
window . location . href = '/sag' ;
}
} ) ;
}
2026-04-12 02:27:01 +02:00
if ( searchBtn ) {
searchBtn . addEventListener ( 'click' , function ( ) {
const trigger = byId ( 'globalSearchBtn' ) ;
if ( trigger ) {
trigger . click ( ) ;
}
} ) ;
}
if ( notificationsBtn ) {
notificationsBtn . addEventListener ( 'click' , function ( ) {
if ( latestNotifications . length > 0 ) {
const first = latestNotifications [ 0 ] || { } ;
if ( first . action ) {
window . location . href = first . action ;
return ;
}
}
const trigger = byId ( 'globalRemindersBtn' ) ;
if ( trigger ) {
trigger . click ( ) ;
}
} ) ;
}
if ( pauseBtn ) {
pauseBtn . addEventListener ( 'click' , function ( ) {
2026-04-24 11:28:12 +02:00
const activeTimer = ( ( ( latestSections || { } ) . timer || { } ) . active || { } ) ;
const own = ( ( ( latestSections || { } ) . timer || { } ) . own || { } ) ;
const paused = Array . isArray ( own . paused ) ? own . paused : [ ] ;
if ( activeTimer . active ) {
pauseActiveTimer ( )
. then ( fetchBottomBarState )
. then ( applyState )
. catch ( function ( err ) {
console . warn ( 'Failed pausing timer' , err ) ;
} ) ;
return ;
}
const pausedTimeId = Number ( ( ( ( paused [ 0 ] || { } ) . time _entry _id ) || ( ( paused [ 0 ] || { } ) . id ) || 0 ) ) ;
resumeTimer ( pausedTimeId || null )
. then ( fetchBottomBarState )
. then ( applyState )
. catch ( function ( err ) {
console . warn ( 'Failed resuming timer' , err ) ;
} ) ;
2026-04-12 02:27:01 +02:00
} ) ;
}
if ( stopBtn ) {
stopBtn . addEventListener ( 'click' , function ( ) {
2026-04-24 11:28:12 +02:00
stopActiveTimer ( )
. then ( fetchBottomBarState )
. then ( applyState )
. catch ( function ( err ) {
console . warn ( 'Failed stopping timer' , err ) ;
} ) ;
2026-04-12 02:27:01 +02:00
} ) ;
}
if ( switchBtn ) {
switchBtn . addEventListener ( 'click' , function ( ) {
2026-04-24 11:28:12 +02:00
openSwitchCaseModal ( ) ;
2026-04-12 02:27:01 +02:00
} ) ;
}
if ( timerChip ) {
timerChip . addEventListener ( 'click' , function ( ) {
window . location . href = '/timetracking' ;
} ) ;
}
document . addEventListener ( 'click' , function ( e ) {
const actionBtn = e . target && e . target . closest ( '[data-bb-create]' ) ;
if ( ! actionBtn ) return ;
const actionKey = actionBtn . getAttribute ( 'data-bb-create' ) ;
const actions = ( latestContextActions . context || [ ] ) . concat ( latestContextActions . global || [ ] ) ;
const matched = actions . find ( function ( item ) { return item . id === actionKey ; } ) ;
if ( actionKey === 'new_case' ) {
const quickBtn = byId ( 'quickCreateBtn' ) ;
if ( quickBtn ) {
quickBtn . click ( ) ;
return ;
}
}
if ( matched && matched . action ) {
window . location . href = matched . action ;
}
} ) ;
}
function bindDynamicActions ( ) {
document . addEventListener ( 'click' , function ( e ) {
const target = e . target ;
const btn = target && target . closest ( 'button' ) ;
if ( ! btn ) return ;
2026-04-24 11:28:12 +02:00
if ( btn . id === 'bbNoteClearBtn' ) {
noteEditorState = { editingId : 0 , title : '' , content : '' } ;
renderTabPanel ( ) ;
return ;
}
if ( btn . id === 'bbNoteSaveBtn' ) {
const titleInput = byId ( 'bbNoteTitleInput' ) ;
const contentInput = byId ( 'bbNoteContentInput' ) ;
const title = titleInput ? String ( titleInput . value || '' ) . trim ( ) : '' ;
const content = contentInput ? String ( contentInput . value || '' ) . trim ( ) : '' ;
if ( ! content ) {
const detail = byId ( 'bbCountDetail' ) ;
if ( detail ) {
detail . innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-warning"></i> Noten er tom.' ;
}
return ;
}
const editId = Number ( btn . getAttribute ( 'data-note-edit-id' ) || 0 ) ;
const action = editId > 0
? updateUserNote ( editId , { title : title , content : content } )
: createUserNote ( title , content ) ;
action
. then ( fetchBottomBarState )
. then ( function ( data ) {
noteEditorState = { editingId : 0 , title : '' , content : '' } ;
applyState ( data ) ;
const detail = byId ( 'bbCountDetail' ) ;
if ( detail ) {
detail . innerHTML = '<i class="bi bi-check-circle me-1 text-success"></i> Note gemt.' ;
}
} )
. catch ( function ( err ) {
console . warn ( 'Failed saving note' , err ) ;
const detail = byId ( 'bbCountDetail' ) ;
if ( detail ) {
detail . innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-danger"></i> ' + escapeHtml ( ( err && err . message ) ? err . message : 'Kunne ikke gemme note.' ) ;
}
} ) ;
return ;
}
const noteEditId = Number ( btn . getAttribute ( 'data-note-edit' ) || 0 ) ;
if ( noteEditId > 0 ) {
const note = noteById ( noteEditId ) ;
noteEditorState = {
editingId : noteEditId ,
title : String ( ( note && note . title ) || '' ) ,
content : String ( ( note && note . content ) || '' )
} ;
renderTabPanel ( ) ;
return ;
}
const notePinId = Number ( btn . getAttribute ( 'data-note-pin' ) || 0 ) ;
if ( notePinId > 0 ) {
const note = noteById ( notePinId ) ;
const pinned = ! ! ( note && note . is _pinned ) ;
updateUserNote ( notePinId , { is _pinned : ! pinned } )
. then ( fetchBottomBarState )
. then ( applyState )
. catch ( function ( err ) {
console . warn ( 'Failed pin toggle' , err ) ;
} ) ;
return ;
}
const noteDeleteId = Number ( btn . getAttribute ( 'data-note-delete' ) || 0 ) ;
if ( noteDeleteId > 0 ) {
if ( ! window . confirm ( 'Slet note permanent fra din liste?' ) ) {
return ;
}
deleteUserNote ( noteDeleteId )
. then ( fetchBottomBarState )
. then ( function ( data ) {
if ( Number ( noteEditorState . editingId || 0 ) === noteDeleteId ) {
noteEditorState = { editingId : 0 , title : '' , content : '' } ;
}
applyState ( data ) ;
} )
. catch ( function ( err ) {
console . warn ( 'Failed deleting note' , err ) ;
} ) ;
return ;
}
const noteToCaseId = Number ( btn . getAttribute ( 'data-note-to-case' ) || 0 ) ;
if ( noteToCaseId > 0 ) {
openNoteTargetModal ( 'case' , noteToCaseId ) ;
return ;
}
const noteToContactId = Number ( btn . getAttribute ( 'data-note-to-contact' ) || 0 ) ;
if ( noteToContactId > 0 ) {
openNoteTargetModal ( 'contact' , noteToContactId ) ;
return ;
}
const noteToCustomerId = Number ( btn . getAttribute ( 'data-note-to-customer' ) || 0 ) ;
if ( noteToCustomerId > 0 ) {
openNoteTargetModal ( 'customer' , noteToCustomerId ) ;
return ;
}
if ( btn . id === 'bbNoteTargetSubmitBtn' ) {
const target = String ( btn . dataset . target || 'case' ) ;
const noteId = Number ( btn . dataset . noteId || 0 ) ;
const targetId = Number ( ( byId ( 'bbNoteTargetIdInput' ) || { } ) . value || 0 ) ;
const field = String ( ( ( byId ( 'bbNoteTargetFieldSelect' ) || { } ) . value ) || '' ) . trim ( ) ;
const text = String ( ( ( byId ( 'bbNoteTargetTextInput' ) || { } ) . value ) || '' ) . trim ( ) ;
if ( ! ( noteId > 0 ) ) {
updateNoteTargetStatus ( 'Mangler note-id.' , true ) ;
return ;
}
if ( ! ( targetId > 0 ) ) {
updateNoteTargetStatus ( 'Mål-ID skal være et tal større end 0.' , true ) ;
return ;
}
if ( ! text ) {
updateNoteTargetStatus ( 'Tekstfeltet er tomt.' , true ) ;
return ;
}
btn . disabled = true ;
updateNoteTargetStatus ( 'Gemmer...' , null ) ;
let action ;
if ( target === 'contact' ) {
action = noteToContact ( noteId , targetId , field || 'mobile' , text , 'append' ) ;
} else if ( target === 'customer' ) {
action = noteToCustomer ( noteId , targetId , field || 'note' , text , 'append' ) ;
} else {
action = noteToCaseComment ( noteId , targetId , text ) ;
}
action
. then ( function ( ) {
const detail = byId ( 'bbCountDetail' ) ;
if ( detail ) {
const targetLabel = target === 'contact' ? 'kontakt' : ( target === 'customer' ? 'firma' : 'sag' ) ;
detail . innerHTML = '<i class="bi bi-check-circle me-1 text-success"></i> Note-data gemt på ' + targetLabel + ' #' + targetId ;
}
updateNoteTargetStatus ( 'Gemt.' , false ) ;
const modal = getNoteTargetModal ( ) ;
if ( modal ) {
modal . hide ( ) ;
}
} )
. catch ( function ( err ) {
console . warn ( 'Failed note target insert' , err ) ;
updateNoteTargetStatus ( ( err && err . message ) ? err . message : 'Kunne ikke gemme note-data.' , true ) ;
} )
. finally ( function ( ) {
btn . disabled = false ;
} ) ;
return ;
}
const switchAction = btn . getAttribute ( 'data-bb-switch-action' ) ;
if ( switchAction ) {
if ( switchAction === 'continue-unchanged' ) {
switchCaseState . decision = 'unchanged' ;
switchCaseStatusMessage ( '<i class="bi bi-info-circle me-1"></i>Timer fortsætter uændret. Du kan åbne en sag uden at starte ny timer.' ) ;
return ;
}
if ( switchAction === 'pause-now' ) {
pauseActiveTimer ( ) . then ( function ( ) {
switchCaseState . decision = 'pause' ;
switchCaseState . activeTimer = null ;
switchCaseStatusMessage ( '<i class="bi bi-check-circle me-1 text-success"></i>Timer sat på pause. Du kan nu starte ny timer.' ) ;
return loadSwitchCaseData ( ) ;
} ) . catch ( function ( err ) {
switchCaseStatusMessage ( '<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml ( err . message || 'Kunne ikke pause timer.' ) ) ;
} ) ;
return ;
}
if ( switchAction === 'stop-now' ) {
stopActiveTimer ( ) . then ( function ( ) {
switchCaseState . decision = 'stop' ;
switchCaseState . activeTimer = null ;
switchCaseStatusMessage ( '<i class="bi bi-check-circle me-1 text-success"></i>Aktiv timer stoppet. Du kan nu starte ny timer.' ) ;
return loadSwitchCaseData ( ) ;
} ) . catch ( function ( ) {
switchCaseStatusMessage ( '<i class="bi bi-exclamation-triangle me-1 text-danger"></i>Kunne ikke stoppe timer.' ) ;
} ) ;
return ;
}
}
const openCaseId = Number ( btn . getAttribute ( 'data-bb-open-case' ) || 0 ) ;
if ( openCaseId > 0 ) {
openCaseDetail ( openCaseId ) ;
return ;
}
const startCaseId = Number ( btn . getAttribute ( 'data-bb-start-case' ) || 0 ) ;
if ( startCaseId > 0 ) {
startTimerForCase ( startCaseId ) ;
return ;
}
const stopTimeId = Number ( btn . getAttribute ( 'data-bb-stop-time' ) || 0 ) ;
if ( stopTimeId > 0 ) {
fetch ( '/api/v1/timetracking/time/stop' , {
method : 'POST' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { time _id : stopTimeId } )
} )
. then ( fetchBottomBarState )
. then ( applyState )
. catch ( function ( err ) {
console . warn ( 'Failed stopping timer from list' , err ) ;
} ) ;
return ;
}
if ( btn . id === 'bbQuickNoteSaveBtn' ) {
const input = byId ( 'bbQuickNoteInput' ) ;
const value = input ? String ( input . value || '' ) . trim ( ) : '' ;
quickNoteDraft = value ;
if ( ! value ) {
updateQuickNoteHint ( 'Skriv en note først.' , true ) ;
return ;
}
const caseId = resolveQuickNoteCaseId ( ) ;
if ( ! ( caseId > 0 ) ) {
updateQuickNoteHint ( 'Åbn en sag (eller start timer på en sag) før du gemmer quick note.' , true ) ;
return ;
}
const originalHtml = btn . innerHTML ;
btn . disabled = true ;
btn . innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Gemmer...' ;
saveQuickNote ( value , caseId )
. then ( function ( ) {
if ( input ) {
input . value = '' ;
}
quickNoteDraft = '' ;
updateQuickNoteHint ( 'Gemte note på sag #' + caseId + '.' , false ) ;
const detail = byId ( 'bbCountDetail' ) ;
if ( detail ) {
detail . innerHTML = '<i class="bi bi-check-circle me-1 text-success"></i> Quick note gemt på sag #' + caseId ;
}
} )
. catch ( function ( err ) {
updateQuickNoteHint ( ( err && err . message ) ? err . message : 'Kunne ikke gemme note.' , true ) ;
} )
. finally ( function ( ) {
btn . disabled = false ;
btn . innerHTML = originalHtml ;
} ) ;
return ;
}
2026-04-12 02:27:01 +02:00
const bossAction = btn . getAttribute ( 'data-boss-action' ) ;
if ( bossAction ) {
if ( bossAction === 'assign_next_to_owner' ) {
const ownerId = Number ( btn . getAttribute ( 'data-owner-id' ) || 0 ) ;
if ( ownerId <= 0 ) {
return ;
}
const originalHtml = btn . innerHTML ;
btn . disabled = true ;
btn . innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Tildeler...' ;
fetch ( '/api/v1/bottom-bar/boss/assign-next-to-user' , {
method : 'POST' ,
credentials : 'include' ,
headers : { 'Content-Type' : 'application/json' } ,
body : JSON . stringify ( { assignee _user _id : ownerId } )
} )
. then ( async r => {
const body = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! r . ok ) {
const detail = body && body . detail ? body . detail : 'Kunne ikke tildele næste sag' ;
throw new Error ( typeof detail === 'string' ? detail : 'Kunne ikke tildele næste sag' ) ;
}
return body ;
} )
. then ( data => {
const detail = byId ( 'bbCountDetail' ) ;
if ( detail ) {
if ( data . status === 'assigned' && data . case ) {
detail . innerHTML = '<i class="bi bi-check-circle me-1 text-success"></i> Tildelt: ' + escapeHtml ( data . case . title || 'Sag' ) + ' til tekniker.' ;
} else {
detail . innerHTML = '<i class="bi bi-info-circle me-1 text-accent"></i> ' + escapeHtml ( data . message || 'Ingen sager at tildele' ) ;
}
}
return fetchBottomBarState ( ) ;
} )
. then ( applyState )
. catch ( err => {
console . error ( 'Assign next to owner failed' , err ) ;
const detail = byId ( 'bbCountDetail' ) ;
if ( detail ) {
detail . innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-danger"></i> ' + escapeHtml ( err . message || 'Fejl ved tildeling' ) ;
}
} )
. finally ( ( ) => {
btn . disabled = false ;
btn . innerHTML = originalHtml ;
} ) ;
return ;
}
if ( bossAction === 'auto_assign_next' ) {
const originalHtml = btn . innerHTML ;
btn . disabled = true ;
btn . innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Fordeler...' ;
fetch ( '/api/v1/bottom-bar/boss/auto-assign-next' , {
method : 'POST' ,
credentials : 'include'
} )
. then ( async r => {
const body = await r . json ( ) . catch ( ( ) => ( { } ) ) ;
if ( ! r . ok ) {
const detail = body && body . detail ? body . detail : 'Kunne ikke auto-fordele sag' ;
throw new Error ( typeof detail === 'string' ? detail : 'Kunne ikke auto-fordele sag' ) ;
}
return body ;
} )
. then ( data => {
const detail = byId ( 'bbCountDetail' ) ;
if ( detail ) {
if ( data . status === 'assigned' && data . case && data . assignee ) {
detail . innerHTML = '<i class="bi bi-check-circle me-1 text-success"></i> Auto-fordelt: ' + escapeHtml ( data . case . title || 'Sag' ) + ' → ' + escapeHtml ( data . assignee . name || 'medarbejder' ) ;
} else {
detail . innerHTML = '<i class="bi bi-info-circle me-1 text-accent"></i> ' + escapeHtml ( data . message || 'Ingen sager at fordele' ) ;
}
}
return fetchBottomBarState ( ) ;
} )
. then ( applyState )
. catch ( err => {
console . error ( 'Boss auto-assign failed' , err ) ;
const detail = byId ( 'bbCountDetail' ) ;
if ( detail ) {
detail . innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-danger"></i> ' + escapeHtml ( err . message || 'Fejl ved auto-fordeling' ) ;
}
} )
. finally ( ( ) => {
btn . disabled = false ;
btn . innerHTML = originalHtml ;
} ) ;
return ;
}
if ( bossAction === 'open_unassigned' ) {
2026-04-24 11:28:12 +02:00
openUnassignedCasesPanel ( ) ;
2026-04-12 02:27:01 +02:00
return ;
}
if ( bossAction === 'open_escalations' ) {
window . location . href = '/sag?priority=urgent' ;
return ;
}
if ( bossAction === 'open_team' ) {
window . location . href = '/timetracking' ;
return ;
}
if ( bossAction === 'open_owner' ) {
const ownerId = Number ( btn . getAttribute ( 'data-owner-id' ) || 0 ) ;
window . location . href = ownerId > 0 ? ( '/sag?ansvarlig=' + ownerId ) : '/sag' ;
return ;
}
if ( bossAction === 'open_case' ) {
const caseId = Number ( btn . getAttribute ( 'data-case-id' ) || 0 ) ;
window . location . href = caseId > 0 ? ( '/sag/' + caseId ) : '/sag' ;
return ;
}
}
if ( btn . id === 'btnNextTask' ) {
console . log ( "-> Beder backend om næste opgave..." ) ;
btn . innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Omsætter kalender og SLA...' ;
btn . disabled = true ;
fetch ( '/api/v1/bottom-bar/next_task' , { method : 'POST' , credentials : 'include' } )
. then ( r => {
if ( ! r . ok ) {
throw new Error ( 'Kunne ikke hente næste opgave' ) ;
}
return r . json ( ) ;
} )
. then ( data => {
const task = data && data . task ? data . task : { } ;
const taskTitle = task . title || 'Ingen opgave fundet' ;
const caseId = task . case _id || '-' ;
const freeMins = data && data . free _time _calculated ? data . free _time _calculated : 0 ;
btn . innerHTML = '<i class="bi bi-magic me-2"></i>Du fik tildelt: ' + taskTitle + ' (Sag #' + caseId + ') <span class="badge bg-light text-dark ms-2">' + freeMins + 'm fri</span>' ;
btn . classList . add ( 'btn-success' ) ;
btn . classList . remove ( 'btn-primary' ) ;
} )
. catch ( err => {
console . error ( "Fejl:" , err ) ;
btn . innerHTML = "Fejl - prøv igen" ;
btn . disabled = false ;
} ) ;
}
if ( btn . id === 'btnSendMsg' ) {
const input = document . getElementById ( 'chatInputQuick' ) ;
const recipientObj = document . getElementById ( 'chatRecipient' ) ;
if ( input && input . value . trim ( ) !== '' ) {
const recipient = recipientObj ? recipientObj . options [ recipientObj . selectedIndex ] . text : 'Alle' ;
console . log ( "-> Sender besked til" , recipient , ":" , input . value ) ;
const msgVal = input . value ;
input . value = '' ;
const msgContainer = document . createElement ( 'div' ) ;
msgContainer . className = 'mb-2 text-end' ;
msgContainer . innerHTML = '<div class="small text-muted mb-1 me-1" style="font-size:0.7rem;">Til: ' + escapeHtml ( recipient ) + '</div><div class="d-inline-block bg-primary text-white p-2 rounded-3 text-start shadow-sm" style="max-width: 85%;"><strong>Mig:</strong> ' + escapeHtml ( msgVal ) + '</div>' ;
const chatContainer = document . querySelector ( '#bbTabInnerContent .d-flex.flex-column.h-100' ) ;
if ( ! chatContainer ) {
return ;
}
const listUl = chatContainer . querySelector ( 'ul.bb-tab-list' ) ;
if ( ! listUl ) {
return ;
}
listUl . appendChild ( msgContainer ) ;
// Simple hacky scroll down
const tabInner = document . getElementById ( 'bbTabInnerContent' ) ;
if ( tabInner ) {
tabInner . scrollTop = tabInner . scrollHeight + 500 ;
}
}
}
} ) ;
2026-04-24 11:28:12 +02:00
document . addEventListener ( 'keydown' , function ( e ) {
if ( e . key !== 'Enter' ) {
return ;
}
const input = byId ( 'bbQuickNoteInput' ) ;
if ( ! input || document . activeElement !== input ) {
return ;
}
e . preventDefault ( ) ;
const saveBtn = byId ( 'bbQuickNoteSaveBtn' ) ;
if ( saveBtn ) {
saveBtn . click ( ) ;
}
} ) ;
document . addEventListener ( 'input' , function ( e ) {
const target = e . target ;
if ( ! target || target . id !== 'bbQuickNoteInput' ) {
if ( target && target . id === 'bbNoteTitleInput' ) {
noteEditorState . title = String ( target . value || '' ) ;
}
if ( target && target . id === 'bbNoteContentInput' ) {
noteEditorState . content = String ( target . value || '' ) ;
}
return ;
}
quickNoteDraft = String ( target . value || '' ) ;
if ( quickNoteHintState . level !== 'muted' ) {
quickNoteHintState = {
message : 'Tip: gemmer som kommentar på aktiv/åben sag.' ,
level : 'muted'
} ;
const hint = byId ( 'bbQuickNoteHint' ) ;
if ( hint ) {
hint . textContent = quickNoteHintState . message ;
hint . classList . remove ( 'text-danger' , 'text-success' ) ;
hint . classList . add ( 'text-muted' ) ;
}
}
} ) ;
2026-04-12 02:27:01 +02:00
}
document . addEventListener ( 'DOMContentLoaded' , function ( ) {
activeKey = 'overview' ; // Default overview state
bindChipClicks ( ) ;
bindChipHoverPreview ( ) ;
bindSheetToggle ( ) ;
bindHeaderActions ( ) ;
bindDynamicActions ( ) ;
bindSideTabs ( ) ;
startPollingFallback ( ) ;
connectRealtime ( ) ;
} ) ;
} ) ( ) ;