(function () { let latestSections = {}; let latestContextActions = { global: [], context: [] }; let activeKey = 'timer'; let overviewFilter = null; let ws = null; let pollTimer = null; let wsReconnectTimer = null; let latestNotificationCount = 0; let latestNotifications = []; let switchCaseModalInstance = null; let quickNoteDraft = ''; let quickNoteHintState = { message: 'Tip: gemmer som kommentar på aktiv/åben sag.', level: 'muted' }; let switchCaseState = { activeTimer: null, decision: 'unchanged', timers: { active: [], paused: [] }, recentCases: [], unassignedCases: [] }; let noteEditorState = { editingId: 0, title: '', content: '' }; let noteTargetModalInstance = null; let noteTargetState = { target: 'case', noteId: 0 }; 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) + '
') : ['Alle systemer oppe.']; if (overviewFilter === 'eset') return eset.list ? eset.list.map(e => '
🔐 ' + esc(e) + '
') : ['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.
']; 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.
'); } // 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 '
' + esc(t.desc) + ' (' + esc(elapsedText) + ')
'; }); } return ['Ingen aktive timere lige nu.']; } 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) + ' ' + esc(t.deadline) + '
'); } return ['Ingen aktuelle opgaver.']; } 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 out = [ '
' + '
Åbne sager
' + Number(stats.open_cases || 0) + '
' + '
Hastesager
' + Number(stats.urgent_cases || 0) + '
' + '
Uden ansvarlig
' + Number(stats.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('
Ufordelte sager
'); unassigned.slice(0, 4).forEach(function (c) { out.push( '
' + '
' + esc(c.title || 'Sag') + '
Prioritet: ' + esc(c.priority || 'normal') + '
' + '' + '
' ); }); } 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 timer = ((latestSections || {}).timer || {}).active || {}; const hasActiveTimer = !!timer.active; if (timerChip && timerText) { timerChip.classList.toggle('is-hidden', !hasActiveTimer); if (hasActiveTimer) { const elapsed = timer.elapsed_hhmmss || '00:00:00'; const name = timer.sag_navn || ('Sag #' + (timer.sag_id || '')); timerText.textContent = name + ' - ' + elapsed; } } if (notifCount) { const unreadMessages = Number((((latestSections || {}).messages || {}).count) || 0); const computed = Number(latestNotificationCount || 0) + unreadMessages; notifCount.textContent = String(computed); } } 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 titleByKey = { overview: 'Overblik', timer: 'Timere', messages: 'Beskeder', tasks: 'Opgaver', notes: 'Noter', boss: 'Chef Dashboard' }; 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 activeTitle = titleByKey[activeKey] || 'Info'; if (titleText) { titleText.textContent = activeTitle; } else { titleContainer.textContent = activeTitle; } const iconSpan = titleContainer.querySelector('.bi'); if (iconSpan) { iconSpan.className = 'bi ' + (iconByKey[activeKey] || 'bi-info-circle') + ' me-2 text-accent'; } // Render rich UI lists const lines = listFor(activeKey, latestSections); const ul = document.createElement('ul'); ul.className = 'bb-tab-list'; lines.forEach(function (line) { const li = document.createElement('li'); // Allow rich HTML (buttons, inputs) - assuming listFor provides sanitized data wrapped in markup li.innerHTML = line; ul.appendChild(li); }); innerContent.innerHTML = ''; // Add specific headers/controls based on active tab if (activeKey === 'tasks') { const topBar = document.createElement('div'); topBar.className = 'bb-task-actions mb-3'; topBar.innerHTML = ''; 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 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: [] }; if (!payload || typeof payload !== 'object') { return out; } if (Array.isArray(payload.active) || Array.isArray(payload.paused)) { out.active = Array.isArray(payload.active) ? payload.active : []; out.paused = Array.isArray(payload.paused) ? payload.paused : []; return out; } const active = payload.active || {}; const paused = Array.isArray(payload.paused) ? payload.paused : []; out.active = active && active.active ? [active] : []; out.paused = paused; return out; } async function fetchSwitchableTimers() { const primary = await fetch('/api/v1/timetracking/time/my-switchable', { credentials: 'include', headers: { 'Accept': 'application/json' } }); if (primary.ok) { const payload = await primary.json(); return normalizeSwitchableTimerPayload(payload); } const fallback = await fetch('/api/v1/bottom-bar/timers/own?paused_limit=10', { credentials: 'include', headers: { 'Accept': 'application/json' } }); if (!fallback.ok) { throw new Error('Kunne ikke hente timeroversigt'); } const payload = await fallback.json(); return normalizeSwitchableTimerPayload(payload); } async function fetchRecentCases() { const response = await fetch('/api/v1/sag/recent?limit=10', { credentials: 'include', headers: { 'Accept': 'application/json' } }); if (!response.ok) { throw new Error('Kunne ikke hente seneste sager'); } const payload = await response.json(); return Array.isArray(payload) ? payload : []; } function getSwitchCaseModal() { const modalEl = byId('bbSwitchCaseModal'); if (!modalEl || !window.bootstrap || !window.bootstrap.Modal) { return null; } if (!switchCaseModalInstance) { switchCaseModalInstance = window.bootstrap.Modal.getOrCreateInstance(modalEl); } return switchCaseModalInstance; } function switchCaseStatusMessage(html) { const statusEl = byId('bbSwitchCaseStatus'); if (statusEl) { statusEl.innerHTML = html; } } function timerDisplayName(timer) { const sagId = Number((timer && timer.sag_id) || 0); const title = (timer && (timer.sag_navn || timer.title || timer.beskrivelse || timer.desc)) || ''; if (title) { return escapeHtml(title); } return sagId > 0 ? ('Sag #' + sagId) : 'Ukendt sag'; } function renderSwitchCaseLists() { const timersEl = byId('bbSwitchTimersList'); const recentEl = byId('bbSwitchRecentCasesList'); const actionsEl = byId('bbSwitchTimerActions'); if (!timersEl || !recentEl) { return; } const active = Array.isArray(switchCaseState.timers.active) ? switchCaseState.timers.active : []; const paused = Array.isArray(switchCaseState.timers.paused) ? switchCaseState.timers.paused : []; const recentCases = Array.isArray(switchCaseState.recentCases) ? switchCaseState.recentCases : []; const unassignedCases = Array.isArray(switchCaseState.unassignedCases) ? switchCaseState.unassignedCases : []; const showUnassigned = unassignedCases.length > 0; if (actionsEl) { actionsEl.classList.toggle('d-none', !switchCaseState.activeTimer); } if (!active.length && !paused.length) { timersEl.innerHTML = '
Ingen aktive eller pausede timere.
'; } else { let timerItems = ''; active.forEach(function (t) { const timeId = Number((t && (t.id || t.time_entry_id)) || 0); timerItems += '
' + '
Aktiv' + timerDisplayName(t) + '
' + '' + '
'; if (timeId > 0) { timerItems += '
Timer ID: ' + timeId + '
'; } }); paused.forEach(function (t) { timerItems += '
' + '
Pauset' + timerDisplayName(t) + '
' + '' + '
'; }); timersEl.innerHTML = timerItems; } const sourceCases = showUnassigned ? unassignedCases : recentCases; const titleEl = byId('bbSwitchCaseModalLabel'); if (titleEl) { titleEl.innerHTML = showUnassigned ? 'Uden ansvarlig (åbne sager)' : 'Skift sag'; } if (!sourceCases.length) { recentEl.innerHTML = '
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 prefix = showUnassigned ? 'Uden ansvarlig' : 'Senest'; return ( '
' + '
' + prefix + title + '
' + '
' + '' + '' + '
' + '
' ); }).join(''); } async function loadSwitchCaseData(options) { const opts = options || {}; switchCaseState.decision = 'unchanged'; switchCaseState.activeTimer = (((latestSections || {}).timer || {}).active || {}).active ? ((latestSections || {}).timer || {}).active : null; switchCaseState.unassignedCases = []; switchCaseState.recentCases = []; switchCaseState.timers = { active: [], paused: [] }; if (opts.onlyUnassigned) { const unassigned = (((latestSections || {}).unassigned || {}).list || []); switchCaseState.unassignedCases = Array.isArray(unassigned) ? unassigned.slice(0, 25) : []; switchCaseStatusMessage('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; } 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; } function resolveQuickNoteCaseId() { const match = (window.location.pathname || '').match(/^\/sag\/(\d+)$/); if (match && match[1]) { return Number(match[1]); } const active = (((latestSections || {}).timer || {}).active || {}); const activeSagId = Number(active.sag_id || 0); if (activeSagId > 0) { return activeSagId; } const recent = (((latestSections || {}).recent_cases || {}).items || []); const recentFirst = Number((((recent[0] || {}).id) || ((recent[0] || {}).sag_id) || 0)); return recentFirst > 0 ? recentFirst : 0; } function saveQuickNote(noteText, caseId) { return fetch('/api/v1/sag/' + Number(caseId) + '/kommentarer', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ indhold: noteText }) }).then(function (res) { if (!res.ok) { throw new Error('Kunne ikke gemme note'); } return res.json().catch(function () { return {}; }); }); } function noteById(noteId) { const notes = (((latestSections || {}).notes || {}).list || []); return notes.find(function (row) { return Number((row || {}).id || 0) === Number(noteId || 0); }) || null; } async function readApiError(response, fallbackMessage) { let payload = {}; try { payload = await response.json(); } catch (e) { payload = {}; } const detail = payload && payload.detail ? payload.detail : null; if (typeof detail === 'string' && detail.trim()) { return detail; } return fallbackMessage; } async function fetchWithNotesFallback(url, options) { const response = await fetch(url, options); if (response.status !== 404 || !/\/api\/v1\/bottom-bar\/notes(\/|$)/.test(url)) { return response; } const altUrl = url.endsWith('/') ? url.slice(0, -1) : (url + '/'); return fetch(altUrl, options); } async function createUserNote(title, content) { const endpoint = '/api/v1/bottom-bar/notes'; const res = await fetchWithNotesFallback(endpoint, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: title || '', content: content || '' }) }); if (res.status === 404) { notesApiUnavailable = true; const now = new Date().toISOString(); const local = loadLocalNotes(); const created = { id: Date.now(), title: String(title || '').trim(), content: String(content || '').trim(), is_pinned: false, is_archived: false, created_at: now, updated_at: now, _local_only: true }; local.unshift(created); saveLocalNotes(local); return created; } if (!res.ok) { throw new Error(await readApiError(res, 'Kunne ikke oprette note (' + endpoint + ')')); } notesApiUnavailable = false; return res.json().catch(function () { return {}; }); } async function updateUserNote(noteId, payload) { const endpoint = '/api/v1/bottom-bar/notes/' + Number(noteId || 0); const res = await fetchWithNotesFallback(endpoint, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload || {}) }); if (res.status === 404) { notesApiUnavailable = true; const local = loadLocalNotes(); const idNum = Number(noteId || 0); const updated = local.map(function (row) { if (Number((row || {}).id || 0) !== idNum) return row; return { ...row, ...payload, updated_at: new Date().toISOString(), _local_only: true }; }); saveLocalNotes(updated); return updated.find(function (row) { return Number((row || {}).id || 0) === idNum; }) || {}; } if (!res.ok) { throw new Error(await readApiError(res, 'Kunne ikke opdatere note (' + endpoint + ')')); } notesApiUnavailable = false; return res.json().catch(function () { return {}; }); } async function deleteUserNote(noteId) { const endpoint = '/api/v1/bottom-bar/notes/' + Number(noteId || 0); const res = await fetchWithNotesFallback(endpoint, { method: 'DELETE', credentials: 'include' }); if (res.status === 404) { notesApiUnavailable = true; const idNum = Number(noteId || 0); const local = loadLocalNotes().filter(function (row) { return Number((row || {}).id || 0) !== idNum; }); saveLocalNotes(local); return { status: 'deleted', note_id: idNum, _local_only: true }; } if (!res.ok) { throw new Error(await readApiError(res, 'Kunne ikke slette note (' + endpoint + ')')); } notesApiUnavailable = false; return res.json().catch(function () { return {}; }); } async function noteToCaseComment(noteId, caseId, excerpt) { const endpoint = '/api/v1/bottom-bar/notes/' + Number(noteId || 0) + '/actions/sag-comment'; const res = await fetchWithNotesFallback(endpoint, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sag_id: Number(caseId || 0), excerpt: excerpt || null }) }); if (!res.ok) { throw new Error(await readApiError(res, 'Kunne ikke indsætte i sag-kommentar (' + endpoint + ')')); } return res.json().catch(function () { return {}; }); } async function noteToContact(noteId, contactId, field, value, mode) { const endpoint = '/api/v1/bottom-bar/notes/' + Number(noteId || 0) + '/actions/contact-update'; const res = await fetchWithNotesFallback(endpoint, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ contact_id: Number(contactId || 0), field: String(field || ''), value: value || '', mode: mode || 'append' }) }); if (!res.ok) { throw new Error(await readApiError(res, 'Kunne ikke opdatere kontakt fra note (' + endpoint + ')')); } return res.json().catch(function () { return {}; }); } async function noteToCustomer(noteId, customerId, field, value, mode) { const endpoint = '/api/v1/bottom-bar/notes/' + Number(noteId || 0) + '/actions/customer-update'; const res = await fetchWithNotesFallback(endpoint, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ customer_id: Number(customerId || 0), field: String(field || ''), value: value || '', mode: mode || 'append' }) }); if (!res.ok) { throw new Error(await readApiError(res, 'Kunne ikke opdatere firma fra note (' + endpoint + ')')); } return res.json().catch(function () { return {}; }); } function getNoteTargetModal() { const modalEl = byId('bbNoteTargetModal'); if (!modalEl || !window.bootstrap || !window.bootstrap.Modal) { return null; } if (!noteTargetModalInstance) { noteTargetModalInstance = window.bootstrap.Modal.getOrCreateInstance(modalEl); } return noteTargetModalInstance; } function updateNoteTargetStatus(message, isError) { const statusEl = byId('bbNoteTargetStatus'); if (!statusEl) { return; } statusEl.textContent = String(message || ''); statusEl.classList.remove('text-muted', 'text-success', 'text-danger'); if (isError === true) { statusEl.classList.add('text-danger'); return; } if (isError === false) { statusEl.classList.add('text-success'); return; } statusEl.classList.add('text-muted'); } function renderNoteTargetFieldOptions(target) { const wrap = byId('bbNoteTargetFieldWrap'); const label = byId('bbNoteTargetFieldLabel'); const select = byId('bbNoteTargetFieldSelect'); if (!wrap || !label || !select) { return; } if (target === 'case') { wrap.classList.add('d-none'); select.innerHTML = ''; return; } const options = target === 'contact' ? [ { value: 'mobile', label: 'Mobile' }, { value: 'phone', label: 'Telefon' }, { value: 'email', label: 'Email' }, { value: 'title', label: 'Titel' }, { value: 'department', label: 'Afdeling' } ] : [ { value: 'note', label: 'Firma note' }, { value: 'mobile_phone', label: 'Mobil' }, { value: 'phone', label: 'Telefon' }, { value: 'email', label: 'Email' }, { value: 'address', label: 'Adresse' }, { value: 'invoice_email', label: 'Faktura-email' } ]; wrap.classList.remove('d-none'); label.textContent = target === 'contact' ? 'Kontaktfelt' : 'Firmafelt'; select.innerHTML = options.map(function (item) { return ''; }).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 () { if (latestNotifications.length > 0) { const first = latestNotifications[0] || {}; if (first.action) { window.location.href = first.action; return; } } const trigger = byId('globalRemindersBtn'); if (trigger) { trigger.click(); } }); } if (pauseBtn) { pauseBtn.addEventListener('click', function () { 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 () { window.location.href = '/timetracking'; }); } document.addEventListener('click', function (e) { const actionBtn = e.target && e.target.closest('[data-bb-create]'); if (!actionBtn) return; const actionKey = actionBtn.getAttribute('data-bb-create'); const actions = (latestContextActions.context || []).concat(latestContextActions.global || []); const matched = actions.find(function (item) { return item.id === actionKey; }); if (actionKey === 'new_case') { const quickBtn = byId('quickCreateBtn'); if (quickBtn) { quickBtn.click(); return; } } if (matched && matched.action) { window.location.href = matched.action; } }); } function bindDynamicActions() { document.addEventListener('click', function (e) { const target = e.target; const btn = target && target.closest('button'); if (!btn) return; 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; } } const openCaseId = Number(btn.getAttribute('data-bb-open-case') || 0); if (openCaseId > 0) { openCaseDetail(openCaseId); return; } const startCaseId = Number(btn.getAttribute('data-bb-start-case') || 0); if (startCaseId > 0) { startTimerForCase(startCaseId); return; } const stopTimeId = Number(btn.getAttribute('data-bb-stop-time') || 0); if (stopTimeId > 0) { fetch('/api/v1/timetracking/time/stop', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ time_id: stopTimeId }) }) .then(fetchBottomBarState) .then(applyState) .catch(function (err) { console.warn('Failed stopping timer from list', err); }); return; } if (btn.id === 'bbQuickNoteSaveBtn') { const input = byId('bbQuickNoteInput'); const value = input ? String(input.value || '').trim() : ''; quickNoteDraft = value; if (!value) { updateQuickNoteHint('Skriv en note først.', true); return; } const caseId = resolveQuickNoteCaseId(); if (!(caseId > 0)) { updateQuickNoteHint('Åbn en sag (eller start timer på en sag) før du gemmer quick note.', true); return; } const originalHtml = btn.innerHTML; btn.disabled = true; btn.innerHTML = '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_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) : '/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 !== '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(); }); })();