(function () { let latestSections = {}; let latestContextActions = { global: [], context: [] }; let activeKey = 'timer'; let timerPanelState = { scope: 'mine', loading: false, loaded: false, rows: [], error: '' }; let overviewFilter = null; let ws = null; let pollTimer = null; let wsReconnectTimer = null; let latestNotificationCount = 0; let latestNotifications = []; 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: [], stopped: [] }, recentCases: [], unassignedCases: [] }; let noteEditorState = { editingId: 0, title: '', content: '' }; let noteTargetModalInstance = null; let noteTargetState = { target: 'case', noteId: 0 }; let chatComposerState = { draft: '', recipient: 'all', requiresManualAck: false, users: [], loading: false, loaded: false, error: '', replyToMessageId: 0, replyToName: '', replyToUserId: null, activeThreadKey: '' }; const LOCAL_NOTES_KEY = 'bmc_bottom_bar_notes_v1'; let notesApiUnavailable = false; let driftSummaryRefreshTimer = null; let markMessagesReadPromise = null; function byId(id) { return document.getElementById(id); } function escapeHtml(value) { return String(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function isMessagesComposerFocused() { const focusedId = document.activeElement && document.activeElement.id; return activeKey === 'messages' && (focusedId === 'chatInputQuick' || focusedId === 'chatRecipient' || focusedId === 'chatRequiresAck'); } function renderChatRecipientOptions(sel) { if (!sel) return; sel.innerHTML = ''; const baseOption = document.createElement('option'); baseOption.value = 'all'; baseOption.textContent = 'Alle på vagt'; sel.appendChild(baseOption); if (chatComposerState.loading && !chatComposerState.users.length) { const loadingOption = document.createElement('option'); loadingOption.value = ''; loadingOption.textContent = 'Indlæser brugere...'; loadingOption.disabled = true; sel.appendChild(loadingOption); } if (chatComposerState.error && !chatComposerState.users.length) { const errorOption = document.createElement('option'); errorOption.value = ''; errorOption.textContent = 'Kunne ikke hente brugere'; errorOption.disabled = true; sel.appendChild(errorOption); } if (chatComposerState.loaded && !chatComposerState.users.length && !chatComposerState.error) { const emptyOption = document.createElement('option'); emptyOption.value = ''; emptyOption.textContent = 'Ingen aktive brugere fundet'; emptyOption.disabled = true; sel.appendChild(emptyOption); } chatComposerState.users.forEach(function (u) { const option = document.createElement('option'); option.value = String(u.id); option.textContent = u.full_name || u.username || u.email || ('Bruger #' + u.id); sel.appendChild(option); }); const wantedValue = String(chatComposerState.recipient || 'all'); const hasWantedValue = Array.from(sel.options).some(function (option) { return option.value === wantedValue; }); if (!hasWantedValue && chatComposerState.replyToUserId && wantedValue === String(chatComposerState.replyToUserId)) { const replyOption = document.createElement('option'); replyOption.value = wantedValue; replyOption.textContent = chatComposerState.replyToName ? ('Svar til: ' + chatComposerState.replyToName) : ('Bruger #' + wantedValue); sel.appendChild(replyOption); } const hasWantedValueAfterReplyFallback = Array.from(sel.options).some(function (option) { return option.value === wantedValue; }); sel.value = hasWantedValueAfterReplyFallback ? wantedValue : 'all'; chatComposerState.recipient = sel.value || 'all'; } function fetchChatUsers() { if (chatComposerState.loading || chatComposerState.loaded) { return; } chatComposerState.loading = true; chatComposerState.error = ''; fetch('/api/v1/users?is_active=true', { credentials: 'include' }) .then(async function (r) { if (!r.ok) { let detail = 'Kunne ikke hente brugere'; try { const payload = await r.json(); detail = payload.detail || payload.message || detail; } catch (_) { // Ignore parse failures } throw new Error(detail); } return r.json(); }) .then(function (payload) { const users = Array.isArray(payload) ? payload : ((payload && payload.data && Array.isArray(payload.data)) ? payload.data : []); chatComposerState.users = users; chatComposerState.loaded = true; chatComposerState.error = ''; const sel = document.getElementById('chatRecipient'); if (sel) { renderChatRecipientOptions(sel); } }) .catch(function (e) { console.error('Error fetching users for chat:', e); chatComposerState.error = e && e.message ? e.message : 'Kunne ikke hente brugere'; chatComposerState.users = []; chatComposerState.loaded = false; const sel = document.getElementById('chatRecipient'); if (sel) { renderChatRecipientOptions(sel); } }) .finally(function () { chatComposerState.loading = false; }); } function updateMessagesTabBadge() { const btn = document.querySelector('.bb-tab-btn[data-bb-tab="messages"]'); if (!btn) return; let badge = btn.querySelector('.bb-tab-badge'); if (!badge) { badge = document.createElement('span'); badge.className = 'bb-tab-badge'; btn.appendChild(badge); } const unread = Number((((latestSections || {}).messages || {}).count) || 0); badge.textContent = String(unread); btn.classList.toggle('has-unread', unread > 0); badge.setAttribute('aria-hidden', unread > 0 ? 'false' : 'true'); } function getMessageThreads() { const messageItems = Array.isArray((((latestSections || {}).messages || {}).list)) ? (((latestSections || {}).messages || {}).list) : []; const threadsByKey = new Map(); messageItems.forEach(function (item) { const own = !!item.is_own; const partnerId = own ? Number(item.recipient_user_id || 0) : Number(item.sender_user_id || 0); const key = partnerId > 0 ? ('user:' + partnerId) : 'broadcast'; const label = partnerId > 0 ? String(own ? (item.to || ('Bruger #' + partnerId)) : (item.from || ('Bruger #' + partnerId))) : 'Alle på vagt'; const existing = threadsByKey.get(key) || { key: key, partnerUserId: partnerId > 0 ? partnerId : null, label: label, items: [], unread: 0, lastCreatedAt: '' }; existing.items.push(item); if (item.is_unread) { existing.unread += 1; } existing.lastCreatedAt = String(item.created_at || existing.lastCreatedAt || ''); threadsByKey.set(key, existing); }); return Array.from(threadsByKey.values()).sort(function (a, b) { return String(b.lastCreatedAt || '').localeCompare(String(a.lastCreatedAt || '')); }); } function getChatUserDisplayName(userId) { const normalizedId = Number(userId || 0); if (!(normalizedId > 0)) { return 'Alle på vagt'; } const matchedUser = (chatComposerState.users || []).find(function (user) { return Number(user.id || 0) === normalizedId; }); if (matchedUser) { return matchedUser.full_name || matchedUser.username || matchedUser.email || ('Bruger #' + normalizedId); } const messageItems = Array.isArray((((latestSections || {}).messages || {}).list)) ? (((latestSections || {}).messages || {}).list) : []; const matchedMessage = messageItems.find(function (item) { return Number(item.sender_user_id || 0) === normalizedId || Number(item.recipient_user_id || 0) === normalizedId; }); if (matchedMessage) { if (Number(matchedMessage.sender_user_id || 0) === normalizedId) { return matchedMessage.from || ('Bruger #' + normalizedId); } return matchedMessage.to || ('Bruger #' + normalizedId); } return 'Bruger #' + normalizedId; } function ensureActiveMessageThread() { const threads = getMessageThreads(); const currentKey = String(chatComposerState.activeThreadKey || '').trim(); const matched = threads.find(function (thread) { return thread.key === currentKey; }); if (matched) { return matched; } if (currentKey.indexOf('user:') === 0) { const partnerUserId = Number(currentKey.split(':')[1] || 0); if (partnerUserId > 0) { return { key: currentKey, partnerUserId: partnerUserId, label: chatComposerState.replyToName || getChatUserDisplayName(partnerUserId), items: [], unread: 0, lastCreatedAt: '' }; } } if (currentKey === 'broadcast') { return { key: 'broadcast', partnerUserId: null, label: 'Alle på vagt', items: [], unread: 0, lastCreatedAt: '' }; } if (!threads.length) { if (String(chatComposerState.recipient || 'all') !== 'all') { const partnerUserId = Number(chatComposerState.recipient || 0); if (partnerUserId > 0) { const syntheticKey = 'user:' + partnerUserId; chatComposerState.activeThreadKey = syntheticKey; return { key: syntheticKey, partnerUserId: partnerUserId, label: chatComposerState.replyToName || getChatUserDisplayName(partnerUserId), items: [], unread: 0, lastCreatedAt: '' }; } } chatComposerState.activeThreadKey = ''; return null; } chatComposerState.activeThreadKey = threads[0].key; return threads[0]; } function getRenderableMessageThreads(activeThread) { const seen = new Set(); const out = []; if (activeThread && activeThread.key) { out.push(activeThread); seen.add(activeThread.key); } getMessageThreads().forEach(function (thread) { if (seen.has(thread.key)) return; seen.add(thread.key); out.push(thread); }); return out; } function syncChatRecipientToActiveThread(activeThread) { if (chatComposerState.replyToUserId) { chatComposerState.recipient = String(chatComposerState.replyToUserId); return; } if (!activeThread) { if (!chatComposerState.recipient) { chatComposerState.recipient = 'all'; } return; } chatComposerState.recipient = activeThread.partnerUserId ? String(activeThread.partnerUserId) : 'all'; } function markActiveMessageThreadRead(activeThread) { if (!activeThread || markMessagesReadPromise) { return; } const unreadCount = Number((((latestSections || {}).messages || {}).count) || 0); if (unreadCount <= 0) { return; } const hasUnreadInThread = (((latestSections || {}).messages || {}).list || []).some(function (item) { if (item.is_own || !item.is_unread || item.requires_manual_ack) return false; if (activeThread.partnerUserId) { return Number(item.sender_user_id || 0) === Number(activeThread.partnerUserId); } return item.recipient_user_id == null; }); if (!hasUnreadInThread) { return; } const payload = activeThread.partnerUserId ? { partner_user_id: Number(activeThread.partnerUserId) } : {}; markMessagesReadPromise = fetch('/api/v1/bottom-bar/messages/read', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify(payload) }) .then(function (response) { if (!response.ok) { throw new Error('Kunne ikke markere beskeder som læst'); } return response.json().catch(function () { return {}; }); }) .then(function () { const messages = ((latestSections || {}).messages || {}); const list = Array.isArray(messages.list) ? messages.list : []; messages.list = list.map(function (item) { const belongsToThread = activeThread.partnerUserId ? Number(item.sender_user_id || 0) === Number(activeThread.partnerUserId) : item.recipient_user_id == null; if (!item.is_own && belongsToThread && !item.requires_manual_ack) { return Object.assign({}, item, { is_unread: false }); } return item; }); messages.count = messages.list.filter(function (item) { return !item.is_own && item.is_unread; }).length; latestSections.messages = messages; updateMessagesTabBadge(); updateActivityZone(); }) .catch(function (err) { console.warn('Failed marking messages read', err); }) .finally(function () { markMessagesReadPromise = null; }); } function clearChatReplyState() { chatComposerState.replyToMessageId = 0; chatComposerState.replyToName = ''; chatComposerState.replyToUserId = null; } function setChatReplyState(message) { if (!message) return; const replyUserId = message.is_own ? Number(message.recipient_user_id || 0) : Number(message.sender_user_id || 0); if (!replyUserId) return; chatComposerState.replyToMessageId = Number(message.id || 0); chatComposerState.replyToName = String(message.is_own ? (message.to || 'Bruger') : (message.from || 'Bruger')); chatComposerState.replyToUserId = replyUserId; chatComposerState.recipient = String(replyUserId); chatComposerState.activeThreadKey = 'user:' + replyUserId; } function acknowledgeMessage(messageId) { const normalizedId = Number(messageId || 0); if (!(normalizedId > 0)) { return Promise.reject(new Error('Ugyldigt besked-id')); } return fetch('/api/v1/bottom-bar/messages/acknowledge', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ message_id: normalizedId }) }) .then(async function (response) { if (!response.ok) { let detail = 'Kunne ikke bekræfte besked'; try { const payload = await response.json(); detail = payload.detail || payload.message || detail; } catch (_) { // Ignore parse failure } throw new Error(detail); } return response.json().catch(function () { return {}; }); }) .then(function () { const messages = ((latestSections || {}).messages || {}); const list = Array.isArray(messages.list) ? messages.list : []; messages.list = list.map(function (item) { if (Number(item.id || 0) === normalizedId) { return Object.assign({}, item, { is_unread: false, is_acknowledged: true }); } return item; }); messages.count = messages.list.filter(function (item) { return !item.is_own && item.is_unread; }).length; latestSections.messages = messages; updateMessagesTabBadge(); updateActivityZone(); }); } 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; } 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) { const error = new Error('Could not load bottom bar state'); error.status = response.status; throw error; } return response.json(); } function disableBottomBarAuthRetry(reason) { stopPolling(); if (wsReconnectTimer) { window.clearTimeout(wsReconnectTimer); wsReconnectTimer = null; } if (ws && ws.readyState === WebSocket.OPEN) { try { ws.close(1000, 'auth-failed'); } catch (_) { // Ignore close failures } } setVisibility(false); console.info('Bottom bar disabled due to auth state:', reason || 'unauthorized'); } function applyState(data) { if (data && data.enabled) { const onDriftPage = (window.location.pathname || '').toLowerCase().indexOf('/drift') === 0; const driftBubble = document.querySelector('.bb-chip[data-bb-key="drift"] .bb-chip-bubble'); const previousDriftDown = Number( (driftBubble && driftBubble.textContent ? Number(driftBubble.textContent) : 0) || (((latestSections || {}).drift || {}).down) || (((latestSections || {}).kuma || {}).down) || 0 ); latestSections = data.sections || {}; if ((!latestSections.drift || typeof latestSections.drift.down === 'undefined') && latestSections.kuma) { latestSections.drift = latestSections.kuma; } if (onDriftPage) { // Drift page owns drift count via its filtered event list. latestSections.drift = latestSections.drift || {}; latestSections.kuma = latestSections.kuma || {}; latestSections.drift.down = previousDriftDown; latestSections.kuma.down = previousDriftDown; } latestSections = hydrateNotesFromLocalIfNeeded(latestSections); latestContextActions = (latestSections.context_actions || { global: [], context: [] }); latestNotificationCount = Number((((data || {}).notifications || {}).count) || 0); latestNotifications = (((data || {}).notifications || {}).items || []); syncBossTabVisibility(); updateBar(latestSections); updateMessagesTabBadge(); if (!onDriftPage) { refreshDriftFromSummary(); } updateActivityZone(); const focusedId = document.activeElement && document.activeElement.id; const keepCurrentRender = (activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput')) || isMessagesComposerFocused(); if (!keepCurrentRender) { renderTabPanel(); } setVisibility(true); return; } setVisibility(false); } async function refreshDriftFromSummary() { if (driftSummaryRefreshTimer) { return; } driftSummaryRefreshTimer = window.setTimeout(async function () { driftSummaryRefreshTimer = null; try { const parseBlacklistValue = function (rawValue) { const raw = String(rawValue || '').trim(); if (!raw) return []; let values = []; try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) { values = parsed; } else if (typeof parsed === 'string') { values = [parsed]; } } catch (_) { values = raw.replace(/;/g, '\n').replace(/,/g, '\n').split('\n'); } const out = []; const seen = new Set(); values.forEach(function (item) { const norm = String(item || '').trim().toLowerCase(); if (!norm || seen.has(norm)) return; seen.add(norm); out.push(norm); }); return out; }; const isEventBlacklisted = function (event, blacklist) { if (!Array.isArray(blacklist) || !blacklist.length) return false; const tokens = new Set(); const add = function (value) { const norm = String(value || '').trim().toLowerCase(); if (norm) tokens.add(norm); }; const sourceEventId = String((event || {}).source_event_id || '').trim().toLowerCase(); add(sourceEventId); if (sourceEventId.indexOf('uisp-') === 0) add(sourceEventId.slice(5)); if (sourceEventId.indexOf('kuma-') === 0) add(sourceEventId.slice(5)); add((event || {}).device); const rawJson = (event && typeof event.raw_json === 'object' && event.raw_json) ? event.raw_json : {}; const rawItem = (rawJson && typeof rawJson.raw_item === 'object' && rawJson.raw_item) ? rawJson.raw_item : {}; const identification = (rawItem && typeof rawItem.identification === 'object' && rawItem.identification) ? rawItem.identification : {}; add(identification.id); add(identification.name); add(identification.hostname); add(identification.mac); return blacklist.some(function (item) { return tokens.has(String(item || '').trim().toLowerCase()); }); }; // Primary source: active drift events filtered client-side by blacklist. const [eventsRes, blacklistRes] = await Promise.all([ fetch('/api/v1/drift/events?status=active&limit=200', { credentials: 'include', headers: { 'Accept': 'application/json' } }), fetch('/api/v1/settings/drift_device_blacklist', { credentials: 'include', headers: { 'Accept': 'application/json' } }) ]); if (eventsRes.ok) { const eventsPayload = await eventsRes.json(); const events = Array.isArray(eventsPayload) ? eventsPayload : []; let blacklist = []; if (blacklistRes.ok) { const settingPayload = await blacklistRes.json(); blacklist = parseBlacklistValue(settingPayload && settingPayload.value ? settingPayload.value : '[]'); } const activeFiltered = events.filter(function (event) { return !isEventBlacklisted(event, blacklist); }).length; latestSections.drift = latestSections.drift || {}; latestSections.kuma = latestSections.kuma || {}; latestSections.drift.down = activeFiltered; latestSections.kuma.down = activeFiltered; updateBar(latestSections); updateActivityZone(); return; } // Keep drift chip aligned with bottom-bar backend status source first. const statusRes = await fetch('/api/v1/dashboard/status', { credentials: 'include', headers: { 'Accept': 'application/json' } }); if (statusRes.ok) { const status = await statusRes.json(); const activeFromStatus = Number(status.drift_active || 0); latestSections.drift = latestSections.drift || {}; latestSections.kuma = latestSections.kuma || {}; latestSections.drift.down = activeFromStatus; latestSections.kuma.down = activeFromStatus; updateBar(latestSections); updateActivityZone(); return; } const res = await fetch('/api/v1/drift/summary', { credentials: 'include', headers: { 'Accept': 'application/json' } }); if (!res.ok) { return; } const summary = await res.json(); const active = Number(summary.active_alerts || 0); latestSections.drift = latestSections.drift || {}; latestSections.kuma = latestSections.kuma || {}; latestSections.drift.down = active; latestSections.kuma.down = active; updateBar(latestSections); updateActivityZone(); } catch (_) { // Ignore drift summary fallback errors to avoid noisy UI logs. } }, 250); } 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'); } } window.addEventListener('bb:setDriftCount', function (event) { const detail = (event && event.detail) || {}; const count = Math.max(0, Number(detail.count || 0)); latestSections = latestSections || {}; latestSections.drift = latestSections.drift || {}; latestSections.kuma = latestSections.kuma || {}; latestSections.drift.down = count; latestSections.kuma.down = count; }); 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 drift = sections.drift || 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), drift: Number(drift.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', drift: 'Drift alerts', eset: 'ESET incidents' }; const val = counts[key] || 0; return nameMap[key] + ': ' + val; } function severityClassFor(key, value) { const val = Number(value || 0); if (key === 'drift') { return val > 0 ? 'sev-critical' : 'sev-ok'; } 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 || {}; const unassigned = sections.unassigned || {}; const timer = sections.timer || {}; const drift = sections.drift || sections.kuma || {}; const eset = sections.eset || {}; const messages = sections.messages || {}; const tasks = sections.tasks || {}; const notes = sections.notes || {}; const boss = sections.boss || {}; function esc(str) { return String(str).replace(/&/g, '&').replace(//g, '>'); } const quickNoteValue = esc(quickNoteDraft || ''); if (key === 'overview') { if (overviewFilter === 'urgent') return urgent.list ? urgent.list.map(u => '
Hastesag: ' + esc(u.title) + '
') : ['Ingen hastesager.']; if (overviewFilter === 'drift') return drift.list ? drift.list.map(k => '
📉 ' + esc(k) + ' Håndter i Drift
') : ['Alle systemer oppe.']; if (overviewFilter === 'eset') return eset.list ? eset.list.map(e => '
🔐 ' + esc(e) + ' Håndter
') : ['Ingen ESET incidents.']; if (overviewFilter === 'cases') return cases.list ? cases.list.map(c => '
' + esc(c.title) + '
') : ['Ingen åbne sager.']; if (overviewFilter === 'mail') return ['
📧 ' + mail.unread + ' ulæste mails.
💬 ' + mail.customer_reply_needed + ' kræver kundesvar. Åbn indbakke
']; if (overviewFilter === 'unassigned') return unassigned.list ? unassigned.list.map(u => '
' + esc(u.title || ('Sag #' + (u.id || ''))) + '
') : ['Ingen åbne sager uden ansvarlig.']; let out = []; if (urgent.count > 0) out.push('
Hastesager: ' + urgent.count + ' aktive
'); if (mail.unread > 0) out.push('
Ubesvarede mails: ' + mail.unread + '
'); if (cases.open > 0) out.push('
Åbne sager i alt: ' + cases.open + '
'); if (drift.down > 0) out.push('
Drift nedetid: ' + drift.down + ' enheder
'); if (eset.incidents > 0) out.push('
ESET incidents: ' + eset.incidents + '
'); if (out.length === 0) { out.push('
🎉 Alt ser grønt ud! Intet kritisk lige nu.
'); } const contextActions = (latestContextActions.context || []); const globalActions = (latestContextActions.global || []); const smartActions = contextActions.length ? contextActions : globalActions; if (smartActions.length) { out.push('
Smarte handlinger
' + smartActions.map(function (action) { return ''; }).join('') + '
'); } // Add quick note button on overview out.push('
' + esc(quickNoteHintState.message || 'Tip: gemmer som kommentar på aktiv/åben sag.') + '
'); 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'); return ''; }); } return ['
Ingen aktiv timer
Brug “Skift sag” i bundlinjen for at starte arbejdet.
']; } if (key === 'messages') { const activeThread = ensureActiveMessageThread(); syncChatRecipientToActiveThread(activeThread); const messageItems = activeThread && Array.isArray(activeThread.items) ? activeThread.items : []; if (messageItems.length > 0) { return messageItems.map(function (m) { const own = !!m.is_own; const unreadBadge = m.is_unread ? ' Ny' : ''; const importantBadge = m.requires_manual_ack ? ' Vigtig' : ''; const ackBadge = own && m.requires_manual_ack ? (m.is_acknowledged ? ' Bekræftet læst' : ' Afventer læst-bekræftelse') : ''; const targetMeta = own && m.to ? '
Til: ' + esc(m.to) + '
' : ''; const replyTargetId = own ? Number(m.recipient_user_id || 0) : Number(m.sender_user_id || 0); const replyBtn = replyTargetId > 0 ? '
' : ''; const ackBtn = (!own && m.requires_manual_ack && m.is_unread) ? '
' : ''; return '' + '
' + targetMeta + '
' + '' + esc(m.from) + ': ' + esc(m.text) + unreadBadge + importantBadge + ackBadge + replyBtn + ackBtn + '
'; }); } return ['Ingen beskeder i denne tråd endnu.']; } if (key === 'tasks') { if (tasks.count > 0) { return (tasks.list || []).map(t => '
' + esc(t.title) + 'Prioritet: ' + esc(t.deadline) + '
Aktuel'); } return ['
Du er ajour
Der er ingen aktuelle opgaver eller påmindelser.
']; } 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 = [ '
' + '
Egne noter (vises i bundbar)
' + '' + '' + '
' + '' + '' + '
' + '
' ]; if (!noteItems.length) { out.push('
Ingen noter endnu.
'); 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( '
' + '
' + '
' + noteTitle + '' + (pinned ? ' Pinned' : '') + '
' + '
' + '' + '' + '' + '
' + '
' + '
' + preview + '
' + '
' + '' + '' + '' + '
' + '
' ); }); return out; } 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 technicianOptions = techniciansToday.map(function (tech) { return ''; }).join(''); const out = [ '
' + '
Support-ledelse
Fordel køen, før kunderne venter
' + '' + '
', '
' + '
Åbne sager
' + Number(stats.open_cases || 0) + '
' + '
Hastesager
' + Number(stats.urgent_cases || 0) + '
' + '
Support uden ejer
' + Number(stats.support_unassigned || 0) + '
' + '
Stale >24t
' + Number(stats.stale_urgent_cases || 0) + '
' + '
', '
' + '' + '' + '' + '
' ]; if (workload.length > 0) { out.push('
Team-belastning
'); workload.slice(0, 5).forEach(function (w) { out.push( '
' + '
' + esc(w.owner_name || 'Ukendt') + '
Åbne: ' + Number(w.open_cases || 0) + ' • Haste: ' + Number(w.urgent_cases || 0) + '
' + '' + '
' ); }); } if (techniciansToday.length > 0) { out.push('
Teknikernes opgaver i dag
'); techniciansToday.slice(0, 6).forEach(function (tech) { const todayTasks = Array.isArray(tech.today_tasks) ? tech.today_tasks : []; let tasksHtml = '
Ingen opgaver i dag.
'; if (todayTasks.length > 0) { tasksHtml = '
' + todayTasks.slice(0, 3).map(function (task) { return '
' + esc(task.title || ('Sag #' + task.id)) + '
'; }).join('') + '
'; } out.push( '
' + '
' + '
' + esc(tech.owner_name || 'Tekniker') + '
I dag: ' + Number(tech.due_today_cases || 0) + ' • Åbne: ' + Number(tech.open_cases || 0) + '
' + '
' + '' + '' + '
' + '
' + tasksHtml + '
' ); }); } if (escalations.length > 0) { out.push('
Eskaleringer
'); escalations.slice(0, 4).forEach(function (c) { const ageHours = Math.floor(Number(c.age_seconds || 0) / 3600); out.push( '
' + '
' + esc(c.title || 'Sag') + '
' + esc(c.owner_name || 'Ikke tildelt') + ' • ' + ageHours + 't siden opdatering
' + '' + '
' ); }); } if (unassigned.length > 0) { out.push('
Supportkø uden ansvarlig
' + Number(stats.support_unassigned || 0) + '
'); unassigned.slice(0, 6).forEach(function (c) { const ageHours = Math.floor(Number(c.age_seconds || 0) / 3600); const ageLabel = ageHours >= 24 ? Math.floor(ageHours / 24) + 'd i kø' : Math.max(ageHours, 0) + 't i kø'; out.push( '
' + '
' + esc(c.title || 'Sag') + '
' + esc(c.customer_name || 'Ingen kunde') + ' · ' + ageLabel + ' · ' + esc(c.priority || 'normal') + '
' + '
' + '' + '' + '' + '
' + '
' ); }); } else { out.push('
Supportkøen er fordelt.
'); } 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'); 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'); 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', drift: 'Drift', eset: 'ESET' }; if (chipLabel) { chipLabel.textContent = labels[key]; } if (chipBubble) { chipBubble.textContent = String(val); } 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 = ' ' + detailTextFor(key, latestSections); }); chip.addEventListener('mouseleave', function () { detail.innerHTML = ' Klik på en kategori for at se detaljer'; }); }); } function updateActivityZone() { const timerChip = byId('bbActiveTimerChip'); const timerText = byId('bbActiveTimerText'); const notifCount = byId('bbNotificationsCount'); const pauseBtn = byId('bbTimerPauseBtn'); const stopBtn = byId('bbTimerStopBtn'); const timer = ((latestSections || {}).timer || {}).active || {}; const ownTimers = ((latestSections || {}).timer || {}).own || {}; const hasPausedTimer = Array.isArray(ownTimers.paused) && ownTimers.paused.length > 0; 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 unreadMessages = Number((((latestSections || {}).messages || {}).count) || 0); const computed = Number(latestNotificationCount || 0) + unreadMessages; notifCount.textContent = String(computed); } if (pauseBtn) { pauseBtn.disabled = !hasActiveTimer && !hasPausedTimer; pauseBtn.title = hasActiveTimer ? 'Pause timer' : (hasPausedTimer ? 'Genoptag senest pausede timer' : 'Ingen timer at pause'); pauseBtn.innerHTML = hasActiveTimer ? '' : ''; } if (stopBtn) { stopBtn.disabled = !hasActiveTimer; stopBtn.title = hasActiveTimer ? 'Stop timer' : 'Ingen aktiv timer'; } } function renderTabPanel() { const titleContainer = byId('bbTabTitle'); const innerContent = byId('bbTabInnerContent'); if (!titleContainer || !innerContent) { return; } let messageFocusState = null; if (activeKey === 'messages') { const activeEl = document.activeElement; const activeId = activeEl && activeEl.id ? activeEl.id : ''; if (activeId === 'chatInputQuick' || activeId === 'chatRecipient' || activeId === 'chatRequiresAck') { messageFocusState = { id: activeId, selectionStart: typeof activeEl.selectionStart === 'number' ? activeEl.selectionStart : null, selectionEnd: typeof activeEl.selectionEnd === 'number' ? activeEl.selectionEnd : null }; } } const titleText = titleContainer.querySelector('.bb-tab-title-text'); const descriptionEl = byId('bbTabDescription'); const titleByKey = { overview: 'Overblik', timer: 'Timere', messages: 'Beskeder', tasks: 'Opgaver', notes: 'Noter', boss: 'Sagsfordeling' }; const iconByKey = { overview: 'bi-bell', timer: 'bi-stopwatch', messages: 'bi-chat-dots', tasks: 'bi-calendar-check', notes: 'bi-journal-text', boss: 'bi-person-workspace' }; const descriptionByKey = { overview: 'Det vigtigste samlet ét sted.', timer: 'Se aktiv tid, stop registreringen eller skift direkte til en anden sag.', messages: 'Interne samtaler samlet efter modtager med tydelig læst-status.', tasks: 'Prioritér næste handling ud fra deadlines og aktuelle påmindelser.', notes: 'Skriv hurtigt til venstre og genbrug dine noter fra arkivet til højre.', boss: 'Fordel supportkøen ud fra kapacitet, hast og ventetid.' }; const activeTitle = titleByKey[activeKey] || 'Info'; if (titleText) { titleText.textContent = activeTitle; } else { titleContainer.textContent = activeTitle; } if (descriptionEl) { descriptionEl.textContent = descriptionByKey[activeKey] || ''; } 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'; ul.classList.add('bb-panel-' + activeKey); 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 = ''; if (activeKey === 'timer') { renderTimerWorkQueue(innerContent); return; } // 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 = ''; innerContent.appendChild(topBar); } if (activeKey === 'messages') { const chatContainer = document.createElement('div'); chatContainer.className = 'bb-messages-layout'; ul.classList.add('bb-messages-list'); const activeThread = ensureActiveMessageThread(); const threadItems = getRenderableMessageThreads(activeThread); syncChatRecipientToActiveThread(activeThread); if (threadItems.length > 0) { const threadList = document.createElement('div'); threadList.className = 'bb-message-threads'; threadItems.forEach(function (thread) { const button = document.createElement('button'); button.type = 'button'; button.className = 'bb-message-thread' + (activeThread && thread.key === activeThread.key ? ' is-active' : ''); button.setAttribute('data-bb-thread-key', thread.key); button.innerHTML = '' + '' + escapeHtml(thread.label || 'Samtale') + '' + (thread.unread > 0 ? '' + Number(thread.unread || 0) + '' : ''); threadList.appendChild(button); }); chatContainer.appendChild(threadList); } const replyBox = document.createElement('div'); replyBox.className = 'bb-messages-composer'; const threadMeta = activeThread ? '
' + (activeThread.partnerUserId ? ('Samtale med ' + escapeHtml(activeThread.label || 'Bruger')) : 'Besked til alle på vagt') + '
' : ''; const replyBanner = chatComposerState.replyToUserId ? `
Svarer til ${escapeHtml(chatComposerState.replyToName || 'Bruger')}
` : ''; replyBox.innerHTML = ` ${threadMeta} ${replyBanner}
`; chatContainer.appendChild(ul); chatContainer.appendChild(replyBox); innerContent.appendChild(chatContainer); const recipientSelect = document.getElementById('chatRecipient'); const input = document.getElementById('chatInputQuick'); const ackToggle = document.getElementById('chatRequiresAck'); const cancelReplyBtn = document.getElementById('bbCancelReplyBtn'); renderChatRecipientOptions(recipientSelect); if (recipientSelect) { recipientSelect.disabled = !!chatComposerState.replyToUserId; } if (ackToggle) { ackToggle.checked = !!chatComposerState.requiresManualAck; ackToggle.addEventListener('change', function () { chatComposerState.requiresManualAck = !!ackToggle.checked; }); } if (input) { input.addEventListener('input', function () { chatComposerState.draft = input.value || ''; }); } if (recipientSelect) { recipientSelect.addEventListener('change', function () { chatComposerState.recipient = recipientSelect.value || 'all'; if (!chatComposerState.replyToUserId) { chatComposerState.activeThreadKey = chatComposerState.recipient === 'all' ? 'broadcast' : ('user:' + chatComposerState.recipient); renderTabPanel(); window.requestAnimationFrame(function () { const nextInput = document.getElementById('chatInputQuick'); if (nextInput) { nextInput.focus(); } }); } }); } if (cancelReplyBtn) { cancelReplyBtn.addEventListener('click', function () { clearChatReplyState(); renderTabPanel(); }); } fetchChatUsers(); markActiveMessageThreadRead(activeThread); if (messageFocusState) { window.requestAnimationFrame(function () { const focusEl = document.getElementById(messageFocusState.id); if (!focusEl) return; focusEl.focus(); if ( messageFocusState.id === 'chatInputQuick' && typeof messageFocusState.selectionStart === 'number' && typeof focusEl.setSelectionRange === 'function' ) { focusEl.setSelectionRange(messageFocusState.selectionStart, messageFocusState.selectionEnd ?? messageFocusState.selectionStart); } }); } } else { if (activeKey === 'notes') { const notesContainer = document.createElement('div'); notesContainer.className = 'bb-notes-layout'; const noteItems = Array.from(ul.children); const editorItem = noteItems.shift() || null; if (editorItem) { const editorWrap = document.createElement('div'); editorWrap.className = 'bb-notes-editor'; editorWrap.appendChild(editorItem); notesContainer.appendChild(editorWrap); } const notesList = document.createElement('ul'); notesList.className = 'bb-tab-list bb-notes-list'; noteItems.forEach(function (item) { notesList.appendChild(item); }); notesContainer.appendChild(notesList); innerContent.appendChild(notesContainer); } else { innerContent.appendChild(ul); } } } function timerStateLabel(state) { if (state === 'active') return ['Aktiv', 'success']; if (state === 'paused') return ['Pauset', 'warning']; return ['Klar til registrering', 'info']; } function formatTimerSeconds(value) { const seconds = Math.max(0, Number(value || 0)); return [Math.floor(seconds / 3600), Math.floor((seconds % 3600) / 60), Math.floor(seconds % 60)] .map(function (part) { return String(part).padStart(2, '0'); }).join(':'); } function renderTimerWorkQueue(container) { const scope = timerPanelState.scope; const toolbar = document.createElement('div'); toolbar.className = 'd-flex justify-content-between align-items-center gap-2 mb-3'; toolbar.innerHTML = '
' + '' + '
' + '' + timerPanelState.rows.length + ' vist'; container.appendChild(toolbar); if (timerPanelState.loading || !timerPanelState.loaded) { const loading = document.createElement('div'); loading.className = 'bb-panel-empty'; loading.innerHTML = '
Henter timere...
'; container.appendChild(loading); if (!timerPanelState.loading) loadTimerWorkQueue(scope); return; } if (timerPanelState.error) { const errorBox = document.createElement('div'); errorBox.className = 'alert alert-warning d-flex justify-content-between align-items-center gap-3'; errorBox.innerHTML = '' + escapeHtml(timerPanelState.error) + '' + ''; container.appendChild(errorBox); } const list = document.createElement('div'); list.className = 'bb-timer-work-grid'; if (!timerPanelState.rows.length) { list.innerHTML = '
Ingen timere at vise
'; } else { list.innerHTML = timerPanelState.rows.map(function (row) { const state = String(row.timer_state || 'pending_conversion'); const label = timerStateLabel(state); const timeId = Number(row.id || row.time_entry_id || 0); const sagId = Number(row.sag_id || 0); const isOwn = row.is_own_timer === true; let action = ''; if (isOwn && state === 'paused') action = ''; if (isOwn && state === 'pending_conversion') action = ''; if (isOwn && state === 'active') action = ''; const elapsedValue = row.elapsed_hhmmss || formatTimerSeconds(row.live_elapsed_seconds || row.elapsed_seconds || row.elapsed); const elapsed = state === 'active' ? '' + escapeHtml(elapsedValue) + ' · ' : ''; const employeeName = row.employee_display_name || row.medarbejder_navn || row.user_name || (isOwn ? 'Min timer' : 'Ukendt medarbejder'); return '
' + label[0] + '' + escapeHtml(row.sag_navn || ('Sag #' + sagId)) + 'Sag #' + sagId + '' + elapsed + escapeHtml(employeeName) + '
' + action + '
'; }).join(''); } container.appendChild(list); } function ownTimerRows(payload) { const groups = normalizeSwitchableTimerPayload(payload); return groups.active.map(function (row) { return Object.assign({}, row, { timer_state: 'active', is_own_timer: true }); }).concat(groups.paused.map(function (row) { return Object.assign({}, row, { timer_state: 'paused', is_own_timer: true }); }), groups.stopped.map(function (row) { return Object.assign({}, row, { timer_state: 'pending_conversion', is_own_timer: true }); })); } async function loadTimerWorkQueue(scope) { timerPanelState.scope = scope || 'mine'; timerPanelState.loading = true; timerPanelState.error = ''; try { if (timerPanelState.scope === 'mine') { const ownTimers = await fetchSwitchableTimers(); timerPanelState.rows = ownTimerRows(ownTimers); switchCaseState.timers.stopped = timerPanelState.rows.filter(function (row) { return row.timer_state === 'pending_conversion'; }); } else { const response = await fetch('/api/v1/timetracking/time/team-status?scope=all', { credentials: 'include' }); if (!response.ok) throw new Error('Kunne ikke hente alle medarbejderes timere'); const payload = await response.json(); timerPanelState.rows = Array.isArray(payload.rows) ? payload.rows : []; } timerPanelState.loaded = true; } catch (err) { timerPanelState.error = err && err.message ? err.message : 'Kunne ikke hente timeroversigten'; timerPanelState.loaded = true; } finally { timerPanelState.loading = false; if (activeKey === 'timer') renderTabPanel(); } } 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 = ' 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 = ' ' + detailTextFor(key, latestSections); } const routes = { mail: '/emails', urgent: '/sag?priority=urgent', timer: '/timetracking', cases: '/sag', drift: '/drift' }; if (key === 'unassigned') { openUnassignedCasesPanel(); return; } 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() { let shouldScheduleNextPoll = true; fetchBottomBarState().then(function (data) { applyState(data); }).catch(function (err) { console.warn('Bottom bar poll failed', err); if (err && (err.status === 401 || err.status === 403)) { shouldScheduleNextPoll = false; disableBottomBarAuthRetry('poll-' + err.status); } }).finally(function () { if (shouldScheduleNextPoll) { pollTimer = window.setTimeout(pollOnce, 15000); } }); } function startPollingFallback() { stopPolling(); pollOnce(); } function updateFromRealtimeEvent(payload) { if (!payload || !payload.event) { return; } const onDriftPage = (window.location.pathname || '').toLowerCase().indexOf('/drift') === 0; 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.drift = latestSections.drift || {}; latestSections.kuma = latestSections.kuma || {}; 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); if (!onDriftPage && Object.prototype.hasOwnProperty.call(status, 'drift_active')) { latestSections.drift.down = Number(status.drift_active || 0); latestSections.kuma.down = Number(status.drift_active || 0); } // On /drift page, the page script keeps drift chip in sync with visible filtered events. if (!onDriftPage) { // Always reconcile against HTTP status/summary source to avoid stale WS drift counts. refreshDriftFromSummary(); } latestSections.boss.stats = latestSections.boss.stats || {}; latestSections.boss.stats.unassigned = Number(status.sager_unassigned || 0); } if (payload.event === 'notification_delta') { const rawData = payload.data || {}; const notifications = rawData.notifications || rawData; const messages = rawData.messages || null; const items = Array.isArray(notifications.items) ? notifications.items : []; latestSections.tasks = latestSections.tasks || {}; if (messages) { latestSections.messages = { count: Number(messages.count || 0), list: Array.isArray(messages.list) ? messages.list : [] }; } else { latestSections.messages = latestSections.messages || {}; } 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); updateMessagesTabBadge(); updateActivityZone(); const focusedId = document.activeElement && document.activeElement.id; const quickNoteFocused = activeKey === 'overview' && focusedId === 'bbQuickNoteInput'; const noteEditorFocused = activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput'); if (!quickNoteFocused && !noteEditorFocused && !isMessagesComposerFocused()) { renderTabPanel(); } } 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 (event) { // 1008 = policy violation (used server-side for auth failure). if (event && event.code === 1008) { disableBottomBarAuthRetry('ws-1008'); return; } 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); }); } 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: [], stopped: [] }; if (!payload || typeof payload !== 'object') { return out; } if (Array.isArray(payload.active) || Array.isArray(payload.paused) || Array.isArray(payload.stopped)) { out.active = Array.isArray(payload.active) ? payload.active : []; out.paused = Array.isArray(payload.paused) ? payload.paused : []; out.stopped = Array.isArray(payload.stopped) ? payload.stopped : []; 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 timerDurationLabel(timer) { let seconds = Number((timer && (timer.live_elapsed_seconds || timer.elapsed_seconds || timer.elapsed)) || 0); if (!seconds && timer && timer.start_tid && timer.slut_tid) { const start = new Date(timer.start_tid).getTime(); const end = new Date(timer.slut_tid).getTime(); if (Number.isFinite(start) && Number.isFinite(end)) { seconds = Math.max(0, Math.floor((end - start) / 1000) - Number(timer.pause_total_seconds || 0)); } } if (!seconds && timer && timer.faktisk_tid_min) seconds = Number(timer.faktisk_tid_min) * 60; return formatTimerSeconds(seconds); } function timerCaseInfo(timer, includeDuration) { const sagId = Number((timer && timer.sag_id) || 0); const customer = String((timer && timer.customer_name) || 'Ingen kunde'); const contact = String((timer && timer.contact_name) || 'Ingen kontakt'); const status = String((timer && timer.case_status) || 'Ukendt status'); return '' + timerDisplayName(timer) + '' + '#' + sagId + '' + '' + escapeHtml(customer) + '' + '' + escapeHtml(contact) + '' + '' + escapeHtml(status) + '' + (includeDuration ? '' + timerDurationLabel(timer) + '' : ''); } 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 stopped = Array.isArray(switchCaseState.timers.stopped) ? switchCaseState.timers.stopped : []; const recentCases = Array.isArray(switchCaseState.recentCases) ? switchCaseState.recentCases : []; const unassignedCases = Array.isArray(switchCaseState.unassignedCases) ? switchCaseState.unassignedCases : []; const showUnassigned = unassignedCases.length > 0; const searchEl = byId('bbSwitchCaseSearch'); const query = String((searchEl && searchEl.value) || '').trim().toLocaleLowerCase('da-DK'); if (actionsEl) { actionsEl.classList.toggle('d-none', !switchCaseState.activeTimer); } if (!active.length && !paused.length && !stopped.length) { timersEl.innerHTML = '
Ingen tidligere timere at fortsætte.
'; } else { let timerItems = ''; active.forEach(function (t) { const timeId = Number((t && (t.id || t.time_entry_id)) || 0); timerItems += '
Aktiv' + timerCaseInfo(t, true) + '
' + '
'; }); paused.forEach(function (t) { const timeId = Number((t && (t.time_entry_id || t.id)) || 0); timerItems += '
Pauset' + timerCaseInfo(t, true) + '
' + '
' + '
'; }); stopped.forEach(function (t) { const sagId = Number((t && t.sag_id) || 0); const timeId = Number((t && (t.time_entry_id || t.id)) || 0); timerItems += '
' + '
' + timerCaseInfo(t, true) + '
' + '
' + '
'; }); timersEl.innerHTML = timerItems; } const sourceCases = (showUnassigned ? unassignedCases : recentCases).filter(function (row) { if (!query) return true; const searchable = String((row && (row.sag_id || row.id)) || '') + ' ' + String((row && (row.titel || row.title)) || ''); return searchable.toLocaleLowerCase('da-DK').includes(query); }); const titleEl = byId('bbSwitchCaseModalLabel'); if (titleEl) { titleEl.innerHTML = showUnassigned ? 'Uden ansvarlig (åbne sager)' : 'Skift sag'; } if (!sourceCases.length) { recentEl.innerHTML = '
' + (query ? 'Ingen sager matcher din søgning.' : 'Ingen sager at vise.') + '
'; 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 meta = showUnassigned ? 'Uden ansvarlig' : 'Senest anvendt'; return ( '
' + '
' + title + '
Sag #' + caseId + ' · ' + meta + '
' + '
' + '' + '' + '
' + '
' ); }).join(''); } async function openTimeConversion(timeId) { const timer = (switchCaseState.timers.stopped || []).concat(timerPanelState.rows || []).find(function (row) { return Number((row && (row.time_entry_id || row.id)) || 0) === Number(timeId); }); if (!timer) return; const panel = byId('bbConvertTimePanel'); const select = byId('bbConvertBillingMethod'); if (!panel || !select) return; panel.dataset.timeId = String(timeId); byId('bbConvertTimeName').textContent = String((timer.sag_navn || timer.title) || ('Sag #' + (timer.sag_id || ''))); byId('bbConvertWorkType').value = timer.work_type || 'support'; byId('bbConvertMinutes').value = Number(timer.fakturerbar_tid_min != null ? timer.fakturerbar_tid_min : (timer.faktisk_tid_min || 0)); select.innerHTML = ''; panel.classList.remove('d-none'); try { const response = await fetch('/api/v1/timetracking/time/' + Number(timeId) + '/settlement-options', { credentials: 'include' }); if (!response.ok) throw new Error('Kunne ikke hente afregningsmuligheder'); const options = await response.json(); const recommended = options.recommended || { method: 'invoice' }; let html = ''; (options.prepaid_cards || []).forEach(function (card) { html += ''; }); (options.agreements || []).forEach(function (agreement) { html += ''; }); html += ''; select.innerHTML = html; const recommendedId = recommended.prepaid_card_id || recommended.fixed_price_agreement_id; select.value = recommended.method + (recommendedId ? ':' + recommendedId : ''); byId('bbConvertRecommendation').textContent = 'Foreslået: ' + (recommended.reason || 'Faktura'); } catch (err) { select.innerHTML = ''; byId('bbConvertRecommendation').textContent = err.message || 'Kunne ikke hente forslag.'; } } async function submitTimeConversion() { const panel = byId('bbConvertTimePanel'); const button = byId('bbConvertSubmit'); const timeId = Number((panel && panel.dataset.timeId) || 0); if (!timeId || !button) return; const selected = String(byId('bbConvertBillingMethod').value || 'invoice').split(':'); const payload = { billing_method: selected[0], work_type: byId('bbConvertWorkType').value, fakturerbar_tid_min: Math.max(0, Number(byId('bbConvertMinutes').value || 0)), entry_type: 'manuel' }; if (selected[0] === 'prepaid') payload.prepaid_card_id = Number(selected[1]); if (selected[0] === 'subscription') payload.fixed_price_agreement_id = Number(selected[1]); button.disabled = true; try { const response = await fetch('/api/v1/timetracking/time/' + timeId + '/approve', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const result = await response.json().catch(function () { return {}; }); if (!response.ok) throw new Error(result.detail || 'Kunne ikke konvertere tiden'); panel.classList.add('d-none'); await loadSwitchCaseData(); if (activeKey === 'timer') await loadTimerWorkQueue(timerPanelState.scope); } catch (err) { switchCaseStatusMessage('' + escapeHtml(err.message || 'Kunne ikke konvertere tiden.')); } finally { button.disabled = false; } } 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: [], stopped: [] }; if (opts.onlyUnassigned) { const unassigned = (((latestSections || {}).unassigned || {}).list || []); switchCaseState.unassignedCases = Array.isArray(unassigned) ? unassigned.slice(0, 25) : []; switchCaseStatusMessage('Viser kun åbne sager uden ansvarlig.'); renderSwitchCaseLists(); return; } switchCaseStatusMessage('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('Kunne ikke hente ' + escapeHtml(failedParts.join(' + ')) + '. Viser det vi har.'); } else if (switchCaseState.activeTimer) { const name = timerDisplayName(switchCaseState.activeTimer); switchCaseStatusMessage('Aktiv timer: ' + name + '. Vælg handling før du starter en ny timer.'); } else { switchCaseStatusMessage('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('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('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 + '/v3'; } catch (err) { switchCaseStatusMessage('' + 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 + '/v3'; } function executeBottomBarAction(action) { if (!action) return false; const command = String(action.command || ''); if (command.indexOf('case_add:') === 0) { const caseAction = command.slice('case_add:'.length); if (typeof window.openCaseModuleAddPanel === 'function' && typeof window.openCaseAddAction === 'function') { Promise.resolve(window.openCaseModuleAddPanel()).then(function () { return window.openCaseAddAction(caseAction); }); setExpanded(false); return true; } } if (command === 'switch_timer') { openSwitchCaseModal(); return true; } if (command === 'open_notes') { activeKey = 'notes'; setExpanded(true); renderTabPanel(); return true; } if (action.action) { window.location.href = action.action; return true; } return false; } function resolveQuickNoteCaseId() { const match = (window.location.pathname || '').match(/^\/sag\/(\d+)(?:\/v3)?\/?$/); 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 ''; }).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 = '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); } function bindHeaderActions() { const backBtn = byId('bbBackBtn'); const searchBtn = byId('bbSearchBtn'); const notificationsBtn = byId('bbNotificationsBtn'); const pauseBtn = byId('bbTimerPauseBtn'); const stopBtn = byId('bbTimerStopBtn'); const switchBtn = byId('bbTimerSwitchBtn'); const timerChip = byId('bbActiveTimerChip'); if (backBtn) { backBtn.addEventListener('click', function () { if (window.history.length > 1) { window.history.back(); } else { window.location.href = '/sag'; } }); } if (searchBtn) { searchBtn.addEventListener('click', function () { const trigger = byId('globalSearchBtn'); if (trigger) { trigger.click(); } }); } if (notificationsBtn) { notificationsBtn.addEventListener('click', function () { const trigger = byId('globalRemindersBtn'); if (trigger) { trigger.click(); } }); } if (pauseBtn) { pauseBtn.addEventListener('click', function () { 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); }); }); } if (stopBtn) { stopBtn.addEventListener('click', function () { stopActiveTimer() .then(fetchBottomBarState) .then(applyState) .catch(function (err) { console.warn('Failed stopping timer', err); }); }); } if (switchBtn) { switchBtn.addEventListener('click', function () { openSwitchCaseModal(); }); } if (timerChip) { timerChip.addEventListener('click', function () { const timer = (((latestSections || {}).timer || {}).active || {}); const sagId = Number(timer.sag_id || 0); window.location.href = sagId > 0 ? ('/sag/' + sagId) : '/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; } } executeBottomBarAction(matched); }); } function bindDynamicActions() { document.addEventListener('click', function (e) { const target = e.target; const timerScopeButton = target && target.closest('[data-bb-timer-scope]'); if (timerScopeButton) { timerPanelState.loaded = false; loadTimerWorkQueue(timerScopeButton.getAttribute('data-bb-timer-scope') || 'mine'); renderTabPanel(); return; } const caseLink = target && target.closest('[data-bb-case-link]'); if (caseLink && !target.closest('button, a, input, select, textarea')) { const sagId = Number(caseLink.getAttribute('data-bb-case-link') || 0); if (sagId > 0) openCaseDetail(sagId); return; } const btn = target && target.closest('button'); if (!btn) return; 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 = ' 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 = ' Note gemt.'; } }) .catch(function (err) { console.warn('Failed saving note', err); const detail = byId('bbCountDetail'); if (detail) { detail.innerHTML = ' ' + 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; } const threadKey = String(btn.getAttribute('data-bb-thread-key') || '').trim(); if (threadKey) { clearChatReplyState(); chatComposerState.activeThreadKey = threadKey; if (threadKey === 'broadcast') { chatComposerState.recipient = 'all'; } else if (threadKey.indexOf('user:') === 0) { chatComposerState.recipient = String(Number(threadKey.split(':')[1] || 0) || 'all'); } activeKey = 'messages'; renderTabPanel(); window.requestAnimationFrame(function () { const input = document.getElementById('chatInputQuick'); if (input) { input.focus(); } }); return; } const replyMessageId = Number(btn.getAttribute('data-bb-reply-message') || 0); if (replyMessageId > 0) { const message = ((((latestSections || {}).messages || {}).list) || []).find(function (item) { return Number(item.id || 0) === replyMessageId; }); if (!message) { return; } setChatReplyState(message); activeKey = 'messages'; renderTabPanel(); window.requestAnimationFrame(function () { const input = document.getElementById('chatInputQuick'); if (input) { input.focus(); } }); return; } const acknowledgeMessageId = Number(btn.getAttribute('data-bb-ack-message') || 0); if (acknowledgeMessageId > 0) { btn.disabled = true; acknowledgeMessage(acknowledgeMessageId) .then(function () { if (activeKey === 'messages') { renderTabPanel(); } }) .catch(function (err) { console.warn('Failed acknowledging message', err); alert(err.message || 'Kunne ikke bekræfte besked'); }) .finally(function () { btn.disabled = false; }); 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 = ' 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('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('Timer sat på pause. Du kan nu starte ny timer.'); return loadSwitchCaseData(); }).catch(function (err) { switchCaseStatusMessage('' + escapeHtml(err.message || 'Kunne ikke pause timer.')); }); return; } if (switchAction === 'stop-now') { stopActiveTimer().then(function () { switchCaseState.decision = 'stop'; switchCaseState.activeTimer = null; switchCaseStatusMessage('Aktiv timer stoppet. Du kan nu starte ny timer.'); return loadSwitchCaseData(); }).catch(function () { switchCaseStatusMessage('Kunne ikke stoppe timer.'); }); return; } } if (btn.hasAttribute('data-bb-cancel-convert')) { const panel = byId('bbConvertTimePanel'); if (panel) panel.classList.add('d-none'); return; } if (btn.id === 'bbConvertSubmit') { submitTimeConversion(); return; } const convertTimeId = Number(btn.getAttribute('data-bb-convert-time') || 0); if (convertTimeId > 0) { openTimeConversion(convertTimeId); return; } const openCaseId = Number(btn.getAttribute('data-bb-open-case') || 0); if (openCaseId > 0) { openCaseDetail(openCaseId); return; } const resumeTimeId = Number(btn.getAttribute('data-bb-resume-time') || 0); if (resumeTimeId > 0) { btn.disabled = true; switchCaseStatusMessage('Genoptager timer...'); resumeTimer(resumeTimeId) .then(fetchBottomBarState) .then(function (state) { applyState(state); switchCaseStatusMessage('Timeren er genoptaget.'); return loadSwitchCaseData(); }) .catch(function (err) { btn.disabled = false; switchCaseStatusMessage('' + escapeHtml(err.message || 'Kunne ikke genoptage timer.')); }); 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 = '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 = ' 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; } const bossAction = btn.getAttribute('data-boss-action'); if (bossAction) { if (bossAction === 'assign_case_to_owner') { const caseId = Number(btn.getAttribute('data-case-id') || 0); const assigneeSelect = document.querySelector('[data-boss-assignee-for="' + caseId + '"]'); const ownerId = Number(assigneeSelect && assigneeSelect.value ? assigneeSelect.value : 0); if (caseId <= 0 || ownerId <= 0) { const detail = byId('bbCountDetail'); if (detail) detail.innerHTML = ' Vælg en medarbejder før sagen tildeles.'; return; } const originalHtml = btn.innerHTML; btn.disabled = true; btn.innerHTML = ''; fetch('/api/v1/bottom-bar/boss/assign-case', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ case_id: caseId, assignee_user_id: ownerId }) }) .then(async r => { const body = await r.json().catch(() => ({})); if (!r.ok) throw new Error(body.detail || 'Kunne ikke tildele sag'); return body; }) .then(data => { const detail = byId('bbCountDetail'); if (detail) detail.innerHTML = ' ' + escapeHtml(data.message || 'Sagen blev tildelt.'); return fetchBottomBarState(); }) .then(applyState) .catch(err => { const detail = byId('bbCountDetail'); if (detail) detail.innerHTML = ' ' + escapeHtml(err.message || 'Fejl ved tildeling'); }) .finally(() => { btn.disabled = false; btn.innerHTML = originalHtml; }); return; } 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 = '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 = ' Tildelt: ' + escapeHtml(data.case.title || 'Sag') + ' til tekniker.'; } else { detail.innerHTML = ' ' + 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 = ' ' + 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 = '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 = ' Auto-fordelt: ' + escapeHtml(data.case.title || 'Sag') + ' → ' + escapeHtml(data.assignee.name || 'medarbejder'); } else { detail.innerHTML = ' ' + 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 = ' ' + escapeHtml(err.message || 'Fejl ved auto-fordeling'); } }) .finally(() => { btn.disabled = false; btn.innerHTML = originalHtml; }); return; } if (bossAction === 'open_unassigned') { openUnassignedCasesPanel(); 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 + '/v3') : '/sag'; return; } } if (btn.id === 'btnNextTask') { console.log("-> Beder backend om næste opgave..."); btn.innerHTML = ' 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 = 'Du fik tildelt: ' + taskTitle + ' (Sag #' + caseId + ') ' + freeMins + 'm fri'; 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() !== '') { if (recipientObj && recipientObj.selectedOptions && recipientObj.selectedOptions[0] && recipientObj.selectedOptions[0].disabled) { alert('Brugerlisten kunne ikke indlæses endnu.'); return; } const recipientValue = recipientObj ? recipientObj.value : 'all'; const msgVal = input.value.trim(); chatComposerState.draft = msgVal; chatComposerState.recipient = recipientValue || 'all'; chatComposerState.activeThreadKey = chatComposerState.recipient === 'all' ? 'broadcast' : ('user:' + chatComposerState.recipient); const requiresManualAck = !!chatComposerState.requiresManualAck; btn.disabled = true; fetch('/api/v1/bottom-bar/messages', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, body: JSON.stringify({ message: msgVal, recipient_user_id: recipientValue && recipientValue !== 'all' ? Number(recipientValue) : null, requires_manual_ack: requiresManualAck }) }) .then(async r => { if (!r.ok) { let detail = 'Kunne ikke sende besked'; try { const payload = await r.json(); detail = payload.detail || payload.message || detail; } catch (_) { // Ignore parse failure } throw new Error(detail); } return r.json(); }) .then(() => { input.value = ''; chatComposerState.draft = ''; chatComposerState.requiresManualAck = false; clearChatReplyState(); return fetchBottomBarState(); }) .then((data) => { applyState(data); if (activeKey === 'messages') { renderTabPanel(); } const tabInner = document.getElementById('bbTabInnerContent'); if (tabInner) { tabInner.scrollTop = tabInner.scrollHeight + 500; } }) .catch(err => { console.error('Fejl ved afsendelse af bundmenu-besked:', err); alert(err.message || 'Kunne ikke sende besked'); }) .finally(() => { btn.disabled = false; }); } } }); 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 === 'bbSwitchCaseSearch') { renderSwitchCaseLists(); return; } 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'); } } }); } document.addEventListener('DOMContentLoaded', function () { activeKey = 'overview'; // Default overview state bindChipClicks(); bindChipHoverPreview(); bindSheetToggle(); bindHeaderActions(); bindDynamicActions(); bindSideTabs(); startPollingFallback(); connectRealtime(); }); })();