bmc_hub/static/js/bottom-bar.js
2026-09-08 01:55:47 +02:00

3575 lines
168 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

(function () {
let latestSections = {};
let latestContextActions = { global: [], context: [] };
let activeKey = 'timer';
let timerPanelState = { scope: 'mine', loading: false, loaded: false, rows: [], error: '' };
let locationsPanelState = { loading: false, loaded: false, rows: [], error: '', query: '' };
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';
window.addEventListener('hub:internal-message-sent', function () { fetchBottomBarState(); });
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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 = item.recipient_user_id == null ? 0 : (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 out = getMessageThreads();
if (activeThread?.key && !out.some(thread => thread.key === activeThread.key)) out.unshift(activeThread);
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 : [];
// An empty response from a healthy API is authoritative. Falling back
// merely because the list is empty resurrects stale/deleted local notes.
if (!notesApiUnavailable) {
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');
try {
window.localStorage.setItem('bmc-bottom-bar-expanded', expanded ? '1' : '0');
} catch (_) {
// Storage may be disabled in private browsing.
}
}
function syncBossTabVisibility() {
const bossBtn = document.querySelector('.bb-tab-btn[data-bb-tab="boss"]');
if (!bossBtn) {
return;
}
const canView = Boolean((((latestSections || {}).boss || {}).can_view));
bossBtn.classList.toggle('d-none', !canView);
if (!canView && activeKey === 'boss') {
activeKey = 'overview';
}
}
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),
procurement: Number((sections.procurement || {}).to_order || 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',
procurement: 'Varer der skal bestilles',
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';
}
if (key === 'procurement') return val > 0 ? 'sev-critical' : '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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
const quickNoteValue = esc(quickNoteDraft || '');
if (key === 'overview') {
if (overviewFilter === 'urgent') return urgent.list ? urgent.list.map(u => '<div><strong class="text-danger"><i class="bi bi-exclamation-octagon"></i> Hastesag:</strong> ' + esc(u.title) + ' <br><button class="btn btn-sm btn-outline-danger mt-2" data-bb-open-case="' + Number(u.id || 0) + '">Vis sag</button></div>') : ['Ingen hastesager.'];
if (overviewFilter === 'drift') return drift.list ? drift.list.map(k => '<div class="d-flex justify-content-between align-items-center"><span>📉 ' + esc(k) + '</span> <a class="btn btn-sm btn-outline-primary" href="/drift">Håndter i Drift</a></div>') : ['Alle systemer oppe.'];
if (overviewFilter === 'eset') return eset.list ? eset.list.map(e => '<div class="d-flex justify-content-between align-items-center"><span>🔐 ' + esc(e) + '</span> <a class="btn btn-sm btn-outline-primary" href="/hardware/eset">Håndter</a></div>') : ['Ingen ESET incidents.'];
if (overviewFilter === 'cases') return cases.list ? cases.list.map(c => '<div><i class="bi bi-folder2-open text-primary"></i> ' + esc(c.title) + ' <button class="btn btn-sm btn-outline-primary mt-2" data-bb-open-case="' + Number(c.id || 0) + '">Vis sag</button></div>') : ['Ingen åbne sager.'];
if (overviewFilter === 'mail') return ['<div>📧 <strong>' + mail.unread + '</strong> ulæste mails. <br>💬 <strong>' + mail.customer_reply_needed + '</strong> kræver kundesvar. <a class="btn btn-sm btn-outline-primary mt-2" href="/emails">Åbn indbakke</a></div>'];
if (overviewFilter === 'unassigned') return unassigned.list ? unassigned.list.map(u => '<div><i class="bi bi-person-x text-warning"></i> ' + esc(u.title || ('Sag #' + (u.id || ''))) + ' <button class="btn btn-sm btn-outline-primary mt-2" data-bb-open-case="' + Number(u.id || 0) + '">Åbn sag</button></div>') : ['Ingen åbne sager uden ansvarlig.'];
let out = [];
if (urgent.count > 0) out.push('<div><i class="bi bi-exclamation-octagon text-danger"></i> Hastesager: <strong>' + urgent.count + '</strong> aktive</div>');
if (mail.unread > 0) out.push('<div><i class="bi bi-envelope text-primary"></i> Ubesvarede mails: <strong>' + mail.unread + '</strong></div>');
if (cases.open > 0) out.push('<div><i class="bi bi-folder2-open text-primary"></i> Åbne sager i alt: <strong>' + cases.open + '</strong></div>');
if (drift.down > 0) out.push('<div><i class="bi bi-activity text-warning"></i> Drift nedetid: <strong>' + drift.down + '</strong> enheder</div>');
if (eset.incidents > 0) out.push('<div><i class="bi bi-shield-lock text-danger"></i> ESET incidents: <strong>' + eset.incidents + '</strong></div>');
if (out.length === 0) {
out.push('<div>🎉 Alt ser grønt ud! Intet kritisk lige nu.</div>');
}
const contextActions = (latestContextActions.context || []);
const globalActions = (latestContextActions.global || []);
const smartActions = contextActions.length ? contextActions : globalActions;
if (smartActions.length) {
out.push('<div class="mt-3 pt-3 border-top"><div class="small fw-semibold text-muted mb-2">Smarte handlinger</div><div class="d-flex flex-wrap gap-2">' + smartActions.map(function (action) {
return '<button type="button" class="btn btn-sm btn-outline-primary" data-bb-create="' + esc(action.id) + '"><i class="bi ' + esc(action.icon || 'bi-lightning-charge') + ' me-1"></i>' + esc(action.label) + '</button>';
}).join('') + '</div></div>');
}
// Add quick note button on overview
out.push('<div class="mt-3 pt-3 border-top"><div class="input-group"><input type="text" id="bbQuickNoteInput" class="form-control form-control-sm" placeholder="Skriv en quick note..." value="' + quickNoteValue + '"><button class="btn btn-outline-secondary btn-sm" id="bbQuickNoteSaveBtn"><i class="bi bi-pencil"></i> Gem note</button></div><div id="bbQuickNoteHint" class="small text-muted mt-2">' + esc(quickNoteHintState.message || 'Tip: gemmer som kommentar på aktiv/åben sag.') + '</div></div>');
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 '<div class="d-flex justify-content-between align-items-center gap-3 bb-timer-case-link" data-bb-case-link="' + Number(t.sag_id || 0) + '" role="link" tabindex="0"><div class="min-w-0"><div class="fw-semibold text-truncate"><span class="bb-live-indicator"></span>' + esc(t.desc) + '</div><div class="small text-muted mt-1">Aktiv tid · <span class="font-monospace fw-semibold" data-bb-live-elapsed="' + Number(t.id || t.time_entry_id || 0) + '">' + esc(elapsedText) + '</span></div></div><div class="d-flex gap-2"><button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + Number(t.sag_id || 0) + '">Åbn sag</button><button class="btn btn-sm btn-danger" data-bb-stop-time="' + Number(t.id || t.time_entry_id || 0) + '"><i class="bi bi-stop-fill me-1"></i>Stop</button></div></div>';
});
}
return ['<div class="bb-panel-empty"><div><i class="bi bi-stopwatch"></i><strong>Ingen aktiv timer</strong><div class="small mt-1">Brug “Skift sag” i bundlinjen for at starte arbejdet.</div></div></div>'];
}
if (key === 'messages') {
const activeThread = ensureActiveMessageThread();
syncChatRecipientToActiveThread(activeThread);
const messageItems = activeThread && Array.isArray(activeThread.items) ? activeThread.items : [];
if (messageItems.length > 0) return messageItems.map(window.BmcMessageUI.message);
return ['<div class="msg-empty"><span><i class="bi bi-chat-square-text"></i></span><strong>Her starter samtalen</strong><p>Send en kort besked eller giv en telefonbesked videre.</p></div>'];
}
if (key === 'tasks') {
if (tasks.count > 0) {
return (tasks.list || []).map(t => '<div class="d-flex align-items-center gap-2 min-w-0"><span class="rounded-circle bg-success-subtle text-success p-2"><i class="bi bi-check2"></i></span><div class="min-w-0"><strong class="d-block text-truncate">' + esc(t.title) + '</strong><span class="small text-muted">Prioritet: ' + esc(t.deadline) + '</span></div></div><span class="badge rounded-pill text-bg-light border">Aktuel</span>');
}
return ['<div class="bb-panel-empty"><div><i class="bi bi-check2-circle"></i><strong>Du er ajour</strong><div class="small mt-1">Der er ingen aktuelle opgaver eller påmindelser.</div></div></div>'];
}
if (key === 'notes') {
const noteItems = Array.isArray(notes.list) ? notes.list : [];
const editorTitle = esc(noteEditorState.title || '');
const editorContent = esc(noteEditorState.content || '');
const editingId = Number(noteEditorState.editingId || 0);
const out = [
'<div class="border rounded p-2 mb-3 bg-body-tertiary">' +
'<div class="small text-muted mb-2">Egne noter (vises i bundbar)</div>' +
'<input type="text" id="bbNoteTitleInput" class="form-control form-control-sm mb-2" placeholder="Titel (valgfri)" value="' + editorTitle + '">' +
'<textarea id="bbNoteContentInput" class="form-control form-control-sm" rows="4" placeholder="Skriv note...">' + editorContent + '</textarea>' +
'<div class="d-flex gap-2 mt-2">' +
'<button class="btn btn-sm btn-primary" id="bbNoteSaveBtn" data-note-edit-id="' + editingId + '"><i class="bi bi-save me-1"></i>' + (editingId > 0 ? 'Gem ændringer' : 'Opret note') + '</button>' +
'<button class="btn btn-sm btn-outline-secondary" id="bbNoteClearBtn"><i class="bi bi-x-circle me-1"></i>Ryd</button>' +
'</div>' +
'</div>'
];
if (!noteItems.length) {
out.push('<div class="text-muted">Ingen noter endnu.</div>');
return out;
}
noteItems.forEach(function (note) {
const noteId = Number(note.id || 0);
const noteTitle = esc((note.title || '').trim() || ('Note #' + noteId));
const content = String(note.content || '');
const preview = esc(content.length > 220 ? (content.slice(0, 220) + '...') : content);
const pinned = !!note.is_pinned;
out.push(
'<div class="border rounded p-2 mb-2">' +
'<div class="d-flex justify-content-between align-items-start gap-2">' +
'<div><strong>' + noteTitle + '</strong>' + (pinned ? ' <span class="badge text-bg-warning">Pinned</span>' : '') + '</div>' +
'<div class="btn-group btn-group-sm">' +
'<button class="btn btn-outline-secondary" data-note-edit="' + noteId + '"><i class="bi bi-pencil"></i></button>' +
'<button class="btn btn-outline-secondary" data-note-pin="' + noteId + '"><i class="bi bi-pin-angle' + (pinned ? '-fill' : '') + '"></i></button>' +
'<button class="btn btn-outline-danger" data-note-delete="' + noteId + '"><i class="bi bi-trash"></i></button>' +
'</div>' +
'</div>' +
'<div class="small text-muted mt-2" style="white-space: pre-wrap;">' + preview + '</div>' +
'<div class="d-flex flex-wrap gap-2 mt-2">' +
'<button class="btn btn-sm btn-outline-primary" data-note-to-case="' + noteId + '"><i class="bi bi-chat-left-text me-1"></i>Til sag-kommentar</button>' +
'<button class="btn btn-sm btn-outline-primary" data-note-to-contact="' + noteId + '"><i class="bi bi-person-lines-fill me-1"></i>Til kontakt</button>' +
'<button class="btn btn-sm btn-outline-primary" data-note-to-customer="' + noteId + '"><i class="bi bi-building me-1"></i>Til firma</button>' +
'</div>' +
'</div>'
);
});
return out;
}
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 '<option value="' + Number(tech.user_id || 0) + '">' + esc(tech.owner_name || 'Tekniker') + ' · ' + Number(tech.open_cases || 0) + ' åbne</option>';
}).join('');
const out = [
'<div class="bb-boss-hero">' +
'<div><div class="bb-boss-eyebrow"><i class="bi bi-headset me-1"></i>Support-ledelse</div><div class="bb-boss-heading">Fordel køen, før kunderne venter</div></div>' +
'<button class="btn btn-sm btn-primary" data-boss-action="auto_assign_next"><i class="bi bi-magic me-1"></i>Fordel næste</button>' +
'</div>',
'<div class="row g-2 bb-boss-kpis">' +
'<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Åbne sager</div><div class="fw-bold">' + Number(stats.open_cases || 0) + '</div></div></div>' +
'<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Hastesager</div><div class="fw-bold text-danger">' + Number(stats.urgent_cases || 0) + '</div></div></div>' +
'<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Support uden ejer</div><div class="fw-bold text-warning">' + Number(stats.support_unassigned || 0) + '</div></div></div>' +
'<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Stale >24t</div><div class="fw-bold text-danger">' + Number(stats.stale_urgent_cases || 0) + '</div></div></div>' +
'</div>',
'<div class="d-flex gap-2 flex-wrap mt-2">' +
'<button class="btn btn-sm btn-outline-primary" data-boss-action="open_unassigned"><i class="bi bi-person-x me-1"></i>Alle ufordelte</button>' +
'<button class="btn btn-sm btn-outline-danger" data-boss-action="open_escalations"><i class="bi bi-exclamation-octagon me-1"></i>Se eskaleringer</button>' +
'<button class="btn btn-sm btn-outline-secondary" data-boss-action="open_team"><i class="bi bi-people me-1"></i>Team-overblik</button>' +
'</div>'
];
if (workload.length > 0) {
out.push('<div class="small text-muted mt-3 mb-1">Team-belastning</div>');
workload.slice(0, 5).forEach(function (w) {
out.push(
'<div class="d-flex justify-content-between align-items-center border rounded p-2">' +
'<div><strong>' + esc(w.owner_name || 'Ukendt') + '</strong><div class="small text-muted">Åbne: ' + Number(w.open_cases || 0) + ' • Haste: ' + Number(w.urgent_cases || 0) + '</div></div>' +
'<button class="btn btn-sm btn-outline-primary" data-boss-action="open_owner" data-owner-id="' + Number(w.user_id || 0) + '">Åbn</button>' +
'</div>'
);
});
}
if (techniciansToday.length > 0) {
out.push('<div class="small text-muted mt-3 mb-1">Teknikernes opgaver i dag</div>');
techniciansToday.slice(0, 6).forEach(function (tech) {
const todayTasks = Array.isArray(tech.today_tasks) ? tech.today_tasks : [];
let tasksHtml = '<div class="small text-muted mt-1">Ingen opgaver i dag.</div>';
if (todayTasks.length > 0) {
tasksHtml = '<div class="small mt-1">' + todayTasks.slice(0, 3).map(function (task) {
return '<div><i class="bi bi-dot"></i> ' + esc(task.title || ('Sag #' + task.id)) + '</div>';
}).join('') + '</div>';
}
out.push(
'<div class="border rounded p-2">' +
'<div class="d-flex justify-content-between align-items-center">' +
'<div><strong>' + esc(tech.owner_name || 'Tekniker') + '</strong><div class="small text-muted">I dag: ' + Number(tech.due_today_cases || 0) + ' • Åbne: ' + Number(tech.open_cases || 0) + '</div></div>' +
'<div class="d-flex gap-1">' +
'<button class="btn btn-sm btn-outline-primary" data-boss-action="open_owner" data-owner-id="' + Number(tech.user_id || 0) + '">Vis</button>' +
'<button class="btn btn-sm btn-primary" data-boss-action="assign_next_to_owner" data-owner-id="' + Number(tech.user_id || 0) + '">Tildel næste</button>' +
'</div>' +
'</div>' +
tasksHtml +
'</div>'
);
});
}
if (escalations.length > 0) {
out.push('<div class="small text-muted mt-3 mb-1">Eskaleringer</div>');
escalations.slice(0, 4).forEach(function (c) {
const ageHours = Math.floor(Number(c.age_seconds || 0) / 3600);
out.push(
'<div class="d-flex justify-content-between align-items-center border rounded p-2">' +
'<div><strong>' + esc(c.title || 'Sag') + '</strong><div class="small text-muted">' + esc(c.owner_name || 'Ikke tildelt') + ' • ' + ageHours + 't siden opdatering</div></div>' +
'<button class="btn btn-sm btn-outline-danger" data-boss-action="open_case" data-case-id="' + Number(c.id || 0) + '">Åbn</button>' +
'</div>'
);
});
}
if (unassigned.length > 0) {
out.push('<div class="d-flex align-items-center justify-content-between mt-3 mb-1"><div class="small text-muted">Supportkø uden ansvarlig</div><span class="badge text-bg-warning">' + Number(stats.support_unassigned || 0) + '</span></div>');
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(
'<div class="bb-support-queue-card">' +
'<div class="bb-support-queue-main"><strong>' + esc(c.title || 'Sag') + '</strong><div class="small text-muted">' + esc(c.customer_name || 'Ingen kunde') + ' · ' + ageLabel + ' · ' + esc(c.priority || 'normal') + '</div></div>' +
'<div class="bb-support-queue-actions">' +
'<select class="form-select form-select-sm" data-boss-assignee-for="' + Number(c.id || 0) + '"><option value="">Vælg medarbejder…</option>' + technicianOptions + '</select>' +
'<button class="btn btn-sm btn-primary" data-boss-action="assign_case_to_owner" data-case-id="' + Number(c.id || 0) + '"' + (technicianOptions ? '' : ' disabled') + '>Tildel</button>' +
'<button class="btn btn-sm btn-outline-secondary" data-boss-action="open_case" data-case-id="' + Number(c.id || 0) + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i></button>' +
'</div>' +
'</div>'
);
});
} else {
out.push('<div class="bb-boss-empty mt-3"><i class="bi bi-check-circle-fill"></i> Supportkøen er fordelt.</div>');
}
return out;
}
return ['Klik rundt i menuen for at se data.'];
}
function updateBar(sections) {
const counts = getCounts(sections);
const keys = Object.keys(counts);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
const chipText = document.querySelector('.bb-chip[data-bb-key="' + key + '"] .bb-chip-text');
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',
procurement: 'Skal bestilles',
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 = '<i class="bi bi-eye me-1 text-accent"></i> ' + detailTextFor(key, latestSections);
});
chip.addEventListener('mouseleave', function () {
detail.innerHTML = '<i class="bi bi-info-circle me-1 opacity-75"></i> Klik på en kategori for at se detaljer';
});
});
}
function updateActivityZone() {
const timerChip = byId('bbActiveTimerChip');
const timerText = byId('bbActiveTimerText');
const notifCount = byId('bbNotificationsCount');
const pauseBtn = byId('bbTimerPauseBtn');
const stopBtn = byId('bbTimerStopBtn');
const timer = ((latestSections || {}).timer || {}).active || {};
const ownTimers = ((latestSections || {}).timer || {}).own || {};
const pausedTimers = Array.isArray(ownTimers.paused) ? ownTimers.paused : [];
const pausedTimer = pausedTimers[0] || null;
const hasPausedTimer = !!pausedTimer;
const hasActiveTimer = !!timer.active;
if (timerChip && timerText) {
timerChip.classList.toggle('is-hidden', !hasActiveTimer && !hasPausedTimer);
timerChip.classList.toggle('is-paused', !hasActiveTimer && hasPausedTimer);
if (hasActiveTimer) {
const elapsed = timer.elapsed_hhmmss || '00:00:00';
const name = timer.sag_navn || ('Sag #' + (timer.sag_id || ''));
timerText.textContent = name + ' - ' + elapsed;
timerChip.title = 'Aktiv timer på ' + name;
} else if (hasPausedTimer) {
const name = pausedTimer.sag_navn || ('Sag #' + (pausedTimer.sag_id || ''));
const elapsed = pausedTimer.elapsed_hhmmss || '00:00:00';
timerText.textContent = 'Pauset · ' + name + ' · ' + elapsed;
timerChip.title = 'Pauset timer på ' + name + ' klik for at åbne sagen';
}
}
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;
const pausedName = hasPausedTimer ? (pausedTimer.sag_navn || ('Sag #' + (pausedTimer.sag_id || ''))) : '';
pauseBtn.title = hasActiveTimer ? 'Pause timer' : (hasPausedTimer ? 'Genoptag ' + pausedName : 'Ingen timer at pause');
pauseBtn.innerHTML = hasActiveTimer ? '<i class="bi bi-pause-fill"></i>' : '<i class="bi bi-play-fill"></i>';
}
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;
const previousMessages = innerContent.querySelector('.bb-messages-list');
const previousScroll = previousMessages ? {key:previousMessages.dataset.threadKey, top:previousMessages.scrollTop, bottom:previousMessages.scrollHeight-previousMessages.scrollTop-previousMessages.clientHeight < 40} : 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',
locations: 'Lokationer',
boss: 'Sagsfordeling'
};
const iconByKey = {
overview: 'bi-bell',
timer: 'bi-stopwatch',
messages: 'bi-chat-dots',
tasks: 'bi-calendar-check',
notes: 'bi-journal-text',
locations: 'bi-geo-alt',
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.',
locations: 'Find og åbn aktive kundelokationer uden at forlade dit arbejde.',
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;
}
if (activeKey === 'locations') {
renderLocationsPanel(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 = '<button class="btn btn-primary btn-sm fw-bold" id="btnNextTask"><i class="bi bi-box-arrow-in-down-right me-1"></i>Giv mig næste opgave</button>';
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);
{
const threadList = document.createElement('div');
threadList.className = 'bb-message-threads';
threadList.innerHTML = '<div class="msg-inbox-heading"><span>Samtaler</span><button type="button" title="Ny besked" aria-label="Ny besked" onclick="window.openInternalMessage()"><i class="bi bi-pencil-square"></i></button></div>';
if (!threadItems.length) threadList.insertAdjacentHTML('beforeend', '<p class="small text-muted px-2">Dine samtaler vises her.</p>');
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.setAttribute('aria-current', activeThread && thread.key === activeThread.key ? 'true' : 'false');
button.innerHTML = window.BmcMessageUI.thread(thread);
threadList.appendChild(button);
});
chatContainer.appendChild(threadList);
}
const replyBox = document.createElement('div');
replyBox.className = 'bb-messages-composer';
const conversation = document.createElement('div');
conversation.className = 'msg-conversation';
conversation.innerHTML = '<header class="msg-conversation-header"><div><strong>' + escapeHtml(activeThread?.label || 'Vælg en samtale') + '</strong><small>' + (!activeThread ? 'Brug blyanten til at skrive til en kollega' : activeThread.partnerUserId ? 'Intern samtale' : 'Fælles beskeder til alle på vagt') + '</small></div><button type="button" class="msg-header-action" onclick="window.openInternalMessage({kind:\'phone\',recipient:' + Number(activeThread?.partnerUserId || 0) + '})"><i class="bi bi-telephone-plus"></i><span>Telefonbesked</span></button></header>';
const threadMeta = activeThread
? '<div class="small text-muted mb-2"><i class="bi bi-chat-square-text me-1"></i>'
+ (activeThread.partnerUserId ? ('Samtale med ' + escapeHtml(activeThread.label || 'Bruger')) : 'Besked til alle på vagt')
+ '</div>'
: '';
const replyBanner = chatComposerState.replyToUserId
? `<div class="small d-flex justify-content-between align-items-center mb-2">
<span><i class="bi bi-reply me-1"></i>Svarer til ${escapeHtml(chatComposerState.replyToName || 'Bruger')}</span>
<button type="button" class="btn btn-sm btn-link p-0 text-decoration-none" id="bbCancelReplyBtn">Annuller</button>
</div>`
: '';
replyBox.innerHTML = `
${replyBanner}
<select id="chatRecipient" hidden aria-label="Modtager">
<option value="all">Alle på vagt</option>
</select>
<div class="msg-compose-field">
<textarea id="chatInputQuick" rows="2" maxlength="2000" aria-label="Besked" ${!activeThread?'disabled':''} placeholder="${activeThread?'Skriv til '+escapeHtml(activeThread.label)+'…':'Vælg en samtale eller opret en ny besked'}">${escapeHtml(chatComposerState.draft || '')}</textarea>
<button type="button" class="msg-send" id="btnSendMsg" aria-label="Send besked" ${!activeThread?'disabled':''}><i class="bi bi-arrow-up"></i></button>
</div>
<div class="msg-compose-footer"><label><input type="checkbox" id="chatRequiresAck" ${chatComposerState.requiresManualAck ? 'checked' : ''}> Bed om læsebekræftelse</label><small>Ctrl / ⌘ + Enter for at sende</small></div>
`;
conversation.appendChild(ul);
conversation.appendChild(replyBox);
chatContainer.appendChild(conversation);
innerContent.appendChild(chatContainer);
ul.dataset.threadKey = activeThread?.key || '';
window.requestAnimationFrame(function () { ul.scrollTop = previousScroll && previousScroll.key === ul.dataset.threadKey && !previousScroll.bottom ? previousScroll.top : ul.scrollHeight; });
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('keydown', function (event) {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) { event.preventDefault(); event.stopPropagation(); document.getElementById('btnSendMsg')?.click(); }
});
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 = '<div class="btn-group btn-group-sm" role="group" aria-label="Timervisning">' +
'<button class="btn ' + (scope === 'mine' ? 'btn-primary' : 'btn-outline-secondary') + '" data-bb-timer-scope="mine">Mine timere</button>' +
'<button class="btn ' + (scope === 'all' ? 'btn-primary' : 'btn-outline-secondary') + '" data-bb-timer-scope="all">Alle medarbejdere</button></div>' +
'<span class="small text-muted">' + timerPanelState.rows.length + ' vist</span>';
container.appendChild(toolbar);
if (timerPanelState.loading || !timerPanelState.loaded) {
const loading = document.createElement('div');
loading.className = 'bb-panel-empty';
loading.innerHTML = '<div><span class="spinner-border spinner-border-sm me-2"></span>Henter timere...</div>';
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 = '<span><i class="bi bi-exclamation-triangle me-2"></i>' + escapeHtml(timerPanelState.error) + '</span>' +
'<button class="btn btn-sm btn-outline-dark" data-bb-timer-scope="' + scope + '">Prøv igen</button>';
container.appendChild(errorBox);
}
const list = document.createElement('div');
list.className = 'bb-timer-work-grid';
if (!timerPanelState.rows.length) {
list.innerHTML = '<div class="bb-panel-empty"><div><i class="bi bi-stopwatch"></i><strong>Ingen timere at vise</strong></div></div>';
} 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 = '<button class="btn btn-sm btn-primary" data-bb-resume-time="' + timeId + '"><i class="bi bi-play-fill me-1"></i>Genoptag</button>';
if (isOwn && state === 'pending_conversion') action = '<button class="btn btn-sm btn-primary" data-bb-convert-time="' + timeId + '"><i class="bi bi-arrow-repeat me-1"></i>Konverter</button>';
if (isOwn && state === 'active') action = '<button class="btn btn-sm btn-danger" data-bb-stop-time="' + timeId + '"><i class="bi bi-stop-fill me-1"></i>Stop</button>';
const elapsedValue = row.elapsed_hhmmss || formatTimerSeconds(row.live_elapsed_seconds || row.elapsed_seconds || row.elapsed);
const elapsed = state === 'active' ? '<span class="font-monospace" data-bb-live-elapsed="' + timeId + '">' + escapeHtml(elapsedValue) + '</span> · ' : '';
const employeeName = row.employee_display_name || row.medarbejder_navn || row.user_name || (isOwn ? 'Min timer' : 'Ukendt medarbejder');
return '<div class="bb-timer-work-card" data-bb-case-link="' + sagId + '" role="link" tabindex="0"><div class="bb-timer-work-main"><span class="badge text-bg-' + label[1] + '">' + label[0] + '</span><strong class="bb-timer-work-title">' + escapeHtml(row.sag_navn || ('Sag #' + sagId)) + '</strong><span class="bb-timer-work-meta">Sag #' + sagId + '</span><span class="bb-timer-work-meta">' + elapsed + escapeHtml(employeeName) + '</span></div><div class="bb-timer-work-actions"><button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + sagId + '" title="Åbn sag" aria-label="Åbn sag #' + sagId + '"><i class="bi bi-box-arrow-up-right"></i></button>' + action + '</div></div>';
}).join('');
}
container.appendChild(list);
}
function locationTypeLabel(value) {
const labels = {
kompleks: 'Kompleks', bygning: 'Bygning', etage: 'Etage',
customer_site: 'Kundelokation', rum: 'Rum', kantine: 'Kantine',
moedelokale: 'Mødelokale', vehicle: 'Køretøj'
};
return labels[String(value || '').toLowerCase()] || 'Lokation';
}
async function loadLocationsPanel(force) {
if (locationsPanelState.loading || (locationsPanelState.loaded && !force)) return;
locationsPanelState.loading = true;
locationsPanelState.error = '';
try {
const response = await fetch('/api/v1/locations?is_active=true&limit=100', { credentials: 'include' });
if (!response.ok) throw new Error('Kunne ikke hente lokationer');
const payload = await response.json();
locationsPanelState.rows = Array.isArray(payload) ? payload : [];
locationsPanelState.loaded = true;
} catch (err) {
locationsPanelState.error = err && err.message ? err.message : 'Kunne ikke hente lokationer';
} finally {
locationsPanelState.loading = false;
if (activeKey === 'locations') renderTabPanel();
}
}
function renderLocationsPanel(container) {
const query = String(locationsPanelState.query || '').trim().toLowerCase();
const rows = locationsPanelState.rows.filter(function (row) {
if (!query) return true;
return [row.name, row.customer_name, row.address_street, row.address_city, row.location_type]
.some(function (value) { return String(value || '').toLowerCase().includes(query); });
});
const toolbar = document.createElement('div');
toolbar.className = 'd-flex gap-2 align-items-center mb-3';
toolbar.innerHTML = '<div class="input-group input-group-sm flex-grow-1">'
+ '<span class="input-group-text"><i class="bi bi-search"></i></span>'
+ '<input id="bbLocationsSearch" class="form-control" type="search" placeholder="Søg navn, kunde eller by" value="' + escapeHtml(locationsPanelState.query || '') + '">'
+ '</div><button type="button" class="btn btn-sm btn-outline-secondary" id="bbLocationsRefresh" title="Opdater"><i class="bi bi-arrow-clockwise"></i></button>'
+ '<a class="btn btn-sm btn-primary" href="/app/locations"><i class="bi bi-box-arrow-up-right me-1"></i>Åbn alle</a>';
container.appendChild(toolbar);
if (locationsPanelState.loading && !locationsPanelState.loaded) {
container.insertAdjacentHTML('beforeend', '<div class="bb-panel-empty"><div><i class="bi bi-geo-alt"></i>Henter lokationer...</div></div>');
} else if (locationsPanelState.error) {
container.insertAdjacentHTML('beforeend', '<div class="alert alert-warning mb-0"><i class="bi bi-exclamation-triangle me-1"></i>' + escapeHtml(locationsPanelState.error) + '</div>');
} else if (!rows.length) {
container.insertAdjacentHTML('beforeend', '<div class="bb-panel-empty"><div><i class="bi bi-geo-alt"></i><strong>Ingen lokationer fundet</strong><div class="small mt-1">Prøv en anden søgning eller åbn lokationsmodulet.</div></div></div>');
} else {
const list = document.createElement('div');
list.className = 'bb-location-list';
rows.slice(0, 40).forEach(function (row) {
const address = [row.address_street, [row.address_postal_code, row.address_city].filter(Boolean).join(' ')].filter(Boolean).join(', ');
const customer = row.customer_name ? '<span><i class="bi bi-building me-1"></i>' + escapeHtml(row.customer_name) + '</span>' : '';
const addressHtml = address ? '<span><i class="bi bi-geo me-1"></i>' + escapeHtml(address) + '</span>' : '';
const item = document.createElement('a');
item.className = 'bb-location-card';
item.href = '/app/locations/' + Number(row.id || 0);
item.innerHTML = '<div class="min-w-0"><div class="fw-semibold text-truncate">' + escapeHtml(row.name || 'Unavngivet lokation') + '</div>'
+ '<div class="small text-muted d-flex flex-wrap gap-2 mt-1"><span class="badge text-bg-light border">' + escapeHtml(locationTypeLabel(row.location_type)) + '</span>' + customer + addressHtml + '</div></div>'
+ '<i class="bi bi-chevron-right text-muted"></i>';
list.appendChild(item);
});
container.appendChild(list);
}
const input = container.querySelector('#bbLocationsSearch');
if (input) input.addEventListener('input', function () { locationsPanelState.query = input.value; renderTabPanel(); });
const refresh = container.querySelector('#bbLocationsRefresh');
if (refresh) refresh.addEventListener('click', function () { loadLocationsPanel(true); });
if (!locationsPanelState.loaded && !locationsPanelState.loading) loadLocationsPanel();
}
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');
try {
window.localStorage.setItem('bmc-bottom-bar-active-tab', activeKey);
} catch (_) {
// Storage may be disabled in private browsing.
}
renderTabPanel();
const detail = byId('bbCountDetail');
if (detail) {
detail.innerHTML = '<i class="bi bi-info-circle me-1 opacity-75"></i> Viser: ' + (activeKey.charAt(0).toUpperCase() + activeKey.slice(1));
}
});
}
}
function bindChipClicks() {
const chips = document.querySelectorAll('.bb-chip');
for (let i = 0; i < chips.length; i++) {
chips[i].addEventListener('click', function () {
const key = this.getAttribute('data-bb-key');
if (!key) return;
const detail = byId('bbCountDetail');
if (detail) {
detail.innerHTML = '<i class="bi bi-check-circle me-1 text-accent"></i> ' + detailTextFor(key, latestSections);
}
const routes = {
mail: '/emails',
urgent: '/sag?priority=urgent',
timer: '/timetracking',
cases: '/sag',
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;
const previousTimer = (((latestSections || {}).timer || {}).active || {});
let timerIdentityChanged = false;
if (payload.event === 'timer_tick') {
const timer = payload.data || {};
timerIdentityChanged = Boolean(previousTimer.active) !== Boolean(timer.active)
|| Number(previousTimer.time_entry_id || 0) !== Number(timer.time_entry_id || 0);
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();
if (payload.event === 'timer_tick' && !timerIdentityChanged) {
const timer = payload.data || {};
const elapsed = timer.elapsed_hhmmss || formatTimerSeconds(timer.elapsed || 0);
document.querySelectorAll('[data-bb-live-elapsed="' + Number(timer.time_entry_id || 0) + '"]').forEach(function (node) {
node.textContent = elapsed;
});
return;
}
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();
});
}
async function stopTimer(timeId) {
const safeTimeId = Number(timeId || 0);
if (!(safeTimeId > 0)) return {};
const response = await fetch('/api/v1/timetracking/time/stop', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ time_id: safeTimeId })
});
if (!response.ok) {
throw new Error(await readApiError(response, 'Kunne ikke stoppe timeren.'));
}
return response.json().catch(function () { return {}; });
}
function stopActiveTimer() {
const active = ((latestSections || {}).timer || {}).active || {};
if (!active.time_entry_id) {
return Promise.resolve();
}
return stopTimer(active.time_entry_id);
}
function notifyTimerStateChanged(action, payload) {
const detail = Object.assign({ action: action, changed_at: new Date().toISOString() }, payload || {});
window.dispatchEvent(new CustomEvent('bb:timer-state-changed', { detail: detail }));
try {
window.localStorage.setItem('bmc:timer-state-changed', JSON.stringify(detail));
} catch (error) {
console.debug('Could not broadcast timer state', error);
}
}
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) {
return readApiError(res, 'Kunne ikke pause timer.').then(function (message) { throw new Error(message); });
}
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) {
return readApiError(res, 'Kunne ikke genoptage timer.').then(function (message) { throw new Error(message); });
}
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 setTimeConversionMode(enabled) {
const modalEl = byId('bbSwitchCaseModal');
const titleEl = byId('bbSwitchCaseModalLabel');
const panel = byId('bbConvertTimePanel');
if (modalEl) modalEl.classList.toggle('is-converting', Boolean(enabled));
if (panel && !enabled) panel.classList.add('d-none');
if (titleEl) {
titleEl.innerHTML = enabled
? '<i class="bi bi-clock-history me-2"></i>Registrer afsluttet tid'
: '<i class="bi bi-arrow-left-right me-2"></i>Skift sag';
}
}
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 '<strong class="bb-switch-info-title">' + timerDisplayName(timer) + '</strong>' +
'<span class="bb-switch-info-meta">#' + sagId + '</span>' +
'<span class="bb-switch-info-meta"><i class="bi bi-building me-1"></i>' + escapeHtml(customer) + '</span>' +
'<span class="bb-switch-info-meta"><i class="bi bi-person me-1"></i>' + escapeHtml(contact) + '</span>' +
'<span class="badge rounded-pill text-bg-light border bb-switch-status">' + escapeHtml(status) + '</span>' +
(includeDuration ? '<span class="bb-switch-info-meta"><i class="bi bi-stopwatch me-1"></i>' + timerDurationLabel(timer) + '</span>' : '');
}
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 = '<div class="bb-switch-empty">Ingen tidligere timere at fortsætte.</div>';
} else {
let timerItems = '';
active.forEach(function (t) {
const timeId = Number((t && (t.id || t.time_entry_id)) || 0);
timerItems +=
'<div class="bb-switch-timer"><div class="bb-switch-timer-info"><span class="badge text-bg-success">Aktiv</span>' + timerCaseInfo(t, true) + '</div>' +
'<button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + Number((t && t.sag_id) || 0) + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i><span class="visually-hidden">Åbn sag</span></button></div>';
});
paused.forEach(function (t) {
const timeId = Number((t && (t.time_entry_id || t.id)) || 0);
timerItems +=
'<div class="bb-switch-timer"><div class="bb-switch-timer-info"><span class="badge text-bg-warning">Pauset</span>' + timerCaseInfo(t, true) + '</div>' +
'<div class="bb-switch-case-actions"><button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + Number((t && t.sag_id) || 0) + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i><span class="visually-hidden">Åbn sag</span></button>' +
'<button class="btn btn-sm btn-primary" data-bb-resume-time="' + timeId + '"><i class="bi bi-play-fill me-1"></i>Genoptag</button></div></div>';
});
stopped.forEach(function (t) {
const sagId = Number((t && t.sag_id) || 0);
const timeId = Number((t && (t.time_entry_id || t.id)) || 0);
timerItems +=
'<div class="bb-switch-timer bb-switch-timer-pending">' +
'<div class="bb-switch-timer-info">' + timerCaseInfo(t, true) + '</div>' +
'<div class="bb-switch-case-actions"><button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + sagId + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i></button>' +
'<button class="btn btn-sm btn-primary" data-bb-convert-time="' + timeId + '">Registrer</button></div></div>';
});
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
? '<i class="bi bi-person-x me-2"></i>Uden ansvarlig (åbne sager)'
: '<i class="bi bi-arrow-left-right me-2"></i>Skift sag';
}
if (!sourceCases.length) {
recentEl.innerHTML = '<div class="bb-switch-empty"><i class="bi bi-search me-1"></i>' + (query ? 'Ingen sager matcher din søgning.' : 'Ingen sager at vise.') + '</div>';
return;
}
recentEl.innerHTML = sourceCases.map(function (row) {
const caseId = Number((row && (row.sag_id || row.id)) || 0);
const title = escapeHtml((row && (row.titel || row.title)) || (caseId > 0 ? ('Sag #' + caseId) : 'Ukendt sag'));
const meta = showUnassigned ? '<span class="text-warning">Uden ansvarlig</span>' : 'Senest anvendt';
return (
'<div class="bb-switch-case">' +
'<div class="bb-switch-case-main"><div class="bb-switch-case-title">' + title + '</div><div class="bb-switch-case-meta">Sag #' + caseId + ' · ' + meta + '</div></div>' +
'<div class="bb-switch-case-actions">' +
'<button class="btn btn-sm btn-outline-secondary" data-bb-open-case="' + caseId + '" title="Åbn sag"><i class="bi bi-box-arrow-up-right"></i><span class="visually-hidden">Åbn sag</span></button>' +
'<button class="btn btn-sm btn-primary" data-bb-start-case="' + caseId + '"><i class="bi bi-play-fill me-1"></i>Start</button>' +
'</div>' +
'</div>'
);
}).join('');
}
async function openTimeConversion(timeId) {
let timer = (switchCaseState.timers.stopped || []).concat(timerPanelState.rows || []).find(function (row) {
return Number((row && (row.time_entry_id || row.id)) || 0) === Number(timeId);
});
if (!timer) {
try {
const timers = await fetchSwitchableTimers();
switchCaseState.timers = timers;
timer = (timers.stopped || []).find(function (row) {
return Number((row && (row.time_entry_id || row.id)) || 0) === Number(timeId);
});
} catch (_) {
// A useful error is shown below when the timer is still absent.
}
}
const modal = getSwitchCaseModal();
if (!modal) {
window.location.href = '/timetracking/registrations';
return;
}
setTimeConversionMode(true);
modal.show();
if (!timer) {
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>Timeren kunne ikke findes. Opdatér oversigten og prøv igen.');
return;
}
const panel = byId('bbConvertTimePanel');
const select = byId('bbConvertBillingMethod');
if (!panel || !select) {
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>Konverteringsformularen kunne ikke åbnes.');
return;
}
panel.dataset.timeId = String(timeId);
byId('bbConvertTimeName').innerHTML = timerCaseInfo(timer, true);
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 = '<option>Henter muligheder...</option>';
panel.classList.remove('d-none');
switchCaseStatusMessage('<i class="bi bi-arrow-repeat me-1 text-primary"></i>Registrér den afsluttede timer som faktura, klippekort, abonnement eller intern tid.');
window.requestAnimationFrame(function () {
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
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 = '<option value="invoice">Faktura</option>';
(options.prepaid_cards || []).forEach(function (card) {
html += '<option value="prepaid:' + Number(card.id) + '">Klippekort #' + escapeHtml(card.card_number || card.id) + ' (' + Number(card.remaining_hours || 0).toFixed(2) + ' t tilbage)</option>';
});
(options.agreements || []).forEach(function (agreement) {
html += '<option value="subscription:' + Number(agreement.id) + '">Abonnement #' + escapeHtml(agreement.agreement_number || agreement.id) + '</option>';
});
html += '<option value="internal">Intern tid</option><option value="non_billable">Ikke fakturerbar</option>';
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 = '<option value="invoice">Faktura</option><option value="internal">Intern tid</option>';
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');
if (activeKey === 'timer') await loadTimerWorkQueue(timerPanelState.scope);
setTimeConversionMode(false);
const modal = getSwitchCaseModal();
if (modal) modal.hide();
window.dispatchEvent(new CustomEvent('bb:time-converted', { detail: { timeId: timeId } }));
} catch (err) {
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + 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('<i class="bi bi-info-circle me-1"></i>Viser kun åbne sager uden ansvarlig.');
renderSwitchCaseLists();
return;
}
switchCaseStatusMessage('<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Henter timer og seneste sager...');
const results = await Promise.allSettled([fetchSwitchableTimers(), fetchRecentCases()]);
const timersResult = results[0];
const casesResult = results[1];
if (timersResult.status === 'fulfilled') {
switchCaseState.timers = timersResult.value;
}
if (casesResult.status === 'fulfilled') {
switchCaseState.recentCases = casesResult.value;
}
const failedParts = [];
if (timersResult.status === 'rejected') failedParts.push('timere');
if (casesResult.status === 'rejected') failedParts.push('seneste sager');
if (failedParts.length) {
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-warning"></i>Kunne ikke hente ' + escapeHtml(failedParts.join(' + ')) + '. Viser det vi har.');
} else if (switchCaseState.activeTimer) {
const name = timerDisplayName(switchCaseState.activeTimer);
switchCaseStatusMessage('<i class="bi bi-stopwatch me-1"></i>Aktiv timer: ' + name + '. Vælg handling før du starter en ny timer.');
} else {
switchCaseStatusMessage('<i class="bi bi-check-circle me-1 text-success"></i>Klar til skift af sag.');
}
renderSwitchCaseLists();
}
async function openSwitchCaseModal(options) {
const modal = getSwitchCaseModal();
if (!modal) {
window.location.href = '/sag';
return;
}
setTimeConversionMode(false);
modal.show();
try {
await loadSwitchCaseData(options);
} catch (err) {
console.warn('Switch case modal load failed', err);
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>Kunne ikke hente data til skift af sag.');
renderSwitchCaseLists();
}
}
window.openBottomBarTimeConversion = openTimeConversion;
function openUnassignedCasesPanel() {
window.location.href = '/sag?unassigned=1';
}
async function startTimerForCase(caseId) {
const validCaseId = Number(caseId || 0);
if (validCaseId <= 0) {
return;
}
const hasActiveTimer = !!(switchCaseState.activeTimer && switchCaseState.activeTimer.active);
if (hasActiveTimer && switchCaseState.decision === 'unchanged') {
switchCaseStatusMessage('<i class="bi bi-info-circle me-1 text-warning"></i>Du har en aktiv timer. Vælg Pause nu, Stop nu eller Fortsæt uændret først.');
return;
}
try {
const response = await fetch('/api/v1/timetracking/time/start', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sag_id: validCaseId })
});
if (!response.ok) {
const payload = await response.json().catch(function () { return {}; });
const detail = (payload && payload.detail) ? payload.detail : ('HTTP ' + response.status);
throw new Error(typeof detail === 'string' ? detail : 'Kunne ikke starte timer');
}
const modal = getSwitchCaseModal();
if (modal) {
modal.hide();
}
window.location.href = '/sag/' + validCaseId + '/v3';
} catch (err) {
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke starte timer for sag.'));
}
}
function openCaseDetail(caseId) {
const validCaseId = Number(caseId || 0);
if (validCaseId <= 0) {
return;
}
const modal = getSwitchCaseModal();
if (modal) {
modal.hide();
}
window.location.href = '/sag/' + validCaseId + '/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 '<option value="' + escapeHtml(item.value) + '">' + escapeHtml(item.label) + '</option>';
}).join('');
}
function openNoteTargetModal(target, noteId) {
const modal = getNoteTargetModal();
if (!modal) {
return;
}
const note = noteById(noteId);
noteTargetState = {
target: String(target || 'case'),
noteId: Number(noteId || 0)
};
const titleEl = byId('bbNoteTargetModalLabel');
const idLabel = byId('bbNoteTargetIdLabel');
const idInput = byId('bbNoteTargetIdInput');
const textInput = byId('bbNoteTargetTextInput');
const submitBtn = byId('bbNoteTargetSubmitBtn');
if (titleEl) {
const targetTitle = noteTargetState.target === 'contact'
? 'kontakt'
: (noteTargetState.target === 'customer' ? 'firma' : 'sag-kommentar');
titleEl.innerHTML = '<i class="bi bi-journal-plus me-2"></i>Indsæt note i ' + escapeHtml(targetTitle);
}
if (idLabel) {
if (noteTargetState.target === 'contact') {
idLabel.textContent = 'Kontakt ID';
} else if (noteTargetState.target === 'customer') {
idLabel.textContent = 'Firma ID';
} else {
idLabel.textContent = 'Sag ID';
}
}
if (idInput) {
const defaultCaseId = resolveQuickNoteCaseId();
const defaultId = noteTargetState.target === 'case' && defaultCaseId > 0 ? defaultCaseId : 0;
idInput.value = defaultId > 0 ? String(defaultId) : '';
}
if (textInput) {
textInput.value = String((note && note.content) || '');
}
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.dataset.target = noteTargetState.target;
submitBtn.dataset.noteId = String(noteTargetState.noteId);
}
renderNoteTargetFieldOptions(noteTargetState.target);
updateNoteTargetStatus('Vælg mål og indsæt tekst.', null);
modal.show();
}
function updateQuickNoteHint(message, isError) {
const hint = byId('bbQuickNoteHint');
quickNoteHintState.message = message;
quickNoteHintState.level = isError ? 'danger' : 'success';
if (!hint) {
return;
}
hint.textContent = message;
hint.classList.remove('text-muted');
hint.classList.toggle('text-danger', !!isError);
hint.classList.toggle('text-success', !isError);
}
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) {
pauseBtn.disabled = true;
pauseActiveTimer()
.then(fetchBottomBarState)
.then(applyState)
.then(function () {
notifyTimerStateChanged('paused');
const detail = byId('bbCountDetail');
if (detail) detail.innerHTML = '<i class="bi bi-pause-circle me-1 text-success"></i>Timer sat på pause.';
})
.catch(function (err) {
console.warn('Failed pausing timer', err);
const detail = byId('bbCountDetail');
if (detail) detail.innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke pause timer.');
})
.finally(function () {
updateActivityZone();
});
return;
}
const pausedTimeId = Number((((paused[0] || {}).time_entry_id) || ((paused[0] || {}).id) || 0));
pauseBtn.disabled = true;
resumeTimer(pausedTimeId || null)
.then(fetchBottomBarState)
.then(applyState)
.then(function () {
notifyTimerStateChanged('resumed', { time_id: pausedTimeId || null });
const detail = byId('bbCountDetail');
if (detail) detail.innerHTML = '<i class="bi bi-play-circle me-1 text-success"></i>Timer genoptaget.';
})
.catch(function (err) {
console.warn('Failed resuming timer', err);
const detail = byId('bbCountDetail');
if (detail) detail.innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke genoptage timer.');
})
.finally(function () {
updateActivityZone();
});
});
}
if (stopBtn) {
stopBtn.addEventListener('click', function () {
stopActiveTimer()
.then(fetchBottomBarState)
.then(applyState)
.then(function () { notifyTimerStateChanged('stopped'); })
.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 own = (((latestSections || {}).timer || {}).own || {});
const paused = Array.isArray(own.paused) ? own.paused : [];
const visibleTimer = timer.active ? timer : (paused[0] || {});
const sagId = Number(visibleTimer.sag_id || 0);
window.location.href = sagId > 0 ? ('/sag/' + sagId + '/v3') : '/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('keydown', function (e) {
if (e.key !== 'Enter' && e.key !== ' ') return;
const caseLink = e.target && e.target.closest('[data-bb-case-link][role="link"]');
if (!caseLink || e.target.closest('button, a, input, select, textarea')) return;
const sagId = Number(caseLink.getAttribute('data-bb-case-link') || 0);
if (sagId > 0) {
e.preventDefault();
openCaseDetail(sagId);
}
});
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 = '<i class="bi bi-exclamation-triangle me-1 text-warning"></i> Noten er tom.';
}
return;
}
const editId = Number(btn.getAttribute('data-note-edit-id') || 0);
const action = editId > 0
? updateUserNote(editId, { title: title, content: content })
: createUserNote(title, content);
action
.then(fetchBottomBarState)
.then(function (data) {
noteEditorState = { editingId: 0, title: '', content: '' };
applyState(data);
const detail = byId('bbCountDetail');
if (detail) {
detail.innerHTML = '<i class="bi bi-check-circle me-1 text-success"></i> Note gemt.';
}
})
.catch(function (err) {
console.warn('Failed saving note', err);
const detail = byId('bbCountDetail');
if (detail) {
detail.innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-danger"></i> ' + escapeHtml((err && err.message) ? err.message : 'Kunne ikke gemme note.');
}
});
return;
}
const noteEditId = Number(btn.getAttribute('data-note-edit') || 0);
if (noteEditId > 0) {
const note = noteById(noteEditId);
noteEditorState = {
editingId: noteEditId,
title: String((note && note.title) || ''),
content: String((note && note.content) || '')
};
renderTabPanel();
return;
}
const notePinId = Number(btn.getAttribute('data-note-pin') || 0);
if (notePinId > 0) {
const note = noteById(notePinId);
const pinned = !!(note && note.is_pinned);
updateUserNote(notePinId, { is_pinned: !pinned })
.then(fetchBottomBarState)
.then(applyState)
.catch(function (err) {
console.warn('Failed pin toggle', err);
});
return;
}
const noteDeleteId = Number(btn.getAttribute('data-note-delete') || 0);
if (noteDeleteId > 0) {
if (!window.confirm('Slet note permanent fra din liste?')) {
return;
}
deleteUserNote(noteDeleteId)
.then(fetchBottomBarState)
.then(function (data) {
if (Number(noteEditorState.editingId || 0) === noteDeleteId) {
noteEditorState = { editingId: 0, title: '', content: '' };
}
applyState(data);
})
.catch(function (err) {
console.warn('Failed deleting note', err);
});
return;
}
const noteToCaseId = Number(btn.getAttribute('data-note-to-case') || 0);
if (noteToCaseId > 0) {
openNoteTargetModal('case', noteToCaseId);
return;
}
const noteToContactId = Number(btn.getAttribute('data-note-to-contact') || 0);
if (noteToContactId > 0) {
openNoteTargetModal('contact', noteToContactId);
return;
}
const noteToCustomerId = Number(btn.getAttribute('data-note-to-customer') || 0);
if (noteToCustomerId > 0) {
openNoteTargetModal('customer', noteToCustomerId);
return;
}
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 = '<i class="bi bi-check-circle me-1 text-success"></i> Note-data gemt på ' + targetLabel + ' #' + targetId;
}
updateNoteTargetStatus('Gemt.', false);
const modal = getNoteTargetModal();
if (modal) {
modal.hide();
}
})
.catch(function (err) {
console.warn('Failed note target insert', err);
updateNoteTargetStatus((err && err.message) ? err.message : 'Kunne ikke gemme note-data.', true);
})
.finally(function () {
btn.disabled = false;
});
return;
}
const switchAction = btn.getAttribute('data-bb-switch-action');
if (switchAction) {
if (switchAction === 'continue-unchanged') {
switchCaseState.decision = 'unchanged';
switchCaseStatusMessage('<i class="bi bi-info-circle me-1"></i>Timer fortsætter uændret. Du kan åbne en sag uden at starte ny timer.');
return;
}
if (switchAction === 'pause-now') {
pauseActiveTimer().then(function () {
notifyTimerStateChanged('paused');
switchCaseState.decision = 'pause';
switchCaseState.activeTimer = null;
switchCaseStatusMessage('<i class="bi bi-check-circle me-1 text-success"></i>Timer sat på pause. Du kan nu starte ny timer.');
return loadSwitchCaseData();
}).catch(function (err) {
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke pause timer.'));
});
return;
}
if (switchAction === 'stop-now') {
stopActiveTimer().then(function () {
notifyTimerStateChanged('stopped');
switchCaseState.decision = 'stop';
switchCaseState.activeTimer = null;
switchCaseStatusMessage('<i class="bi bi-check-circle me-1 text-success"></i>Aktiv timer stoppet. Du kan nu starte ny timer.');
return loadSwitchCaseData();
}).catch(function () {
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>Kunne ikke stoppe timer.');
});
return;
}
}
if (btn.hasAttribute('data-bb-cancel-convert')) {
const panel = byId('bbConvertTimePanel');
if (panel) panel.classList.add('d-none');
setTimeConversionMode(false);
loadSwitchCaseData().catch(function (err) {
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke hente timeroversigten.'));
});
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('<span class="spinner-border spinner-border-sm me-2" aria-hidden="true"></span>Genoptager timer...');
resumeTimer(resumeTimeId)
.then(fetchBottomBarState)
.then(function (state) {
applyState(state);
switchCaseStatusMessage('<i class="bi bi-check-circle me-1 text-success"></i>Timeren er genoptaget.');
return loadSwitchCaseData();
})
.catch(function (err) {
btn.disabled = false;
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + 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) {
btn.disabled = true;
stopTimer(stopTimeId)
.then(fetchBottomBarState)
.then(applyState)
.catch(function (err) {
btn.disabled = false;
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke stoppe timeren.'));
});
return;
}
if (btn.id === 'bbQuickNoteSaveBtn') {
const input = byId('bbQuickNoteInput');
const value = input ? String(input.value || '').trim() : '';
quickNoteDraft = value;
if (!value) {
updateQuickNoteHint('Skriv en note først.', true);
return;
}
const caseId = resolveQuickNoteCaseId();
if (!(caseId > 0)) {
updateQuickNoteHint('Åbn en sag (eller start timer på en sag) før du gemmer quick note.', true);
return;
}
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Gemmer...';
saveQuickNote(value, caseId)
.then(function () {
if (input) {
input.value = '';
}
quickNoteDraft = '';
updateQuickNoteHint('Gemte note på sag #' + caseId + '.', false);
const detail = byId('bbCountDetail');
if (detail) {
detail.innerHTML = '<i class="bi bi-check-circle me-1 text-success"></i> Quick note gemt på sag #' + caseId;
}
})
.catch(function (err) {
updateQuickNoteHint((err && err.message) ? err.message : 'Kunne ikke gemme note.', true);
})
.finally(function () {
btn.disabled = false;
btn.innerHTML = originalHtml;
});
return;
}
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 = '<i class="bi bi-info-circle me-1 text-warning"></i> Vælg en medarbejder før sagen tildeles.';
return;
}
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>';
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 = '<i class="bi bi-check-circle me-1 text-success"></i> ' + escapeHtml(data.message || 'Sagen blev tildelt.');
return fetchBottomBarState();
})
.then(applyState)
.catch(err => {
const detail = byId('bbCountDetail');
if (detail) detail.innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-danger"></i> ' + escapeHtml(err.message || 'Fejl ved tildeling');
})
.finally(() => { btn.disabled = false; btn.innerHTML = originalHtml; });
return;
}
if (bossAction === 'assign_next_to_owner') {
const ownerId = Number(btn.getAttribute('data-owner-id') || 0);
if (ownerId <= 0) {
return;
}
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Tildeler...';
fetch('/api/v1/bottom-bar/boss/assign-next-to-user', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ assignee_user_id: ownerId })
})
.then(async r => {
const body = await r.json().catch(() => ({}));
if (!r.ok) {
const detail = body && body.detail ? body.detail : 'Kunne ikke tildele næste sag';
throw new Error(typeof detail === 'string' ? detail : 'Kunne ikke tildele næste sag');
}
return body;
})
.then(data => {
const detail = byId('bbCountDetail');
if (detail) {
if (data.status === 'assigned' && data.case) {
detail.innerHTML = '<i class="bi bi-check-circle me-1 text-success"></i> Tildelt: ' + escapeHtml(data.case.title || 'Sag') + ' til tekniker.';
} else {
detail.innerHTML = '<i class="bi bi-info-circle me-1 text-accent"></i> ' + escapeHtml(data.message || 'Ingen sager at tildele');
}
}
return fetchBottomBarState();
})
.then(applyState)
.catch(err => {
console.error('Assign next to owner failed', err);
const detail = byId('bbCountDetail');
if (detail) {
detail.innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-danger"></i> ' + escapeHtml(err.message || 'Fejl ved tildeling');
}
})
.finally(() => {
btn.disabled = false;
btn.innerHTML = originalHtml;
});
return;
}
if (bossAction === 'auto_assign_next') {
const originalHtml = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Fordeler...';
fetch('/api/v1/bottom-bar/boss/auto-assign-next', {
method: 'POST',
credentials: 'include'
})
.then(async r => {
const body = await r.json().catch(() => ({}));
if (!r.ok) {
const detail = body && body.detail ? body.detail : 'Kunne ikke auto-fordele sag';
throw new Error(typeof detail === 'string' ? detail : 'Kunne ikke auto-fordele sag');
}
return body;
})
.then(data => {
const detail = byId('bbCountDetail');
if (detail) {
if (data.status === 'assigned' && data.case && data.assignee) {
detail.innerHTML = '<i class="bi bi-check-circle me-1 text-success"></i> Auto-fordelt: ' + escapeHtml(data.case.title || 'Sag') + ' → ' + escapeHtml(data.assignee.name || 'medarbejder');
} else {
detail.innerHTML = '<i class="bi bi-info-circle me-1 text-accent"></i> ' + escapeHtml(data.message || 'Ingen sager at fordele');
}
}
return fetchBottomBarState();
})
.then(applyState)
.catch(err => {
console.error('Boss auto-assign failed', err);
const detail = byId('bbCountDetail');
if (detail) {
detail.innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-danger"></i> ' + escapeHtml(err.message || 'Fejl ved auto-fordeling');
}
})
.finally(() => {
btn.disabled = false;
btn.innerHTML = originalHtml;
});
return;
}
if (bossAction === 'open_unassigned') {
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 = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Omsætter kalender og SLA...';
btn.disabled = true;
fetch('/api/v1/bottom-bar/next_task', { method: 'POST', credentials: 'include' })
.then(r => {
if (!r.ok) {
throw new Error('Kunne ikke hente næste opgave');
}
return r.json();
})
.then(data => {
const task = data && data.task ? data.task : {};
const taskTitle = task.title || 'Ingen opgave fundet';
const caseId = task.case_id || '-';
const freeMins = data && data.free_time_calculated ? data.free_time_calculated : 0;
btn.innerHTML = '<i class="bi bi-magic me-2"></i>Du fik tildelt: ' + escapeHtml(taskTitle) + ' (Sag #' + escapeHtml(caseId) + ') <span class="badge bg-light text-dark ms-2">' + escapeHtml(freeMins) + 'm fri</span>';
btn.classList.add('btn-success');
btn.classList.remove('btn-primary');
})
.catch(err => {
console.error("Fejl:", err);
btn.innerHTML = "Fejl - prøv igen";
btn.disabled = false;
});
}
if (btn.id === 'btnSendMsg') {
const input = document.getElementById('chatInputQuick');
const recipientObj = document.getElementById('chatRecipient');
if (input && input.value.trim() !== '') {
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.querySelector('.bb-messages-list');
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 () {
try {
activeKey = window.localStorage.getItem('bmc-bottom-bar-active-tab') || 'overview';
} catch (_) {
activeKey = 'overview';
}
const allowedTabs = ['overview', 'timer', 'messages', 'tasks', 'notes', 'boss'];
if (allowedTabs.indexOf(activeKey) === -1) activeKey = 'overview';
const selectedTab = document.querySelector('.bb-tab-btn[data-bb-tab="' + activeKey + '"]');
document.querySelectorAll('.bb-tab-btn').forEach(function (button) {
const selected = button === selectedTab;
button.classList.toggle('is-active', selected);
button.setAttribute('aria-selected', selected ? 'true' : 'false');
});
bindChipClicks();
bindChipHoverPreview();
bindSheetToggle();
bindHeaderActions();
bindDynamicActions();
bindSideTabs();
try {
setExpanded(window.localStorage.getItem('bmc-bottom-bar-expanded') === '1');
} catch (_) {
setExpanded(false);
}
startPollingFallback();
connectRealtime();
});
})();