- Added multiple test cases for the sag module to ensure proper functionality and data handling. - Created new templates for knowledge detail and knowledge index pages to display articles and solutions. - Introduced migrations to enhance the internet connections schema, including new columns for manual sharing and SLA subscriptions. - Added a script to reconcile known internet connections with verified data. - Planned the implementation of a new website content administration module for managing customer references and operational status.
3608 lines
168 KiB
JavaScript
3608 lines
168 KiB
JavaScript
(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';
|
||
let notesApiUnavailable = false;
|
||
let driftSummaryRefreshTimer = null;
|
||
let markMessagesReadPromise = null;
|
||
|
||
function byId(id) {
|
||
return document.getElementById(id);
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
function isMessagesComposerFocused() {
|
||
const focusedId = document.activeElement && document.activeElement.id;
|
||
return activeKey === 'messages' && (focusedId === 'chatInputQuick' || focusedId === 'chatRecipient' || focusedId === 'chatRequiresAck');
|
||
}
|
||
|
||
function renderChatRecipientOptions(sel) {
|
||
if (!sel) return;
|
||
sel.innerHTML = '';
|
||
|
||
const baseOption = document.createElement('option');
|
||
baseOption.value = 'all';
|
||
baseOption.textContent = 'Alle på vagt';
|
||
sel.appendChild(baseOption);
|
||
|
||
if (chatComposerState.loading && !chatComposerState.users.length) {
|
||
const loadingOption = document.createElement('option');
|
||
loadingOption.value = '';
|
||
loadingOption.textContent = 'Indlæser brugere...';
|
||
loadingOption.disabled = true;
|
||
sel.appendChild(loadingOption);
|
||
}
|
||
|
||
if (chatComposerState.error && !chatComposerState.users.length) {
|
||
const errorOption = document.createElement('option');
|
||
errorOption.value = '';
|
||
errorOption.textContent = 'Kunne ikke hente brugere';
|
||
errorOption.disabled = true;
|
||
sel.appendChild(errorOption);
|
||
}
|
||
|
||
if (chatComposerState.loaded && !chatComposerState.users.length && !chatComposerState.error) {
|
||
const emptyOption = document.createElement('option');
|
||
emptyOption.value = '';
|
||
emptyOption.textContent = 'Ingen aktive brugere fundet';
|
||
emptyOption.disabled = true;
|
||
sel.appendChild(emptyOption);
|
||
}
|
||
|
||
chatComposerState.users.forEach(function (u) {
|
||
const option = document.createElement('option');
|
||
option.value = String(u.id);
|
||
option.textContent = u.full_name || u.username || u.email || ('Bruger #' + u.id);
|
||
sel.appendChild(option);
|
||
});
|
||
|
||
const wantedValue = String(chatComposerState.recipient || 'all');
|
||
const hasWantedValue = Array.from(sel.options).some(function (option) {
|
||
return option.value === wantedValue;
|
||
});
|
||
if (!hasWantedValue && chatComposerState.replyToUserId && wantedValue === String(chatComposerState.replyToUserId)) {
|
||
const replyOption = document.createElement('option');
|
||
replyOption.value = wantedValue;
|
||
replyOption.textContent = chatComposerState.replyToName
|
||
? ('Svar til: ' + chatComposerState.replyToName)
|
||
: ('Bruger #' + wantedValue);
|
||
sel.appendChild(replyOption);
|
||
}
|
||
const hasWantedValueAfterReplyFallback = Array.from(sel.options).some(function (option) {
|
||
return option.value === wantedValue;
|
||
});
|
||
sel.value = hasWantedValueAfterReplyFallback ? wantedValue : 'all';
|
||
chatComposerState.recipient = sel.value || 'all';
|
||
}
|
||
|
||
function fetchChatUsers() {
|
||
if (chatComposerState.loading || chatComposerState.loaded) {
|
||
return;
|
||
}
|
||
|
||
chatComposerState.loading = true;
|
||
chatComposerState.error = '';
|
||
|
||
fetch('/api/v1/users?is_active=true', { credentials: 'include' })
|
||
.then(async function (r) {
|
||
if (!r.ok) {
|
||
let detail = 'Kunne ikke hente brugere';
|
||
try {
|
||
const payload = await r.json();
|
||
detail = payload.detail || payload.message || detail;
|
||
} catch (_) {
|
||
// Ignore parse failures
|
||
}
|
||
throw new Error(detail);
|
||
}
|
||
return r.json();
|
||
})
|
||
.then(function (payload) {
|
||
const users = Array.isArray(payload) ? payload : ((payload && payload.data && Array.isArray(payload.data)) ? payload.data : []);
|
||
chatComposerState.users = users;
|
||
chatComposerState.loaded = true;
|
||
chatComposerState.error = '';
|
||
const sel = document.getElementById('chatRecipient');
|
||
if (sel) {
|
||
renderChatRecipientOptions(sel);
|
||
}
|
||
})
|
||
.catch(function (e) {
|
||
console.error('Error fetching users for chat:', e);
|
||
chatComposerState.error = e && e.message ? e.message : 'Kunne ikke hente brugere';
|
||
chatComposerState.users = [];
|
||
chatComposerState.loaded = false;
|
||
const sel = document.getElementById('chatRecipient');
|
||
if (sel) {
|
||
renderChatRecipientOptions(sel);
|
||
}
|
||
})
|
||
.finally(function () {
|
||
chatComposerState.loading = false;
|
||
});
|
||
}
|
||
|
||
function updateMessagesTabBadge() {
|
||
const btn = document.querySelector('.bb-tab-btn[data-bb-tab="messages"]');
|
||
if (!btn) return;
|
||
|
||
let badge = btn.querySelector('.bb-tab-badge');
|
||
if (!badge) {
|
||
badge = document.createElement('span');
|
||
badge.className = 'bb-tab-badge';
|
||
btn.appendChild(badge);
|
||
}
|
||
|
||
const unread = Number((((latestSections || {}).messages || {}).count) || 0);
|
||
badge.textContent = String(unread);
|
||
btn.classList.toggle('has-unread', unread > 0);
|
||
badge.setAttribute('aria-hidden', unread > 0 ? 'false' : 'true');
|
||
}
|
||
|
||
function getMessageThreads() {
|
||
const messageItems = Array.isArray((((latestSections || {}).messages || {}).list)) ? (((latestSections || {}).messages || {}).list) : [];
|
||
const threadsByKey = new Map();
|
||
|
||
messageItems.forEach(function (item) {
|
||
const own = !!item.is_own;
|
||
const partnerId = own ? Number(item.recipient_user_id || 0) : Number(item.sender_user_id || 0);
|
||
const key = partnerId > 0 ? ('user:' + partnerId) : 'broadcast';
|
||
const label = partnerId > 0
|
||
? String(own ? (item.to || ('Bruger #' + partnerId)) : (item.from || ('Bruger #' + partnerId)))
|
||
: 'Alle på vagt';
|
||
const existing = threadsByKey.get(key) || {
|
||
key: key,
|
||
partnerUserId: partnerId > 0 ? partnerId : null,
|
||
label: label,
|
||
items: [],
|
||
unread: 0,
|
||
lastCreatedAt: ''
|
||
};
|
||
existing.items.push(item);
|
||
if (item.is_unread) {
|
||
existing.unread += 1;
|
||
}
|
||
existing.lastCreatedAt = String(item.created_at || existing.lastCreatedAt || '');
|
||
threadsByKey.set(key, existing);
|
||
});
|
||
|
||
return Array.from(threadsByKey.values()).sort(function (a, b) {
|
||
return String(b.lastCreatedAt || '').localeCompare(String(a.lastCreatedAt || ''));
|
||
});
|
||
}
|
||
|
||
function getChatUserDisplayName(userId) {
|
||
const normalizedId = Number(userId || 0);
|
||
if (!(normalizedId > 0)) {
|
||
return 'Alle på vagt';
|
||
}
|
||
|
||
const matchedUser = (chatComposerState.users || []).find(function (user) {
|
||
return Number(user.id || 0) === normalizedId;
|
||
});
|
||
if (matchedUser) {
|
||
return matchedUser.full_name || matchedUser.username || matchedUser.email || ('Bruger #' + normalizedId);
|
||
}
|
||
|
||
const messageItems = Array.isArray((((latestSections || {}).messages || {}).list)) ? (((latestSections || {}).messages || {}).list) : [];
|
||
const matchedMessage = messageItems.find(function (item) {
|
||
return Number(item.sender_user_id || 0) === normalizedId || Number(item.recipient_user_id || 0) === normalizedId;
|
||
});
|
||
if (matchedMessage) {
|
||
if (Number(matchedMessage.sender_user_id || 0) === normalizedId) {
|
||
return matchedMessage.from || ('Bruger #' + normalizedId);
|
||
}
|
||
return matchedMessage.to || ('Bruger #' + normalizedId);
|
||
}
|
||
|
||
return 'Bruger #' + normalizedId;
|
||
}
|
||
|
||
function ensureActiveMessageThread() {
|
||
const threads = getMessageThreads();
|
||
const currentKey = String(chatComposerState.activeThreadKey || '').trim();
|
||
const matched = threads.find(function (thread) { return thread.key === currentKey; });
|
||
if (matched) {
|
||
return matched;
|
||
}
|
||
|
||
if (currentKey.indexOf('user:') === 0) {
|
||
const partnerUserId = Number(currentKey.split(':')[1] || 0);
|
||
if (partnerUserId > 0) {
|
||
return {
|
||
key: currentKey,
|
||
partnerUserId: partnerUserId,
|
||
label: chatComposerState.replyToName || getChatUserDisplayName(partnerUserId),
|
||
items: [],
|
||
unread: 0,
|
||
lastCreatedAt: ''
|
||
};
|
||
}
|
||
}
|
||
|
||
if (currentKey === 'broadcast') {
|
||
return {
|
||
key: 'broadcast',
|
||
partnerUserId: null,
|
||
label: 'Alle på vagt',
|
||
items: [],
|
||
unread: 0,
|
||
lastCreatedAt: ''
|
||
};
|
||
}
|
||
|
||
if (!threads.length) {
|
||
if (String(chatComposerState.recipient || 'all') !== 'all') {
|
||
const partnerUserId = Number(chatComposerState.recipient || 0);
|
||
if (partnerUserId > 0) {
|
||
const syntheticKey = 'user:' + partnerUserId;
|
||
chatComposerState.activeThreadKey = syntheticKey;
|
||
return {
|
||
key: syntheticKey,
|
||
partnerUserId: partnerUserId,
|
||
label: chatComposerState.replyToName || getChatUserDisplayName(partnerUserId),
|
||
items: [],
|
||
unread: 0,
|
||
lastCreatedAt: ''
|
||
};
|
||
}
|
||
}
|
||
chatComposerState.activeThreadKey = '';
|
||
return null;
|
||
}
|
||
|
||
chatComposerState.activeThreadKey = threads[0].key;
|
||
return threads[0];
|
||
}
|
||
|
||
function getRenderableMessageThreads(activeThread) {
|
||
const seen = new Set();
|
||
const out = [];
|
||
|
||
if (activeThread && activeThread.key) {
|
||
out.push(activeThread);
|
||
seen.add(activeThread.key);
|
||
}
|
||
|
||
getMessageThreads().forEach(function (thread) {
|
||
if (seen.has(thread.key)) return;
|
||
seen.add(thread.key);
|
||
out.push(thread);
|
||
});
|
||
|
||
return out;
|
||
}
|
||
|
||
function syncChatRecipientToActiveThread(activeThread) {
|
||
if (chatComposerState.replyToUserId) {
|
||
chatComposerState.recipient = String(chatComposerState.replyToUserId);
|
||
return;
|
||
}
|
||
|
||
if (!activeThread) {
|
||
if (!chatComposerState.recipient) {
|
||
chatComposerState.recipient = 'all';
|
||
}
|
||
return;
|
||
}
|
||
|
||
chatComposerState.recipient = activeThread.partnerUserId ? String(activeThread.partnerUserId) : 'all';
|
||
}
|
||
|
||
function markActiveMessageThreadRead(activeThread) {
|
||
if (!activeThread || markMessagesReadPromise) {
|
||
return;
|
||
}
|
||
|
||
const unreadCount = Number((((latestSections || {}).messages || {}).count) || 0);
|
||
if (unreadCount <= 0) {
|
||
return;
|
||
}
|
||
|
||
const hasUnreadInThread = (((latestSections || {}).messages || {}).list || []).some(function (item) {
|
||
if (item.is_own || !item.is_unread || item.requires_manual_ack) return false;
|
||
if (activeThread.partnerUserId) {
|
||
return Number(item.sender_user_id || 0) === Number(activeThread.partnerUserId);
|
||
}
|
||
return item.recipient_user_id == null;
|
||
});
|
||
if (!hasUnreadInThread) {
|
||
return;
|
||
}
|
||
|
||
const payload = activeThread.partnerUserId ? { partner_user_id: Number(activeThread.partnerUserId) } : {};
|
||
markMessagesReadPromise = fetch('/api/v1/bottom-bar/messages/read', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
})
|
||
.then(function (response) {
|
||
if (!response.ok) {
|
||
throw new Error('Kunne ikke markere beskeder som læst');
|
||
}
|
||
return response.json().catch(function () { return {}; });
|
||
})
|
||
.then(function () {
|
||
const messages = ((latestSections || {}).messages || {});
|
||
const list = Array.isArray(messages.list) ? messages.list : [];
|
||
messages.list = list.map(function (item) {
|
||
const belongsToThread = activeThread.partnerUserId
|
||
? Number(item.sender_user_id || 0) === Number(activeThread.partnerUserId)
|
||
: item.recipient_user_id == null;
|
||
if (!item.is_own && belongsToThread && !item.requires_manual_ack) {
|
||
return Object.assign({}, item, { is_unread: false });
|
||
}
|
||
return item;
|
||
});
|
||
messages.count = messages.list.filter(function (item) {
|
||
return !item.is_own && item.is_unread;
|
||
}).length;
|
||
latestSections.messages = messages;
|
||
updateMessagesTabBadge();
|
||
updateActivityZone();
|
||
})
|
||
.catch(function (err) {
|
||
console.warn('Failed marking messages read', err);
|
||
})
|
||
.finally(function () {
|
||
markMessagesReadPromise = null;
|
||
});
|
||
}
|
||
|
||
function clearChatReplyState() {
|
||
chatComposerState.replyToMessageId = 0;
|
||
chatComposerState.replyToName = '';
|
||
chatComposerState.replyToUserId = null;
|
||
}
|
||
|
||
function setChatReplyState(message) {
|
||
if (!message) return;
|
||
const replyUserId = message.is_own ? Number(message.recipient_user_id || 0) : Number(message.sender_user_id || 0);
|
||
if (!replyUserId) return;
|
||
chatComposerState.replyToMessageId = Number(message.id || 0);
|
||
chatComposerState.replyToName = String(message.is_own ? (message.to || 'Bruger') : (message.from || 'Bruger'));
|
||
chatComposerState.replyToUserId = replyUserId;
|
||
chatComposerState.recipient = String(replyUserId);
|
||
chatComposerState.activeThreadKey = 'user:' + replyUserId;
|
||
}
|
||
|
||
function acknowledgeMessage(messageId) {
|
||
const normalizedId = Number(messageId || 0);
|
||
if (!(normalizedId > 0)) {
|
||
return Promise.reject(new Error('Ugyldigt besked-id'));
|
||
}
|
||
|
||
return fetch('/api/v1/bottom-bar/messages/acknowledge', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||
body: JSON.stringify({ message_id: normalizedId })
|
||
})
|
||
.then(async function (response) {
|
||
if (!response.ok) {
|
||
let detail = 'Kunne ikke bekræfte besked';
|
||
try {
|
||
const payload = await response.json();
|
||
detail = payload.detail || payload.message || detail;
|
||
} catch (_) {
|
||
// Ignore parse failure
|
||
}
|
||
throw new Error(detail);
|
||
}
|
||
return response.json().catch(function () { return {}; });
|
||
})
|
||
.then(function () {
|
||
const messages = ((latestSections || {}).messages || {});
|
||
const list = Array.isArray(messages.list) ? messages.list : [];
|
||
messages.list = list.map(function (item) {
|
||
if (Number(item.id || 0) === normalizedId) {
|
||
return Object.assign({}, item, {
|
||
is_unread: false,
|
||
is_acknowledged: true
|
||
});
|
||
}
|
||
return item;
|
||
});
|
||
messages.count = messages.list.filter(function (item) {
|
||
return !item.is_own && item.is_unread;
|
||
}).length;
|
||
latestSections.messages = messages;
|
||
updateMessagesTabBadge();
|
||
updateActivityZone();
|
||
});
|
||
}
|
||
|
||
function loadLocalNotes() {
|
||
try {
|
||
const raw = window.localStorage.getItem(LOCAL_NOTES_KEY);
|
||
const parsed = raw ? JSON.parse(raw) : [];
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch (e) {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function saveLocalNotes(notes) {
|
||
try {
|
||
window.localStorage.setItem(LOCAL_NOTES_KEY, JSON.stringify(Array.isArray(notes) ? notes : []));
|
||
} catch (e) {
|
||
// Ignore storage failures (private mode/quota)
|
||
}
|
||
}
|
||
|
||
function hydrateNotesFromLocalIfNeeded(sections) {
|
||
const target = sections || {};
|
||
const notes = target.notes || { list: [], count: 0 };
|
||
const localNotes = loadLocalNotes();
|
||
const remoteList = Array.isArray(notes.list) ? notes.list : [];
|
||
|
||
// 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),
|
||
timer: Number(timer.active_count || 0),
|
||
drift: Number(drift.down || 0),
|
||
eset: Number(eset.incidents || 0)
|
||
};
|
||
}
|
||
|
||
function detailTextFor(key, sections) {
|
||
const counts = getCounts(sections);
|
||
const nameMap = {
|
||
mail: 'Ubesvarede mails',
|
||
cases: 'Åbne sager',
|
||
urgent: 'Hastesager',
|
||
unassigned: 'Sager uden ansvarlig',
|
||
timer: 'Aktive timere',
|
||
drift: 'Drift alerts',
|
||
eset: 'ESET incidents'
|
||
};
|
||
const val = counts[key] || 0;
|
||
return nameMap[key] + ': ' + val;
|
||
}
|
||
|
||
function severityClassFor(key, value) {
|
||
const val = Number(value || 0);
|
||
if (key === 'drift') {
|
||
return val > 0 ? 'sev-critical' : 'sev-ok';
|
||
}
|
||
if (key === 'urgent') {
|
||
return val > 0 ? 'sev-critical' : 'sev-ok';
|
||
}
|
||
if (key === 'unassigned') {
|
||
if (val >= 3) return 'sev-critical';
|
||
if (val > 0) return 'sev-warn';
|
||
return 'sev-ok';
|
||
}
|
||
if (key === 'mail') {
|
||
if (val >= 10) return 'sev-critical';
|
||
if (val > 0) return 'sev-warn';
|
||
return 'sev-ok';
|
||
}
|
||
return val > 0 ? 'sev-warn' : 'sev-ok';
|
||
}
|
||
|
||
function listFor(key, sections) {
|
||
const mail = sections.mail || {};
|
||
const cases = sections.cases || {};
|
||
const urgent = sections.urgent || {};
|
||
const unassigned = sections.unassigned || {};
|
||
const timer = sections.timer || {};
|
||
const drift = sections.drift || sections.kuma || {};
|
||
const eset = sections.eset || {};
|
||
const messages = sections.messages || {};
|
||
const tasks = sections.tasks || {};
|
||
const notes = sections.notes || {};
|
||
const boss = sections.boss || {};
|
||
|
||
function esc(str) {
|
||
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
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(function (m) {
|
||
const own = !!m.is_own;
|
||
const unreadBadge = m.is_unread ? ' <span class="badge text-bg-warning ms-1">Ny</span>' : '';
|
||
const importantBadge = m.requires_manual_ack ? ' <span class="badge text-bg-danger ms-1">Vigtig</span>' : '';
|
||
const ackBadge = own && m.requires_manual_ack
|
||
? (m.is_acknowledged
|
||
? ' <span class="badge text-bg-success ms-1">Bekræftet læst</span>'
|
||
: ' <span class="badge text-bg-secondary ms-1">Afventer læst-bekræftelse</span>')
|
||
: '';
|
||
const targetMeta = own && m.to ? '<div class="small text-muted mb-1">Til: ' + esc(m.to) + '</div>' : '';
|
||
const replyTargetId = own ? Number(m.recipient_user_id || 0) : Number(m.sender_user_id || 0);
|
||
const replyBtn = replyTargetId > 0
|
||
? '<div class="mt-2"><button type="button" class="btn btn-sm ' + (own ? 'btn-light' : 'btn-outline-secondary') + '" data-bb-reply-message="' + Number(m.id || 0) + '"><i class="bi bi-reply me-1"></i>Svar</button></div>'
|
||
: '';
|
||
const ackBtn = (!own && m.requires_manual_ack && m.is_unread)
|
||
? '<div class="mt-2"><button type="button" class="btn btn-sm btn-danger" data-bb-ack-message="' + Number(m.id || 0) + '"><i class="bi bi-check2-circle me-1"></i>Bekræft læst</button></div>'
|
||
: '';
|
||
return ''
|
||
+ '<div class="' + (own ? 'text-end' : '') + '">'
|
||
+ targetMeta
|
||
+ '<div class="d-inline-block ' + (own ? 'bg-primary text-white' : (m.requires_manual_ack ? 'bg-warning-subtle border border-warning-subtle' : 'bg-light')) + ' p-2 rounded-3 text-start shadow-sm" style="max-width: 85%;">'
|
||
+ '<strong class="' + (own ? 'text-white' : 'text-accent') + '">' + esc(m.from) + ':</strong> '
|
||
+ esc(m.text)
|
||
+ unreadBadge
|
||
+ importantBadge
|
||
+ ackBadge
|
||
+ replyBtn
|
||
+ ackBtn
|
||
+ '</div></div>';
|
||
});
|
||
}
|
||
return ['Ingen beskeder i denne tråd endnu.'];
|
||
}
|
||
|
||
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',
|
||
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;
|
||
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);
|
||
|
||
if (threadItems.length > 0) {
|
||
const threadList = document.createElement('div');
|
||
threadList.className = 'bb-message-threads';
|
||
threadItems.forEach(function (thread) {
|
||
const button = document.createElement('button');
|
||
button.type = 'button';
|
||
button.className = 'bb-message-thread' + (activeThread && thread.key === activeThread.key ? ' is-active' : '');
|
||
button.setAttribute('data-bb-thread-key', thread.key);
|
||
button.innerHTML = ''
|
||
+ '<span class="bb-message-thread-label">' + escapeHtml(thread.label || 'Samtale') + '</span>'
|
||
+ (thread.unread > 0 ? '<span class="bb-message-thread-count">' + Number(thread.unread || 0) + '</span>' : '');
|
||
threadList.appendChild(button);
|
||
});
|
||
chatContainer.appendChild(threadList);
|
||
}
|
||
|
||
const replyBox = document.createElement('div');
|
||
replyBox.className = 'bb-messages-composer';
|
||
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 = `
|
||
${threadMeta}
|
||
${replyBanner}
|
||
<div class="input-group input-group-sm mb-1">
|
||
<span class="input-group-text bg-light text-muted border-0"><i class="bi bi-person"></i></span>
|
||
<select id="chatRecipient" class="form-select border-0 bg-light">
|
||
<option value="all">Alle på vagt</option>
|
||
</select>
|
||
</div>
|
||
<label class="form-check form-switch small mb-2">
|
||
<input class="form-check-input" type="checkbox" id="chatRequiresAck" ${chatComposerState.requiresManualAck ? 'checked' : ''}>
|
||
<span class="form-check-label">Kræv manuel læst-bekræftelse</span>
|
||
</label>
|
||
<div class="input-group">
|
||
<input type="text" id="chatInputQuick" class="form-control form-control-sm" placeholder="Skriv en besked..." value="${escapeHtml(chatComposerState.draft || '')}">
|
||
<button class="btn btn-outline-primary btn-sm" id="btnSendMsg"><i class="bi bi-send"></i></button>
|
||
</div>
|
||
`;
|
||
|
||
chatContainer.appendChild(ul);
|
||
chatContainer.appendChild(replyBox);
|
||
innerContent.appendChild(chatContainer);
|
||
|
||
const recipientSelect = document.getElementById('chatRecipient');
|
||
const input = document.getElementById('chatInputQuick');
|
||
const ackToggle = document.getElementById('chatRequiresAck');
|
||
const cancelReplyBtn = document.getElementById('bbCancelReplyBtn');
|
||
renderChatRecipientOptions(recipientSelect);
|
||
if (recipientSelect) {
|
||
recipientSelect.disabled = !!chatComposerState.replyToUserId;
|
||
}
|
||
if (ackToggle) {
|
||
ackToggle.checked = !!chatComposerState.requiresManualAck;
|
||
ackToggle.addEventListener('change', function () {
|
||
chatComposerState.requiresManualAck = !!ackToggle.checked;
|
||
});
|
||
}
|
||
if (input) {
|
||
input.addEventListener('input', function () {
|
||
chatComposerState.draft = input.value || '';
|
||
});
|
||
}
|
||
if (recipientSelect) {
|
||
recipientSelect.addEventListener('change', function () {
|
||
chatComposerState.recipient = recipientSelect.value || 'all';
|
||
if (!chatComposerState.replyToUserId) {
|
||
chatComposerState.activeThreadKey = chatComposerState.recipient === 'all'
|
||
? 'broadcast'
|
||
: ('user:' + chatComposerState.recipient);
|
||
renderTabPanel();
|
||
window.requestAnimationFrame(function () {
|
||
const nextInput = document.getElementById('chatInputQuick');
|
||
if (nextInput) {
|
||
nextInput.focus();
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
if (cancelReplyBtn) {
|
||
cancelReplyBtn.addEventListener('click', function () {
|
||
clearChatReplyState();
|
||
renderTabPanel();
|
||
});
|
||
}
|
||
fetchChatUsers();
|
||
markActiveMessageThreadRead(activeThread);
|
||
|
||
if (messageFocusState) {
|
||
window.requestAnimationFrame(function () {
|
||
const focusEl = document.getElementById(messageFocusState.id);
|
||
if (!focusEl) return;
|
||
focusEl.focus();
|
||
if (
|
||
messageFocusState.id === 'chatInputQuick'
|
||
&& typeof messageFocusState.selectionStart === 'number'
|
||
&& typeof focusEl.setSelectionRange === 'function'
|
||
) {
|
||
focusEl.setSelectionRange(messageFocusState.selectionStart, messageFocusState.selectionEnd ?? messageFocusState.selectionStart);
|
||
}
|
||
});
|
||
}
|
||
} else {
|
||
if (activeKey === 'notes') {
|
||
const notesContainer = document.createElement('div');
|
||
notesContainer.className = 'bb-notes-layout';
|
||
|
||
const noteItems = Array.from(ul.children);
|
||
const editorItem = noteItems.shift() || null;
|
||
|
||
if (editorItem) {
|
||
const editorWrap = document.createElement('div');
|
||
editorWrap.className = 'bb-notes-editor';
|
||
editorWrap.appendChild(editorItem);
|
||
notesContainer.appendChild(editorWrap);
|
||
}
|
||
|
||
const notesList = document.createElement('ul');
|
||
notesList.className = 'bb-tab-list bb-notes-list';
|
||
noteItems.forEach(function (item) {
|
||
notesList.appendChild(item);
|
||
});
|
||
notesContainer.appendChild(notesList);
|
||
innerContent.appendChild(notesContainer);
|
||
} else {
|
||
innerContent.appendChild(ul);
|
||
}
|
||
}
|
||
|
||
}
|
||
|
||
function timerStateLabel(state) {
|
||
if (state === 'active') return ['Aktiv', 'success'];
|
||
if (state === 'paused') return ['Pauset', 'warning'];
|
||
return ['Klar til registrering', 'info'];
|
||
}
|
||
|
||
function formatTimerSeconds(value) {
|
||
const seconds = Math.max(0, Number(value || 0));
|
||
return [Math.floor(seconds / 3600), Math.floor((seconds % 3600) / 60), Math.floor(seconds % 60)]
|
||
.map(function (part) { return String(part).padStart(2, '0'); }).join(':');
|
||
}
|
||
|
||
function renderTimerWorkQueue(container) {
|
||
const scope = timerPanelState.scope;
|
||
const toolbar = document.createElement('div');
|
||
toolbar.className = 'd-flex justify-content-between align-items-center gap-2 mb-3';
|
||
toolbar.innerHTML = '<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.getElementById('bbTabInnerContent');
|
||
if (tabInner) {
|
||
tabInner.scrollTop = tabInner.scrollHeight + 500;
|
||
}
|
||
})
|
||
.catch(err => {
|
||
console.error('Fejl ved afsendelse af bundmenu-besked:', err);
|
||
alert(err.message || 'Kunne ikke sende besked');
|
||
})
|
||
.finally(() => {
|
||
btn.disabled = false;
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
document.addEventListener('keydown', function (e) {
|
||
if (e.key !== 'Enter') {
|
||
return;
|
||
}
|
||
const input = byId('bbQuickNoteInput');
|
||
if (!input || document.activeElement !== input) {
|
||
return;
|
||
}
|
||
e.preventDefault();
|
||
const saveBtn = byId('bbQuickNoteSaveBtn');
|
||
if (saveBtn) {
|
||
saveBtn.click();
|
||
}
|
||
});
|
||
|
||
document.addEventListener('input', function (e) {
|
||
const target = e.target;
|
||
if (target && target.id === 'bbSwitchCaseSearch') {
|
||
renderSwitchCaseLists();
|
||
return;
|
||
}
|
||
if (!target || target.id !== 'bbQuickNoteInput') {
|
||
if (target && target.id === 'bbNoteTitleInput') {
|
||
noteEditorState.title = String(target.value || '');
|
||
}
|
||
if (target && target.id === 'bbNoteContentInput') {
|
||
noteEditorState.content = String(target.value || '');
|
||
}
|
||
return;
|
||
}
|
||
quickNoteDraft = String(target.value || '');
|
||
if (quickNoteHintState.level !== 'muted') {
|
||
quickNoteHintState = {
|
||
message: 'Tip: gemmer som kommentar på aktiv/åben sag.',
|
||
level: 'muted'
|
||
};
|
||
const hint = byId('bbQuickNoteHint');
|
||
if (hint) {
|
||
hint.textContent = quickNoteHintState.message;
|
||
hint.classList.remove('text-danger', 'text-success');
|
||
hint.classList.add('text-muted');
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
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();
|
||
});
|
||
|
||
})();
|