2991 lines
129 KiB
JavaScript
2991 lines
129 KiB
JavaScript
(function () {
|
|
let latestSections = {};
|
|
let latestContextActions = { global: [], context: [] };
|
|
let activeKey = 'timer';
|
|
let overviewFilter = null;
|
|
let ws = null;
|
|
let pollTimer = null;
|
|
let wsReconnectTimer = null;
|
|
let latestNotificationCount = 0;
|
|
let latestNotifications = [];
|
|
let switchCaseModalInstance = null;
|
|
let quickNoteDraft = '';
|
|
let quickNoteHintState = {
|
|
message: 'Tip: gemmer som kommentar på aktiv/åben sag.',
|
|
level: 'muted'
|
|
};
|
|
let switchCaseState = {
|
|
activeTimer: null,
|
|
decision: 'unchanged',
|
|
timers: { active: [], paused: [] },
|
|
recentCases: [],
|
|
unassignedCases: []
|
|
};
|
|
let noteEditorState = {
|
|
editingId: 0,
|
|
title: '',
|
|
content: ''
|
|
};
|
|
let noteTargetModalInstance = null;
|
|
let noteTargetState = {
|
|
target: 'case',
|
|
noteId: 0
|
|
};
|
|
let chatComposerState = {
|
|
draft: '',
|
|
recipient: 'all',
|
|
requiresManualAck: false,
|
|
users: [],
|
|
loading: false,
|
|
loaded: false,
|
|
error: '',
|
|
replyToMessageId: 0,
|
|
replyToName: '',
|
|
replyToUserId: null,
|
|
activeThreadKey: ''
|
|
};
|
|
const LOCAL_NOTES_KEY = 'bmc_bottom_bar_notes_v1';
|
|
let notesApiUnavailable = false;
|
|
let driftSummaryRefreshTimer = null;
|
|
let markMessagesReadPromise = null;
|
|
|
|
function byId(id) {
|
|
return document.getElementById(id);
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function isMessagesComposerFocused() {
|
|
const focusedId = document.activeElement && document.activeElement.id;
|
|
return activeKey === 'messages' && (focusedId === 'chatInputQuick' || focusedId === 'chatRecipient' || focusedId === 'chatRequiresAck');
|
|
}
|
|
|
|
function renderChatRecipientOptions(sel) {
|
|
if (!sel) return;
|
|
sel.innerHTML = '';
|
|
|
|
const baseOption = document.createElement('option');
|
|
baseOption.value = 'all';
|
|
baseOption.textContent = 'Alle på vagt';
|
|
sel.appendChild(baseOption);
|
|
|
|
if (chatComposerState.loading && !chatComposerState.users.length) {
|
|
const loadingOption = document.createElement('option');
|
|
loadingOption.value = '';
|
|
loadingOption.textContent = 'Indlæser brugere...';
|
|
loadingOption.disabled = true;
|
|
sel.appendChild(loadingOption);
|
|
}
|
|
|
|
if (chatComposerState.error && !chatComposerState.users.length) {
|
|
const errorOption = document.createElement('option');
|
|
errorOption.value = '';
|
|
errorOption.textContent = 'Kunne ikke hente brugere';
|
|
errorOption.disabled = true;
|
|
sel.appendChild(errorOption);
|
|
}
|
|
|
|
if (chatComposerState.loaded && !chatComposerState.users.length && !chatComposerState.error) {
|
|
const emptyOption = document.createElement('option');
|
|
emptyOption.value = '';
|
|
emptyOption.textContent = 'Ingen aktive brugere fundet';
|
|
emptyOption.disabled = true;
|
|
sel.appendChild(emptyOption);
|
|
}
|
|
|
|
chatComposerState.users.forEach(function (u) {
|
|
const option = document.createElement('option');
|
|
option.value = String(u.id);
|
|
option.textContent = u.full_name || u.username || u.email || ('Bruger #' + u.id);
|
|
sel.appendChild(option);
|
|
});
|
|
|
|
const wantedValue = String(chatComposerState.recipient || 'all');
|
|
const hasWantedValue = Array.from(sel.options).some(function (option) {
|
|
return option.value === wantedValue;
|
|
});
|
|
if (!hasWantedValue && chatComposerState.replyToUserId && wantedValue === String(chatComposerState.replyToUserId)) {
|
|
const replyOption = document.createElement('option');
|
|
replyOption.value = wantedValue;
|
|
replyOption.textContent = chatComposerState.replyToName
|
|
? ('Svar til: ' + chatComposerState.replyToName)
|
|
: ('Bruger #' + wantedValue);
|
|
sel.appendChild(replyOption);
|
|
}
|
|
const hasWantedValueAfterReplyFallback = Array.from(sel.options).some(function (option) {
|
|
return option.value === wantedValue;
|
|
});
|
|
sel.value = hasWantedValueAfterReplyFallback ? wantedValue : 'all';
|
|
chatComposerState.recipient = sel.value || 'all';
|
|
}
|
|
|
|
function fetchChatUsers() {
|
|
if (chatComposerState.loading || chatComposerState.loaded) {
|
|
return;
|
|
}
|
|
|
|
chatComposerState.loading = true;
|
|
chatComposerState.error = '';
|
|
|
|
fetch('/api/v1/users?is_active=true', { credentials: 'include' })
|
|
.then(async function (r) {
|
|
if (!r.ok) {
|
|
let detail = 'Kunne ikke hente brugere';
|
|
try {
|
|
const payload = await r.json();
|
|
detail = payload.detail || payload.message || detail;
|
|
} catch (_) {
|
|
// Ignore parse failures
|
|
}
|
|
throw new Error(detail);
|
|
}
|
|
return r.json();
|
|
})
|
|
.then(function (payload) {
|
|
const users = Array.isArray(payload) ? payload : ((payload && payload.data && Array.isArray(payload.data)) ? payload.data : []);
|
|
chatComposerState.users = users;
|
|
chatComposerState.loaded = true;
|
|
chatComposerState.error = '';
|
|
const sel = document.getElementById('chatRecipient');
|
|
if (sel) {
|
|
renderChatRecipientOptions(sel);
|
|
}
|
|
})
|
|
.catch(function (e) {
|
|
console.error('Error fetching users for chat:', e);
|
|
chatComposerState.error = e && e.message ? e.message : 'Kunne ikke hente brugere';
|
|
chatComposerState.users = [];
|
|
chatComposerState.loaded = false;
|
|
const sel = document.getElementById('chatRecipient');
|
|
if (sel) {
|
|
renderChatRecipientOptions(sel);
|
|
}
|
|
})
|
|
.finally(function () {
|
|
chatComposerState.loading = false;
|
|
});
|
|
}
|
|
|
|
function updateMessagesTabBadge() {
|
|
const btn = document.querySelector('.bb-tab-btn[data-bb-tab="messages"]');
|
|
if (!btn) return;
|
|
|
|
let badge = btn.querySelector('.bb-tab-badge');
|
|
if (!badge) {
|
|
badge = document.createElement('span');
|
|
badge.className = 'bb-tab-badge';
|
|
btn.appendChild(badge);
|
|
}
|
|
|
|
const unread = Number((((latestSections || {}).messages || {}).count) || 0);
|
|
badge.textContent = String(unread);
|
|
btn.classList.toggle('has-unread', unread > 0);
|
|
badge.setAttribute('aria-hidden', unread > 0 ? 'false' : 'true');
|
|
}
|
|
|
|
function getMessageThreads() {
|
|
const messageItems = Array.isArray((((latestSections || {}).messages || {}).list)) ? (((latestSections || {}).messages || {}).list) : [];
|
|
const threadsByKey = new Map();
|
|
|
|
messageItems.forEach(function (item) {
|
|
const own = !!item.is_own;
|
|
const partnerId = own ? Number(item.recipient_user_id || 0) : Number(item.sender_user_id || 0);
|
|
const key = partnerId > 0 ? ('user:' + partnerId) : 'broadcast';
|
|
const label = partnerId > 0
|
|
? String(own ? (item.to || ('Bruger #' + partnerId)) : (item.from || ('Bruger #' + partnerId)))
|
|
: 'Alle på vagt';
|
|
const existing = threadsByKey.get(key) || {
|
|
key: key,
|
|
partnerUserId: partnerId > 0 ? partnerId : null,
|
|
label: label,
|
|
items: [],
|
|
unread: 0,
|
|
lastCreatedAt: ''
|
|
};
|
|
existing.items.push(item);
|
|
if (item.is_unread) {
|
|
existing.unread += 1;
|
|
}
|
|
existing.lastCreatedAt = String(item.created_at || existing.lastCreatedAt || '');
|
|
threadsByKey.set(key, existing);
|
|
});
|
|
|
|
return Array.from(threadsByKey.values()).sort(function (a, b) {
|
|
return String(b.lastCreatedAt || '').localeCompare(String(a.lastCreatedAt || ''));
|
|
});
|
|
}
|
|
|
|
function getChatUserDisplayName(userId) {
|
|
const normalizedId = Number(userId || 0);
|
|
if (!(normalizedId > 0)) {
|
|
return 'Alle på vagt';
|
|
}
|
|
|
|
const matchedUser = (chatComposerState.users || []).find(function (user) {
|
|
return Number(user.id || 0) === normalizedId;
|
|
});
|
|
if (matchedUser) {
|
|
return matchedUser.full_name || matchedUser.username || matchedUser.email || ('Bruger #' + normalizedId);
|
|
}
|
|
|
|
const messageItems = Array.isArray((((latestSections || {}).messages || {}).list)) ? (((latestSections || {}).messages || {}).list) : [];
|
|
const matchedMessage = messageItems.find(function (item) {
|
|
return Number(item.sender_user_id || 0) === normalizedId || Number(item.recipient_user_id || 0) === normalizedId;
|
|
});
|
|
if (matchedMessage) {
|
|
if (Number(matchedMessage.sender_user_id || 0) === normalizedId) {
|
|
return matchedMessage.from || ('Bruger #' + normalizedId);
|
|
}
|
|
return matchedMessage.to || ('Bruger #' + normalizedId);
|
|
}
|
|
|
|
return 'Bruger #' + normalizedId;
|
|
}
|
|
|
|
function ensureActiveMessageThread() {
|
|
const threads = getMessageThreads();
|
|
const currentKey = String(chatComposerState.activeThreadKey || '').trim();
|
|
const matched = threads.find(function (thread) { return thread.key === currentKey; });
|
|
if (matched) {
|
|
return matched;
|
|
}
|
|
|
|
if (currentKey.indexOf('user:') === 0) {
|
|
const partnerUserId = Number(currentKey.split(':')[1] || 0);
|
|
if (partnerUserId > 0) {
|
|
return {
|
|
key: currentKey,
|
|
partnerUserId: partnerUserId,
|
|
label: chatComposerState.replyToName || getChatUserDisplayName(partnerUserId),
|
|
items: [],
|
|
unread: 0,
|
|
lastCreatedAt: ''
|
|
};
|
|
}
|
|
}
|
|
|
|
if (currentKey === 'broadcast') {
|
|
return {
|
|
key: 'broadcast',
|
|
partnerUserId: null,
|
|
label: 'Alle på vagt',
|
|
items: [],
|
|
unread: 0,
|
|
lastCreatedAt: ''
|
|
};
|
|
}
|
|
|
|
if (!threads.length) {
|
|
if (String(chatComposerState.recipient || 'all') !== 'all') {
|
|
const partnerUserId = Number(chatComposerState.recipient || 0);
|
|
if (partnerUserId > 0) {
|
|
const syntheticKey = 'user:' + partnerUserId;
|
|
chatComposerState.activeThreadKey = syntheticKey;
|
|
return {
|
|
key: syntheticKey,
|
|
partnerUserId: partnerUserId,
|
|
label: chatComposerState.replyToName || getChatUserDisplayName(partnerUserId),
|
|
items: [],
|
|
unread: 0,
|
|
lastCreatedAt: ''
|
|
};
|
|
}
|
|
}
|
|
chatComposerState.activeThreadKey = '';
|
|
return null;
|
|
}
|
|
|
|
chatComposerState.activeThreadKey = threads[0].key;
|
|
return threads[0];
|
|
}
|
|
|
|
function getRenderableMessageThreads(activeThread) {
|
|
const seen = new Set();
|
|
const out = [];
|
|
|
|
if (activeThread && activeThread.key) {
|
|
out.push(activeThread);
|
|
seen.add(activeThread.key);
|
|
}
|
|
|
|
getMessageThreads().forEach(function (thread) {
|
|
if (seen.has(thread.key)) return;
|
|
seen.add(thread.key);
|
|
out.push(thread);
|
|
});
|
|
|
|
return out;
|
|
}
|
|
|
|
function syncChatRecipientToActiveThread(activeThread) {
|
|
if (chatComposerState.replyToUserId) {
|
|
chatComposerState.recipient = String(chatComposerState.replyToUserId);
|
|
return;
|
|
}
|
|
|
|
if (!activeThread) {
|
|
if (!chatComposerState.recipient) {
|
|
chatComposerState.recipient = 'all';
|
|
}
|
|
return;
|
|
}
|
|
|
|
chatComposerState.recipient = activeThread.partnerUserId ? String(activeThread.partnerUserId) : 'all';
|
|
}
|
|
|
|
function markActiveMessageThreadRead(activeThread) {
|
|
if (!activeThread || markMessagesReadPromise) {
|
|
return;
|
|
}
|
|
|
|
const unreadCount = Number((((latestSections || {}).messages || {}).count) || 0);
|
|
if (unreadCount <= 0) {
|
|
return;
|
|
}
|
|
|
|
const hasUnreadInThread = (((latestSections || {}).messages || {}).list || []).some(function (item) {
|
|
if (item.is_own || !item.is_unread || item.requires_manual_ack) return false;
|
|
if (activeThread.partnerUserId) {
|
|
return Number(item.sender_user_id || 0) === Number(activeThread.partnerUserId);
|
|
}
|
|
return item.recipient_user_id == null;
|
|
});
|
|
if (!hasUnreadInThread) {
|
|
return;
|
|
}
|
|
|
|
const payload = activeThread.partnerUserId ? { partner_user_id: Number(activeThread.partnerUserId) } : {};
|
|
markMessagesReadPromise = fetch('/api/v1/bottom-bar/messages/read', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
})
|
|
.then(function (response) {
|
|
if (!response.ok) {
|
|
throw new Error('Kunne ikke markere beskeder som læst');
|
|
}
|
|
return response.json().catch(function () { return {}; });
|
|
})
|
|
.then(function () {
|
|
const messages = ((latestSections || {}).messages || {});
|
|
const list = Array.isArray(messages.list) ? messages.list : [];
|
|
messages.list = list.map(function (item) {
|
|
const belongsToThread = activeThread.partnerUserId
|
|
? Number(item.sender_user_id || 0) === Number(activeThread.partnerUserId)
|
|
: item.recipient_user_id == null;
|
|
if (!item.is_own && belongsToThread && !item.requires_manual_ack) {
|
|
return Object.assign({}, item, { is_unread: false });
|
|
}
|
|
return item;
|
|
});
|
|
messages.count = messages.list.filter(function (item) {
|
|
return !item.is_own && item.is_unread;
|
|
}).length;
|
|
latestSections.messages = messages;
|
|
updateMessagesTabBadge();
|
|
updateActivityZone();
|
|
})
|
|
.catch(function (err) {
|
|
console.warn('Failed marking messages read', err);
|
|
})
|
|
.finally(function () {
|
|
markMessagesReadPromise = null;
|
|
});
|
|
}
|
|
|
|
function clearChatReplyState() {
|
|
chatComposerState.replyToMessageId = 0;
|
|
chatComposerState.replyToName = '';
|
|
chatComposerState.replyToUserId = null;
|
|
}
|
|
|
|
function setChatReplyState(message) {
|
|
if (!message) return;
|
|
const replyUserId = message.is_own ? Number(message.recipient_user_id || 0) : Number(message.sender_user_id || 0);
|
|
if (!replyUserId) return;
|
|
chatComposerState.replyToMessageId = Number(message.id || 0);
|
|
chatComposerState.replyToName = String(message.is_own ? (message.to || 'Bruger') : (message.from || 'Bruger'));
|
|
chatComposerState.replyToUserId = replyUserId;
|
|
chatComposerState.recipient = String(replyUserId);
|
|
chatComposerState.activeThreadKey = 'user:' + replyUserId;
|
|
}
|
|
|
|
function acknowledgeMessage(messageId) {
|
|
const normalizedId = Number(messageId || 0);
|
|
if (!(normalizedId > 0)) {
|
|
return Promise.reject(new Error('Ugyldigt besked-id'));
|
|
}
|
|
|
|
return fetch('/api/v1/bottom-bar/messages/acknowledge', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
body: JSON.stringify({ message_id: normalizedId })
|
|
})
|
|
.then(async function (response) {
|
|
if (!response.ok) {
|
|
let detail = 'Kunne ikke bekræfte besked';
|
|
try {
|
|
const payload = await response.json();
|
|
detail = payload.detail || payload.message || detail;
|
|
} catch (_) {
|
|
// Ignore parse failure
|
|
}
|
|
throw new Error(detail);
|
|
}
|
|
return response.json().catch(function () { return {}; });
|
|
})
|
|
.then(function () {
|
|
const messages = ((latestSections || {}).messages || {});
|
|
const list = Array.isArray(messages.list) ? messages.list : [];
|
|
messages.list = list.map(function (item) {
|
|
if (Number(item.id || 0) === normalizedId) {
|
|
return Object.assign({}, item, {
|
|
is_unread: false,
|
|
is_acknowledged: true
|
|
});
|
|
}
|
|
return item;
|
|
});
|
|
messages.count = messages.list.filter(function (item) {
|
|
return !item.is_own && item.is_unread;
|
|
}).length;
|
|
latestSections.messages = messages;
|
|
updateMessagesTabBadge();
|
|
updateActivityZone();
|
|
});
|
|
}
|
|
|
|
function loadLocalNotes() {
|
|
try {
|
|
const raw = window.localStorage.getItem(LOCAL_NOTES_KEY);
|
|
const parsed = raw ? JSON.parse(raw) : [];
|
|
return Array.isArray(parsed) ? parsed : [];
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function saveLocalNotes(notes) {
|
|
try {
|
|
window.localStorage.setItem(LOCAL_NOTES_KEY, JSON.stringify(Array.isArray(notes) ? notes : []));
|
|
} catch (e) {
|
|
// Ignore storage failures (private mode/quota)
|
|
}
|
|
}
|
|
|
|
function hydrateNotesFromLocalIfNeeded(sections) {
|
|
const target = sections || {};
|
|
const notes = target.notes || { list: [], count: 0 };
|
|
const localNotes = loadLocalNotes();
|
|
const remoteList = Array.isArray(notes.list) ? notes.list : [];
|
|
|
|
if (!notesApiUnavailable && remoteList.length > 0) {
|
|
return target;
|
|
}
|
|
|
|
target.notes = {
|
|
count: localNotes.length,
|
|
list: localNotes
|
|
.slice()
|
|
.sort(function (a, b) {
|
|
return Number(b.is_pinned || 0) - Number(a.is_pinned || 0)
|
|
|| String(b.updated_at || '').localeCompare(String(a.updated_at || ''));
|
|
})
|
|
};
|
|
return target;
|
|
}
|
|
|
|
async function fetchBottomBarState() {
|
|
const contextPath = encodeURIComponent(window.location.pathname || '/');
|
|
const response = await fetch('/api/v1/bottom-bar/state?context=' + contextPath, {
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Accept': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const error = new Error('Could not load bottom bar state');
|
|
error.status = response.status;
|
|
throw error;
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
function disableBottomBarAuthRetry(reason) {
|
|
stopPolling();
|
|
if (wsReconnectTimer) {
|
|
window.clearTimeout(wsReconnectTimer);
|
|
wsReconnectTimer = null;
|
|
}
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
try {
|
|
ws.close(1000, 'auth-failed');
|
|
} catch (_) {
|
|
// Ignore close failures
|
|
}
|
|
}
|
|
setVisibility(false);
|
|
console.info('Bottom bar disabled due to auth state:', reason || 'unauthorized');
|
|
}
|
|
|
|
function applyState(data) {
|
|
if (data && data.enabled) {
|
|
const onDriftPage = (window.location.pathname || '').toLowerCase().indexOf('/drift') === 0;
|
|
const driftBubble = document.querySelector('.bb-chip[data-bb-key="drift"] .bb-chip-bubble');
|
|
const previousDriftDown = Number(
|
|
(driftBubble && driftBubble.textContent ? Number(driftBubble.textContent) : 0)
|
|
|| (((latestSections || {}).drift || {}).down)
|
|
|| (((latestSections || {}).kuma || {}).down)
|
|
|| 0
|
|
);
|
|
latestSections = data.sections || {};
|
|
if ((!latestSections.drift || typeof latestSections.drift.down === 'undefined') && latestSections.kuma) {
|
|
latestSections.drift = latestSections.kuma;
|
|
}
|
|
|
|
if (onDriftPage) {
|
|
// Drift page owns drift count via its filtered event list.
|
|
latestSections.drift = latestSections.drift || {};
|
|
latestSections.kuma = latestSections.kuma || {};
|
|
latestSections.drift.down = previousDriftDown;
|
|
latestSections.kuma.down = previousDriftDown;
|
|
}
|
|
|
|
latestSections = hydrateNotesFromLocalIfNeeded(latestSections);
|
|
latestContextActions = (latestSections.context_actions || { global: [], context: [] });
|
|
latestNotificationCount = Number((((data || {}).notifications || {}).count) || 0);
|
|
latestNotifications = (((data || {}).notifications || {}).items || []);
|
|
syncBossTabVisibility();
|
|
updateBar(latestSections);
|
|
updateMessagesTabBadge();
|
|
if (!onDriftPage) {
|
|
refreshDriftFromSummary();
|
|
}
|
|
updateActivityZone();
|
|
const focusedId = document.activeElement && document.activeElement.id;
|
|
const keepCurrentRender = (activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput'))
|
|
|| isMessagesComposerFocused();
|
|
if (!keepCurrentRender) {
|
|
renderTabPanel();
|
|
}
|
|
setVisibility(true);
|
|
return;
|
|
}
|
|
setVisibility(false);
|
|
}
|
|
|
|
async function refreshDriftFromSummary() {
|
|
if (driftSummaryRefreshTimer) {
|
|
return;
|
|
}
|
|
|
|
driftSummaryRefreshTimer = window.setTimeout(async function () {
|
|
driftSummaryRefreshTimer = null;
|
|
try {
|
|
const parseBlacklistValue = function (rawValue) {
|
|
const raw = String(rawValue || '').trim();
|
|
if (!raw) return [];
|
|
|
|
let values = [];
|
|
try {
|
|
const parsed = JSON.parse(raw);
|
|
if (Array.isArray(parsed)) {
|
|
values = parsed;
|
|
} else if (typeof parsed === 'string') {
|
|
values = [parsed];
|
|
}
|
|
} catch (_) {
|
|
values = raw.replace(/;/g, '\n').replace(/,/g, '\n').split('\n');
|
|
}
|
|
|
|
const out = [];
|
|
const seen = new Set();
|
|
values.forEach(function (item) {
|
|
const norm = String(item || '').trim().toLowerCase();
|
|
if (!norm || seen.has(norm)) return;
|
|
seen.add(norm);
|
|
out.push(norm);
|
|
});
|
|
return out;
|
|
};
|
|
|
|
const isEventBlacklisted = function (event, blacklist) {
|
|
if (!Array.isArray(blacklist) || !blacklist.length) return false;
|
|
const tokens = new Set();
|
|
const add = function (value) {
|
|
const norm = String(value || '').trim().toLowerCase();
|
|
if (norm) tokens.add(norm);
|
|
};
|
|
|
|
const sourceEventId = String((event || {}).source_event_id || '').trim().toLowerCase();
|
|
add(sourceEventId);
|
|
if (sourceEventId.indexOf('uisp-') === 0) add(sourceEventId.slice(5));
|
|
if (sourceEventId.indexOf('kuma-') === 0) add(sourceEventId.slice(5));
|
|
add((event || {}).device);
|
|
|
|
const rawJson = (event && typeof event.raw_json === 'object' && event.raw_json) ? event.raw_json : {};
|
|
const rawItem = (rawJson && typeof rawJson.raw_item === 'object' && rawJson.raw_item) ? rawJson.raw_item : {};
|
|
const identification = (rawItem && typeof rawItem.identification === 'object' && rawItem.identification) ? rawItem.identification : {};
|
|
add(identification.id);
|
|
add(identification.name);
|
|
add(identification.hostname);
|
|
add(identification.mac);
|
|
|
|
return blacklist.some(function (item) {
|
|
return tokens.has(String(item || '').trim().toLowerCase());
|
|
});
|
|
};
|
|
|
|
// Primary source: active drift events filtered client-side by blacklist.
|
|
const [eventsRes, blacklistRes] = await Promise.all([
|
|
fetch('/api/v1/drift/events?status=active&limit=200', {
|
|
credentials: 'include',
|
|
headers: { 'Accept': 'application/json' }
|
|
}),
|
|
fetch('/api/v1/settings/drift_device_blacklist', {
|
|
credentials: 'include',
|
|
headers: { 'Accept': 'application/json' }
|
|
})
|
|
]);
|
|
|
|
if (eventsRes.ok) {
|
|
const eventsPayload = await eventsRes.json();
|
|
const events = Array.isArray(eventsPayload) ? eventsPayload : [];
|
|
let blacklist = [];
|
|
if (blacklistRes.ok) {
|
|
const settingPayload = await blacklistRes.json();
|
|
blacklist = parseBlacklistValue(settingPayload && settingPayload.value ? settingPayload.value : '[]');
|
|
}
|
|
|
|
const activeFiltered = events.filter(function (event) {
|
|
return !isEventBlacklisted(event, blacklist);
|
|
}).length;
|
|
|
|
latestSections.drift = latestSections.drift || {};
|
|
latestSections.kuma = latestSections.kuma || {};
|
|
latestSections.drift.down = activeFiltered;
|
|
latestSections.kuma.down = activeFiltered;
|
|
updateBar(latestSections);
|
|
updateActivityZone();
|
|
return;
|
|
}
|
|
|
|
// Keep drift chip aligned with bottom-bar backend status source first.
|
|
const statusRes = await fetch('/api/v1/dashboard/status', {
|
|
credentials: 'include',
|
|
headers: { 'Accept': 'application/json' }
|
|
});
|
|
if (statusRes.ok) {
|
|
const status = await statusRes.json();
|
|
const activeFromStatus = Number(status.drift_active || 0);
|
|
latestSections.drift = latestSections.drift || {};
|
|
latestSections.kuma = latestSections.kuma || {};
|
|
latestSections.drift.down = activeFromStatus;
|
|
latestSections.kuma.down = activeFromStatus;
|
|
updateBar(latestSections);
|
|
updateActivityZone();
|
|
return;
|
|
}
|
|
|
|
const res = await fetch('/api/v1/drift/summary', {
|
|
credentials: 'include',
|
|
headers: { 'Accept': 'application/json' }
|
|
});
|
|
if (!res.ok) {
|
|
return;
|
|
}
|
|
const summary = await res.json();
|
|
const active = Number(summary.active_alerts || 0);
|
|
latestSections.drift = latestSections.drift || {};
|
|
latestSections.kuma = latestSections.kuma || {};
|
|
latestSections.drift.down = active;
|
|
latestSections.kuma.down = active;
|
|
updateBar(latestSections);
|
|
updateActivityZone();
|
|
} catch (_) {
|
|
// Ignore drift summary fallback errors to avoid noisy UI logs.
|
|
}
|
|
}, 250);
|
|
}
|
|
|
|
function setVisibility(enabled) {
|
|
const shell = byId('globalBottomBar');
|
|
if (!shell) {
|
|
return;
|
|
}
|
|
|
|
if (enabled) {
|
|
shell.hidden = false;
|
|
window.requestAnimationFrame(function () {
|
|
shell.classList.add('is-visible');
|
|
});
|
|
} else {
|
|
shell.classList.remove('is-visible');
|
|
window.setTimeout(function () {
|
|
if (!shell.classList.contains('is-visible')) {
|
|
shell.hidden = true;
|
|
}
|
|
}, 320);
|
|
}
|
|
|
|
document.body.classList.toggle('bottom-bar-visible', !!enabled);
|
|
if (!enabled) {
|
|
document.body.classList.remove('bottom-bar-expanded');
|
|
}
|
|
}
|
|
|
|
window.addEventListener('bb:setDriftCount', function (event) {
|
|
const detail = (event && event.detail) || {};
|
|
const count = Math.max(0, Number(detail.count || 0));
|
|
latestSections = latestSections || {};
|
|
latestSections.drift = latestSections.drift || {};
|
|
latestSections.kuma = latestSections.kuma || {};
|
|
latestSections.drift.down = count;
|
|
latestSections.kuma.down = count;
|
|
});
|
|
|
|
function setExpanded(expanded) {
|
|
const shell = byId('globalBottomBar');
|
|
const toggle = byId('bbSheetToggle');
|
|
const panel = byId('bbSheetPanel');
|
|
if (!shell || !toggle || !panel) {
|
|
return;
|
|
}
|
|
|
|
shell.classList.toggle('is-expanded', !!expanded);
|
|
document.body.classList.toggle('bottom-bar-expanded', !!expanded);
|
|
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
panel.setAttribute('aria-hidden', expanded ? 'false' : 'true');
|
|
}
|
|
|
|
function syncBossTabVisibility() {
|
|
const bossBtn = document.querySelector('.bb-tab-btn[data-bb-tab="boss"]');
|
|
if (!bossBtn) {
|
|
return;
|
|
}
|
|
|
|
bossBtn.classList.remove('d-none');
|
|
}
|
|
|
|
function getCounts(sections) {
|
|
const mail = sections.mail || {};
|
|
const cases = sections.cases || {};
|
|
const urgent = sections.urgent || {};
|
|
const timer = sections.timer || {};
|
|
const drift = sections.drift || sections.kuma || {};
|
|
const eset = sections.eset || {};
|
|
const unassigned = sections.unassigned || {};
|
|
|
|
return {
|
|
mail: Number(mail.unread || 0),
|
|
cases: Number(cases.open || 0),
|
|
urgent: Number(urgent.count || 0),
|
|
unassigned: Number(unassigned.count || 0),
|
|
timer: Number(timer.active_count || 0),
|
|
drift: Number(drift.down || 0),
|
|
eset: Number(eset.incidents || 0)
|
|
};
|
|
}
|
|
|
|
function detailTextFor(key, sections) {
|
|
const counts = getCounts(sections);
|
|
const nameMap = {
|
|
mail: 'Ubesvarede mails',
|
|
cases: 'Åbne sager',
|
|
urgent: 'Hastesager',
|
|
unassigned: 'Sager uden ansvarlig',
|
|
timer: 'Aktive timere',
|
|
drift: 'Drift alerts',
|
|
eset: 'ESET incidents'
|
|
};
|
|
const val = counts[key] || 0;
|
|
return nameMap[key] + ': ' + val;
|
|
}
|
|
|
|
function severityClassFor(key, value) {
|
|
const val = Number(value || 0);
|
|
if (key === 'drift') {
|
|
return val > 0 ? 'sev-critical' : 'sev-ok';
|
|
}
|
|
if (key === 'urgent') {
|
|
return val > 0 ? 'sev-critical' : 'sev-ok';
|
|
}
|
|
if (key === 'unassigned') {
|
|
if (val >= 3) return 'sev-critical';
|
|
if (val > 0) return 'sev-warn';
|
|
return 'sev-ok';
|
|
}
|
|
if (key === 'mail') {
|
|
if (val >= 10) return 'sev-critical';
|
|
if (val > 0) return 'sev-warn';
|
|
return 'sev-ok';
|
|
}
|
|
return val > 0 ? 'sev-warn' : 'sev-ok';
|
|
}
|
|
|
|
function listFor(key, sections) {
|
|
const mail = sections.mail || {};
|
|
const cases = sections.cases || {};
|
|
const urgent = sections.urgent || {};
|
|
const unassigned = sections.unassigned || {};
|
|
const timer = sections.timer || {};
|
|
const drift = sections.drift || sections.kuma || {};
|
|
const eset = sections.eset || {};
|
|
const messages = sections.messages || {};
|
|
const tasks = sections.tasks || {};
|
|
const notes = sections.notes || {};
|
|
const boss = sections.boss || {};
|
|
|
|
function esc(str) {
|
|
return String(str).replace(/&/g, '&').replace(/</g, '<').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> <div><button class="btn btn-sm btn-outline-primary me-1">Opret Sag</button> <button class="btn btn-sm btn-outline-secondary">Ignorer</button></div></div>') : ['Alle systemer oppe.'];
|
|
if (overviewFilter === 'eset') return eset.list ? eset.list.map(e => '<div class="d-flex justify-content-between align-items-center"><span>🔐 ' + esc(e) + '</span> <button class="btn btn-sm btn-outline-primary">Håndter</button></div>') : ['Ingen ESET incidents.'];
|
|
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. <button class="btn btn-sm btn-outline-primary mt-2">Åbn indbakke</button></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>');
|
|
}
|
|
|
|
// 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"><span><i class="bi bi-stopwatch text-success"></i> ' + esc(t.desc) + ' (' + esc(elapsedText) + ')</span> <button class="btn btn-sm btn-danger" data-bb-stop-time="' + Number(t.id || t.time_entry_id || 0) + '"><i class="bi bi-stop-fill"></i> Stop</button></div>';
|
|
});
|
|
}
|
|
return ['Ingen aktive timere lige nu.'];
|
|
}
|
|
|
|
if (key === 'messages') {
|
|
const activeThread = ensureActiveMessageThread();
|
|
syncChatRecipientToActiveThread(activeThread);
|
|
const messageItems = activeThread && Array.isArray(activeThread.items) ? activeThread.items : [];
|
|
if (messageItems.length > 0) {
|
|
return messageItems.map(function (m) {
|
|
const own = !!m.is_own;
|
|
const unreadBadge = m.is_unread ? ' <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><i class="bi bi-calendar-check text-success"></i> <strong>' + esc(t.title) + '</strong> <span class="badge bg-secondary ms-2">' + esc(t.deadline) + '</span></div>');
|
|
}
|
|
return ['Ingen aktuelle opgaver.'];
|
|
}
|
|
|
|
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 out = [
|
|
'<div class="row g-2">' +
|
|
'<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Åbne sager</div><div class="fw-bold">' + Number(stats.open_cases || 0) + '</div></div></div>' +
|
|
'<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Hastesager</div><div class="fw-bold text-danger">' + Number(stats.urgent_cases || 0) + '</div></div></div>' +
|
|
'<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Uden ansvarlig</div><div class="fw-bold text-warning">' + Number(stats.unassigned || 0) + '</div></div></div>' +
|
|
'<div class="col-6"><div class="border rounded p-2 bg-body-tertiary"><div class="small text-muted">Stale >24t</div><div class="fw-bold text-danger">' + Number(stats.stale_urgent_cases || 0) + '</div></div></div>' +
|
|
'</div>',
|
|
'<div class="d-flex gap-2 flex-wrap mt-2">' +
|
|
'<button class="btn btn-sm btn-primary" data-boss-action="auto_assign_next"><i class="bi bi-magic me-1"></i>Auto-fordel næste</button>' +
|
|
'<button class="btn btn-sm btn-outline-primary" data-boss-action="open_unassigned"><i class="bi bi-person-x me-1"></i>Fordel ufordelte</button>' +
|
|
'<button class="btn btn-sm btn-outline-danger" data-boss-action="open_escalations"><i class="bi bi-exclamation-octagon me-1"></i>Se eskaleringer</button>' +
|
|
'<button class="btn btn-sm btn-outline-secondary" data-boss-action="open_team"><i class="bi bi-people me-1"></i>Team-overblik</button>' +
|
|
'</div>'
|
|
];
|
|
|
|
if (workload.length > 0) {
|
|
out.push('<div class="small text-muted mt-3 mb-1">Team-belastning</div>');
|
|
workload.slice(0, 5).forEach(function (w) {
|
|
out.push(
|
|
'<div class="d-flex justify-content-between align-items-center border rounded p-2">' +
|
|
'<div><strong>' + esc(w.owner_name || 'Ukendt') + '</strong><div class="small text-muted">Åbne: ' + Number(w.open_cases || 0) + ' • Haste: ' + Number(w.urgent_cases || 0) + '</div></div>' +
|
|
'<button class="btn btn-sm btn-outline-primary" data-boss-action="open_owner" data-owner-id="' + Number(w.user_id || 0) + '">Åbn</button>' +
|
|
'</div>'
|
|
);
|
|
});
|
|
}
|
|
|
|
if (techniciansToday.length > 0) {
|
|
out.push('<div class="small text-muted mt-3 mb-1">Teknikernes opgaver i dag</div>');
|
|
techniciansToday.slice(0, 6).forEach(function (tech) {
|
|
const todayTasks = Array.isArray(tech.today_tasks) ? tech.today_tasks : [];
|
|
let tasksHtml = '<div class="small text-muted mt-1">Ingen opgaver i dag.</div>';
|
|
if (todayTasks.length > 0) {
|
|
tasksHtml = '<div class="small mt-1">' + todayTasks.slice(0, 3).map(function (task) {
|
|
return '<div><i class="bi bi-dot"></i> ' + esc(task.title || ('Sag #' + task.id)) + '</div>';
|
|
}).join('') + '</div>';
|
|
}
|
|
|
|
out.push(
|
|
'<div class="border rounded p-2">' +
|
|
'<div class="d-flex justify-content-between align-items-center">' +
|
|
'<div><strong>' + esc(tech.owner_name || 'Tekniker') + '</strong><div class="small text-muted">I dag: ' + Number(tech.due_today_cases || 0) + ' • Åbne: ' + Number(tech.open_cases || 0) + '</div></div>' +
|
|
'<div class="d-flex gap-1">' +
|
|
'<button class="btn btn-sm btn-outline-primary" data-boss-action="open_owner" data-owner-id="' + Number(tech.user_id || 0) + '">Vis</button>' +
|
|
'<button class="btn btn-sm btn-primary" data-boss-action="assign_next_to_owner" data-owner-id="' + Number(tech.user_id || 0) + '">Tildel næste</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
tasksHtml +
|
|
'</div>'
|
|
);
|
|
});
|
|
}
|
|
|
|
if (escalations.length > 0) {
|
|
out.push('<div class="small text-muted mt-3 mb-1">Eskaleringer</div>');
|
|
escalations.slice(0, 4).forEach(function (c) {
|
|
const ageHours = Math.floor(Number(c.age_seconds || 0) / 3600);
|
|
out.push(
|
|
'<div class="d-flex justify-content-between align-items-center border rounded p-2">' +
|
|
'<div><strong>' + esc(c.title || 'Sag') + '</strong><div class="small text-muted">' + esc(c.owner_name || 'Ikke tildelt') + ' • ' + ageHours + 't siden opdatering</div></div>' +
|
|
'<button class="btn btn-sm btn-outline-danger" data-boss-action="open_case" data-case-id="' + Number(c.id || 0) + '">Åbn</button>' +
|
|
'</div>'
|
|
);
|
|
});
|
|
}
|
|
|
|
if (unassigned.length > 0) {
|
|
out.push('<div class="small text-muted mt-3 mb-1">Ufordelte sager</div>');
|
|
unassigned.slice(0, 4).forEach(function (c) {
|
|
out.push(
|
|
'<div class="d-flex justify-content-between align-items-center border rounded p-2">' +
|
|
'<div><strong>' + esc(c.title || 'Sag') + '</strong><div class="small text-muted">Prioritet: ' + esc(c.priority || 'normal') + '</div></div>' +
|
|
'<button class="btn btn-sm btn-outline-warning" data-boss-action="open_case" data-case-id="' + Number(c.id || 0) + '">Åbn</button>' +
|
|
'</div>'
|
|
);
|
|
});
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
return ['Klik rundt i menuen for at se data.'];
|
|
}
|
|
|
|
function updateBar(sections) {
|
|
const counts = getCounts(sections);
|
|
const keys = Object.keys(counts);
|
|
|
|
for (let i = 0; i < keys.length; i++) {
|
|
const key = keys[i];
|
|
const chipText = document.querySelector('.bb-chip[data-bb-key="' + key + '"] .bb-chip-text');
|
|
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 timer = ((latestSections || {}).timer || {}).active || {};
|
|
const hasActiveTimer = !!timer.active;
|
|
if (timerChip && timerText) {
|
|
timerChip.classList.toggle('is-hidden', !hasActiveTimer);
|
|
if (hasActiveTimer) {
|
|
const elapsed = timer.elapsed_hhmmss || '00:00:00';
|
|
const name = timer.sag_navn || ('Sag #' + (timer.sag_id || ''));
|
|
timerText.textContent = name + ' - ' + elapsed;
|
|
}
|
|
}
|
|
|
|
if (notifCount) {
|
|
const unreadMessages = Number((((latestSections || {}).messages || {}).count) || 0);
|
|
const computed = Number(latestNotificationCount || 0) + unreadMessages;
|
|
notifCount.textContent = String(computed);
|
|
}
|
|
}
|
|
|
|
function renderTabPanel() {
|
|
const titleContainer = byId('bbTabTitle');
|
|
const innerContent = byId('bbTabInnerContent');
|
|
if (!titleContainer || !innerContent) {
|
|
return;
|
|
}
|
|
|
|
let messageFocusState = null;
|
|
if (activeKey === 'messages') {
|
|
const activeEl = document.activeElement;
|
|
const activeId = activeEl && activeEl.id ? activeEl.id : '';
|
|
if (activeId === 'chatInputQuick' || activeId === 'chatRecipient' || activeId === 'chatRequiresAck') {
|
|
messageFocusState = {
|
|
id: activeId,
|
|
selectionStart: typeof activeEl.selectionStart === 'number' ? activeEl.selectionStart : null,
|
|
selectionEnd: typeof activeEl.selectionEnd === 'number' ? activeEl.selectionEnd : null
|
|
};
|
|
}
|
|
}
|
|
|
|
const titleText = titleContainer.querySelector('.bb-tab-title-text');
|
|
|
|
const titleByKey = {
|
|
overview: 'Overblik',
|
|
timer: 'Timere',
|
|
messages: 'Beskeder',
|
|
tasks: 'Opgaver',
|
|
notes: 'Noter',
|
|
boss: 'Chef Dashboard'
|
|
};
|
|
|
|
const iconByKey = {
|
|
overview: 'bi-bell',
|
|
timer: 'bi-stopwatch',
|
|
messages: 'bi-chat-dots',
|
|
tasks: 'bi-calendar-check',
|
|
notes: 'bi-journal-text',
|
|
boss: 'bi-person-workspace'
|
|
};
|
|
|
|
const activeTitle = titleByKey[activeKey] || 'Info';
|
|
if (titleText) {
|
|
titleText.textContent = activeTitle;
|
|
} else {
|
|
titleContainer.textContent = activeTitle;
|
|
}
|
|
|
|
const iconSpan = titleContainer.querySelector('.bi');
|
|
if (iconSpan) {
|
|
iconSpan.className = 'bi ' + (iconByKey[activeKey] || 'bi-info-circle') + ' me-2 text-accent';
|
|
}
|
|
|
|
// Render rich UI lists
|
|
const lines = listFor(activeKey, latestSections);
|
|
const ul = document.createElement('ul');
|
|
ul.className = 'bb-tab-list';
|
|
|
|
lines.forEach(function (line) {
|
|
const li = document.createElement('li');
|
|
// Allow rich HTML (buttons, inputs) - assuming listFor provides sanitized data wrapped in markup
|
|
li.innerHTML = line;
|
|
ul.appendChild(li);
|
|
});
|
|
|
|
innerContent.innerHTML = '';
|
|
|
|
// Add specific headers/controls based on active tab
|
|
if (activeKey === 'tasks') {
|
|
const topBar = document.createElement('div');
|
|
topBar.className = 'bb-task-actions mb-3';
|
|
topBar.innerHTML = '<button class="btn btn-primary btn-sm w-100 fw-bold shadow-sm" id="btnNextTask"><i class="bi bi-box-arrow-in-down-right"></i> Giv mig næste opgave</button>';
|
|
innerContent.appendChild(topBar);
|
|
}
|
|
if (activeKey === 'messages') {
|
|
const chatContainer = document.createElement('div');
|
|
chatContainer.className = '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 bindSideTabs() {
|
|
const buttons = document.querySelectorAll('.bb-tab-btn');
|
|
for (let i = 0; i < buttons.length; i++) {
|
|
buttons[i].addEventListener('click', function (e) {
|
|
// Clear filter on direct human click of the button, unless we programmatically called click()
|
|
if (e.isTrusted) overviewFilter = null;
|
|
|
|
for (let j = 0; j < buttons.length; j++) {
|
|
buttons[j].classList.remove('is-active');
|
|
buttons[j].setAttribute('aria-selected', 'false');
|
|
}
|
|
this.classList.add('is-active');
|
|
this.setAttribute('aria-selected', 'true');
|
|
|
|
activeKey = this.getAttribute('data-bb-tab');
|
|
renderTabPanel();
|
|
|
|
const detail = byId('bbCountDetail');
|
|
if (detail) {
|
|
detail.innerHTML = '<i class="bi bi-info-circle me-1 opacity-75"></i> Viser: ' + (activeKey.charAt(0).toUpperCase() + activeKey.slice(1));
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function bindChipClicks() {
|
|
const chips = document.querySelectorAll('.bb-chip');
|
|
for (let i = 0; i < chips.length; i++) {
|
|
chips[i].addEventListener('click', function () {
|
|
const key = this.getAttribute('data-bb-key');
|
|
if (!key) return;
|
|
|
|
const detail = byId('bbCountDetail');
|
|
if (detail) {
|
|
detail.innerHTML = '<i class="bi bi-check-circle me-1 text-accent"></i> ' + detailTextFor(key, latestSections);
|
|
}
|
|
|
|
const routes = {
|
|
mail: '/emails',
|
|
urgent: '/sag?priority=urgent',
|
|
timer: '/timetracking',
|
|
cases: '/sag',
|
|
drift: '/drift'
|
|
};
|
|
|
|
if (key === 'unassigned') {
|
|
openUnassignedCasesPanel();
|
|
return;
|
|
}
|
|
|
|
const route = routes[key] || '/dashboard';
|
|
window.location.href = route;
|
|
});
|
|
}
|
|
}
|
|
|
|
function bindSheetToggle() {
|
|
const toggle = byId('bbSheetToggle');
|
|
if (!toggle) return;
|
|
toggle.addEventListener('click', function () {
|
|
const shell = byId('globalBottomBar');
|
|
if (!shell) return;
|
|
const isExp = shell.classList.contains('is-expanded');
|
|
setExpanded(!isExp);
|
|
});
|
|
}
|
|
|
|
function stopPolling() {
|
|
if (pollTimer) {
|
|
window.clearTimeout(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
}
|
|
|
|
function pollOnce() {
|
|
let shouldScheduleNextPoll = true;
|
|
fetchBottomBarState().then(function (data) {
|
|
applyState(data);
|
|
}).catch(function (err) {
|
|
console.warn('Bottom bar poll failed', err);
|
|
if (err && (err.status === 401 || err.status === 403)) {
|
|
shouldScheduleNextPoll = false;
|
|
disableBottomBarAuthRetry('poll-' + err.status);
|
|
}
|
|
}).finally(function () {
|
|
if (shouldScheduleNextPoll) {
|
|
pollTimer = window.setTimeout(pollOnce, 15000);
|
|
}
|
|
});
|
|
}
|
|
|
|
function startPollingFallback() {
|
|
stopPolling();
|
|
pollOnce();
|
|
}
|
|
|
|
function updateFromRealtimeEvent(payload) {
|
|
if (!payload || !payload.event) {
|
|
return;
|
|
}
|
|
|
|
const onDriftPage = (window.location.pathname || '').toLowerCase().indexOf('/drift') === 0;
|
|
|
|
if (payload.event === 'timer_tick') {
|
|
const timer = payload.data || {};
|
|
latestSections.timer = latestSections.timer || {};
|
|
latestSections.timer.active = timer;
|
|
latestSections.timer.active_count = timer.active ? 1 : 0;
|
|
latestSections.timer.list = timer.active ? [{
|
|
id: timer.time_entry_id,
|
|
sag_id: timer.sag_id,
|
|
desc: timer.sag_navn || ('Sag #' + (timer.sag_id || '')),
|
|
elapsed: timer.elapsed,
|
|
elapsed_hhmmss: timer.elapsed_hhmmss
|
|
}] : [];
|
|
}
|
|
|
|
if (payload.event === 'status_delta') {
|
|
const status = payload.data || {};
|
|
latestSections.mail = latestSections.mail || {};
|
|
latestSections.cases = latestSections.cases || {};
|
|
latestSections.urgent = latestSections.urgent || {};
|
|
latestSections.unassigned = latestSections.unassigned || {};
|
|
latestSections.drift = latestSections.drift || {};
|
|
latestSections.kuma = latestSections.kuma || {};
|
|
latestSections.boss = latestSections.boss || { stats: {} };
|
|
|
|
latestSections.mail.unread = Number(status.mails_unread || 0);
|
|
latestSections.mail.customer_reply_needed = Number(status.mails_unread || 0);
|
|
latestSections.cases.open = Number(status.sager_open || 0);
|
|
latestSections.urgent.count = Number(status.sager_urgent || 0);
|
|
latestSections.unassigned.count = Number(status.sager_unassigned || 0);
|
|
if (!onDriftPage && Object.prototype.hasOwnProperty.call(status, 'drift_active')) {
|
|
latestSections.drift.down = Number(status.drift_active || 0);
|
|
latestSections.kuma.down = Number(status.drift_active || 0);
|
|
}
|
|
// On /drift page, the page script keeps drift chip in sync with visible filtered events.
|
|
if (!onDriftPage) {
|
|
// Always reconcile against HTTP status/summary source to avoid stale WS drift counts.
|
|
refreshDriftFromSummary();
|
|
}
|
|
latestSections.boss.stats = latestSections.boss.stats || {};
|
|
latestSections.boss.stats.unassigned = Number(status.sager_unassigned || 0);
|
|
}
|
|
|
|
if (payload.event === 'notification_delta') {
|
|
const rawData = payload.data || {};
|
|
const notifications = rawData.notifications || rawData;
|
|
const messages = rawData.messages || null;
|
|
const items = Array.isArray(notifications.items) ? notifications.items : [];
|
|
latestSections.tasks = latestSections.tasks || {};
|
|
if (messages) {
|
|
latestSections.messages = {
|
|
count: Number(messages.count || 0),
|
|
list: Array.isArray(messages.list) ? messages.list : []
|
|
};
|
|
} else {
|
|
latestSections.messages = latestSections.messages || {};
|
|
}
|
|
|
|
latestSections.tasks.count = items.length;
|
|
latestSections.tasks.list = items.slice(0, 5).map(function (item) {
|
|
return {
|
|
title: item.title || 'Notifikation',
|
|
deadline: item.severity || 'info'
|
|
};
|
|
});
|
|
latestNotificationCount = Number(notifications.count || items.length || 0);
|
|
latestNotifications = items;
|
|
}
|
|
|
|
syncBossTabVisibility();
|
|
updateBar(latestSections);
|
|
updateMessagesTabBadge();
|
|
updateActivityZone();
|
|
const focusedId = document.activeElement && document.activeElement.id;
|
|
const quickNoteFocused = activeKey === 'overview' && focusedId === 'bbQuickNoteInput';
|
|
const noteEditorFocused = activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput');
|
|
if (!quickNoteFocused && !noteEditorFocused && !isMessagesComposerFocused()) {
|
|
renderTabPanel();
|
|
}
|
|
}
|
|
|
|
function scheduleWsReconnect() {
|
|
if (wsReconnectTimer) {
|
|
window.clearTimeout(wsReconnectTimer);
|
|
}
|
|
wsReconnectTimer = window.setTimeout(connectRealtime, 3000);
|
|
}
|
|
|
|
function connectRealtime() {
|
|
if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
|
|
return;
|
|
}
|
|
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = protocol + '//' + window.location.host + '/api/v1/bottom-bar/ws';
|
|
|
|
try {
|
|
ws = new WebSocket(wsUrl);
|
|
} catch (err) {
|
|
console.warn('Bottom bar websocket init failed', err);
|
|
startPollingFallback();
|
|
scheduleWsReconnect();
|
|
return;
|
|
}
|
|
|
|
ws.addEventListener('open', function () {
|
|
stopPolling();
|
|
});
|
|
|
|
ws.addEventListener('message', function (event) {
|
|
try {
|
|
const payload = JSON.parse(event.data || '{}');
|
|
updateFromRealtimeEvent(payload);
|
|
} catch (err) {
|
|
console.warn('Bottom bar websocket parse error', err);
|
|
}
|
|
});
|
|
|
|
ws.addEventListener('close', function (event) {
|
|
// 1008 = policy violation (used server-side for auth failure).
|
|
if (event && event.code === 1008) {
|
|
disableBottomBarAuthRetry('ws-1008');
|
|
return;
|
|
}
|
|
startPollingFallback();
|
|
scheduleWsReconnect();
|
|
});
|
|
|
|
ws.addEventListener('error', function (err) {
|
|
console.warn('Bottom bar websocket error', err);
|
|
startPollingFallback();
|
|
});
|
|
}
|
|
|
|
|
|
function stopActiveTimer() {
|
|
const active = ((latestSections || {}).timer || {}).active || {};
|
|
if (!active.time_entry_id) {
|
|
return Promise.resolve();
|
|
}
|
|
|
|
return fetch('/api/v1/timetracking/time/stop', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ time_id: active.time_entry_id })
|
|
}).catch(function (err) {
|
|
console.warn('Failed stopping active timer', err);
|
|
});
|
|
}
|
|
|
|
function pauseActiveTimer() {
|
|
return fetch('/api/v1/timetracking/time/pause', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: '{}'
|
|
}).then(function (res) {
|
|
if (!res.ok) {
|
|
throw new Error('Kunne ikke pause timer');
|
|
}
|
|
return res.json().catch(function () { return {}; });
|
|
});
|
|
}
|
|
|
|
function resumeTimer(timeId) {
|
|
const payload = {};
|
|
if (Number(timeId || 0) > 0) {
|
|
payload.time_id = Number(timeId);
|
|
}
|
|
|
|
return fetch('/api/v1/timetracking/time/resume', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
}).then(function (res) {
|
|
if (!res.ok) {
|
|
throw new Error('Kunne ikke genoptage timer');
|
|
}
|
|
return res.json().catch(function () { return {}; });
|
|
});
|
|
}
|
|
|
|
function normalizeSwitchableTimerPayload(payload) {
|
|
const out = { active: [], paused: [] };
|
|
if (!payload || typeof payload !== 'object') {
|
|
return out;
|
|
}
|
|
|
|
if (Array.isArray(payload.active) || Array.isArray(payload.paused)) {
|
|
out.active = Array.isArray(payload.active) ? payload.active : [];
|
|
out.paused = Array.isArray(payload.paused) ? payload.paused : [];
|
|
return out;
|
|
}
|
|
|
|
const active = payload.active || {};
|
|
const paused = Array.isArray(payload.paused) ? payload.paused : [];
|
|
out.active = active && active.active ? [active] : [];
|
|
out.paused = paused;
|
|
return out;
|
|
}
|
|
|
|
async function fetchSwitchableTimers() {
|
|
const primary = await fetch('/api/v1/timetracking/time/my-switchable', {
|
|
credentials: 'include',
|
|
headers: { 'Accept': 'application/json' }
|
|
});
|
|
|
|
if (primary.ok) {
|
|
const payload = await primary.json();
|
|
return normalizeSwitchableTimerPayload(payload);
|
|
}
|
|
|
|
const fallback = await fetch('/api/v1/bottom-bar/timers/own?paused_limit=10', {
|
|
credentials: 'include',
|
|
headers: { 'Accept': 'application/json' }
|
|
});
|
|
|
|
if (!fallback.ok) {
|
|
throw new Error('Kunne ikke hente timeroversigt');
|
|
}
|
|
|
|
const payload = await fallback.json();
|
|
return normalizeSwitchableTimerPayload(payload);
|
|
}
|
|
|
|
async function fetchRecentCases() {
|
|
const response = await fetch('/api/v1/sag/recent?limit=10', {
|
|
credentials: 'include',
|
|
headers: { 'Accept': 'application/json' }
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error('Kunne ikke hente seneste sager');
|
|
}
|
|
const payload = await response.json();
|
|
return Array.isArray(payload) ? payload : [];
|
|
}
|
|
|
|
function getSwitchCaseModal() {
|
|
const modalEl = byId('bbSwitchCaseModal');
|
|
if (!modalEl || !window.bootstrap || !window.bootstrap.Modal) {
|
|
return null;
|
|
}
|
|
|
|
if (!switchCaseModalInstance) {
|
|
switchCaseModalInstance = window.bootstrap.Modal.getOrCreateInstance(modalEl);
|
|
}
|
|
return switchCaseModalInstance;
|
|
}
|
|
|
|
function switchCaseStatusMessage(html) {
|
|
const statusEl = byId('bbSwitchCaseStatus');
|
|
if (statusEl) {
|
|
statusEl.innerHTML = html;
|
|
}
|
|
}
|
|
|
|
function timerDisplayName(timer) {
|
|
const sagId = Number((timer && timer.sag_id) || 0);
|
|
const title = (timer && (timer.sag_navn || timer.title || timer.beskrivelse || timer.desc)) || '';
|
|
if (title) {
|
|
return escapeHtml(title);
|
|
}
|
|
return sagId > 0 ? ('Sag #' + sagId) : 'Ukendt sag';
|
|
}
|
|
|
|
function renderSwitchCaseLists() {
|
|
const timersEl = byId('bbSwitchTimersList');
|
|
const recentEl = byId('bbSwitchRecentCasesList');
|
|
const actionsEl = byId('bbSwitchTimerActions');
|
|
if (!timersEl || !recentEl) {
|
|
return;
|
|
}
|
|
|
|
const active = Array.isArray(switchCaseState.timers.active) ? switchCaseState.timers.active : [];
|
|
const paused = Array.isArray(switchCaseState.timers.paused) ? switchCaseState.timers.paused : [];
|
|
const recentCases = Array.isArray(switchCaseState.recentCases) ? switchCaseState.recentCases : [];
|
|
const unassignedCases = Array.isArray(switchCaseState.unassignedCases) ? switchCaseState.unassignedCases : [];
|
|
const showUnassigned = unassignedCases.length > 0;
|
|
|
|
if (actionsEl) {
|
|
actionsEl.classList.toggle('d-none', !switchCaseState.activeTimer);
|
|
}
|
|
|
|
if (!active.length && !paused.length) {
|
|
timersEl.innerHTML = '<div class="list-group-item text-muted">Ingen aktive eller pausede timere.</div>';
|
|
} else {
|
|
let timerItems = '';
|
|
active.forEach(function (t) {
|
|
const timeId = Number((t && (t.id || t.time_entry_id)) || 0);
|
|
timerItems +=
|
|
'<div class="list-group-item d-flex justify-content-between align-items-start">' +
|
|
'<div><span class="badge text-bg-success me-2">Aktiv</span>' + timerDisplayName(t) + '</div>' +
|
|
'<button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + Number((t && t.sag_id) || 0) + '">Åbn sag</button>' +
|
|
'</div>';
|
|
if (timeId > 0) {
|
|
timerItems +=
|
|
'<div class="list-group-item small text-muted border-top-0 pt-0">Timer ID: ' + timeId + '</div>';
|
|
}
|
|
});
|
|
|
|
paused.forEach(function (t) {
|
|
timerItems +=
|
|
'<div class="list-group-item d-flex justify-content-between align-items-start">' +
|
|
'<div><span class="badge text-bg-warning me-2">Pauset</span>' + timerDisplayName(t) + '</div>' +
|
|
'<button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + Number((t && t.sag_id) || 0) + '">Åbn sag</button>' +
|
|
'</div>';
|
|
});
|
|
|
|
timersEl.innerHTML = timerItems;
|
|
}
|
|
|
|
const sourceCases = showUnassigned ? unassignedCases : recentCases;
|
|
const titleEl = byId('bbSwitchCaseModalLabel');
|
|
if (titleEl) {
|
|
titleEl.innerHTML = showUnassigned
|
|
? '<i class="bi bi-person-x me-2"></i>Uden ansvarlig (åbne sager)'
|
|
: '<i class="bi bi-arrow-left-right me-2"></i>Skift sag';
|
|
}
|
|
|
|
if (!sourceCases.length) {
|
|
recentEl.innerHTML = '<div class="list-group-item text-muted">Ingen sager at vise.</div>';
|
|
return;
|
|
}
|
|
|
|
recentEl.innerHTML = sourceCases.map(function (row) {
|
|
const caseId = Number((row && (row.sag_id || row.id)) || 0);
|
|
const title = escapeHtml((row && (row.titel || row.title)) || (caseId > 0 ? ('Sag #' + caseId) : 'Ukendt sag'));
|
|
const prefix = showUnassigned ? '<span class="badge text-bg-warning me-2">Uden ansvarlig</span>' : '<span class="badge text-bg-light border me-2">Senest</span>';
|
|
return (
|
|
'<div class="list-group-item d-flex justify-content-between align-items-start gap-2">' +
|
|
'<div class="me-2">' + prefix + title + '</div>' +
|
|
'<div class="d-flex gap-1">' +
|
|
'<button class="btn btn-sm btn-outline-primary" data-bb-open-case="' + caseId + '">Åbn</button>' +
|
|
'<button class="btn btn-sm btn-primary" data-bb-start-case="' + caseId + '">Start timer</button>' +
|
|
'</div>' +
|
|
'</div>'
|
|
);
|
|
}).join('');
|
|
}
|
|
|
|
async function loadSwitchCaseData(options) {
|
|
const opts = options || {};
|
|
switchCaseState.decision = 'unchanged';
|
|
switchCaseState.activeTimer = (((latestSections || {}).timer || {}).active || {}).active
|
|
? ((latestSections || {}).timer || {}).active
|
|
: null;
|
|
switchCaseState.unassignedCases = [];
|
|
switchCaseState.recentCases = [];
|
|
switchCaseState.timers = { active: [], paused: [] };
|
|
|
|
if (opts.onlyUnassigned) {
|
|
const unassigned = (((latestSections || {}).unassigned || {}).list || []);
|
|
switchCaseState.unassignedCases = Array.isArray(unassigned) ? unassigned.slice(0, 25) : [];
|
|
switchCaseStatusMessage('<i class="bi bi-info-circle me-1"></i>Viser kun åbne sager uden ansvarlig.');
|
|
renderSwitchCaseLists();
|
|
return;
|
|
}
|
|
|
|
switchCaseStatusMessage('<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Henter timer og seneste sager...');
|
|
|
|
const results = await Promise.allSettled([fetchSwitchableTimers(), fetchRecentCases()]);
|
|
|
|
const timersResult = results[0];
|
|
const casesResult = results[1];
|
|
|
|
if (timersResult.status === 'fulfilled') {
|
|
switchCaseState.timers = timersResult.value;
|
|
}
|
|
|
|
if (casesResult.status === 'fulfilled') {
|
|
switchCaseState.recentCases = casesResult.value;
|
|
}
|
|
|
|
const failedParts = [];
|
|
if (timersResult.status === 'rejected') failedParts.push('timere');
|
|
if (casesResult.status === 'rejected') failedParts.push('seneste sager');
|
|
|
|
if (failedParts.length) {
|
|
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-warning"></i>Kunne ikke hente ' + escapeHtml(failedParts.join(' + ')) + '. Viser det vi har.');
|
|
} else if (switchCaseState.activeTimer) {
|
|
const name = timerDisplayName(switchCaseState.activeTimer);
|
|
switchCaseStatusMessage('<i class="bi bi-stopwatch me-1"></i>Aktiv timer: ' + name + '. Vælg handling før du starter en ny timer.');
|
|
} else {
|
|
switchCaseStatusMessage('<i class="bi bi-check-circle me-1 text-success"></i>Klar til skift af sag.');
|
|
}
|
|
|
|
renderSwitchCaseLists();
|
|
}
|
|
|
|
async function openSwitchCaseModal(options) {
|
|
const modal = getSwitchCaseModal();
|
|
if (!modal) {
|
|
window.location.href = '/sag';
|
|
return;
|
|
}
|
|
|
|
modal.show();
|
|
try {
|
|
await loadSwitchCaseData(options);
|
|
} catch (err) {
|
|
console.warn('Switch case modal load failed', err);
|
|
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>Kunne ikke hente data til skift af sag.');
|
|
renderSwitchCaseLists();
|
|
}
|
|
}
|
|
|
|
function openUnassignedCasesPanel() {
|
|
window.location.href = '/sag?unassigned=1';
|
|
}
|
|
|
|
async function startTimerForCase(caseId) {
|
|
const validCaseId = Number(caseId || 0);
|
|
if (validCaseId <= 0) {
|
|
return;
|
|
}
|
|
|
|
const hasActiveTimer = !!(switchCaseState.activeTimer && switchCaseState.activeTimer.active);
|
|
if (hasActiveTimer && switchCaseState.decision === 'unchanged') {
|
|
switchCaseStatusMessage('<i class="bi bi-info-circle me-1 text-warning"></i>Du har en aktiv timer. Vælg Pause nu, Stop nu eller Fortsæt uændret først.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/v1/timetracking/time/start', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sag_id: validCaseId })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const payload = await response.json().catch(function () { return {}; });
|
|
const detail = (payload && payload.detail) ? payload.detail : ('HTTP ' + response.status);
|
|
throw new Error(typeof detail === 'string' ? detail : 'Kunne ikke starte timer');
|
|
}
|
|
|
|
const modal = getSwitchCaseModal();
|
|
if (modal) {
|
|
modal.hide();
|
|
}
|
|
window.location.href = '/sag/' + validCaseId;
|
|
} catch (err) {
|
|
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke starte timer for sag.'));
|
|
}
|
|
}
|
|
|
|
function openCaseDetail(caseId) {
|
|
const validCaseId = Number(caseId || 0);
|
|
if (validCaseId <= 0) {
|
|
return;
|
|
}
|
|
const modal = getSwitchCaseModal();
|
|
if (modal) {
|
|
modal.hide();
|
|
}
|
|
window.location.href = '/sag/' + validCaseId;
|
|
}
|
|
|
|
function resolveQuickNoteCaseId() {
|
|
const match = (window.location.pathname || '').match(/^\/sag\/(\d+)$/);
|
|
if (match && match[1]) {
|
|
return Number(match[1]);
|
|
}
|
|
|
|
const active = (((latestSections || {}).timer || {}).active || {});
|
|
const activeSagId = Number(active.sag_id || 0);
|
|
if (activeSagId > 0) {
|
|
return activeSagId;
|
|
}
|
|
|
|
const recent = (((latestSections || {}).recent_cases || {}).items || []);
|
|
const recentFirst = Number((((recent[0] || {}).id) || ((recent[0] || {}).sag_id) || 0));
|
|
return recentFirst > 0 ? recentFirst : 0;
|
|
}
|
|
|
|
function saveQuickNote(noteText, caseId) {
|
|
return fetch('/api/v1/sag/' + Number(caseId) + '/kommentarer', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ indhold: noteText })
|
|
}).then(function (res) {
|
|
if (!res.ok) {
|
|
throw new Error('Kunne ikke gemme note');
|
|
}
|
|
return res.json().catch(function () { return {}; });
|
|
});
|
|
}
|
|
|
|
function noteById(noteId) {
|
|
const notes = (((latestSections || {}).notes || {}).list || []);
|
|
return notes.find(function (row) { return Number((row || {}).id || 0) === Number(noteId || 0); }) || null;
|
|
}
|
|
|
|
async function readApiError(response, fallbackMessage) {
|
|
let payload = {};
|
|
try {
|
|
payload = await response.json();
|
|
} catch (e) {
|
|
payload = {};
|
|
}
|
|
const detail = payload && payload.detail ? payload.detail : null;
|
|
if (typeof detail === 'string' && detail.trim()) {
|
|
return detail;
|
|
}
|
|
return fallbackMessage;
|
|
}
|
|
|
|
async function fetchWithNotesFallback(url, options) {
|
|
const response = await fetch(url, options);
|
|
if (response.status !== 404 || !/\/api\/v1\/bottom-bar\/notes(\/|$)/.test(url)) {
|
|
return response;
|
|
}
|
|
|
|
const altUrl = url.endsWith('/') ? url.slice(0, -1) : (url + '/');
|
|
return fetch(altUrl, options);
|
|
}
|
|
|
|
async function createUserNote(title, content) {
|
|
const endpoint = '/api/v1/bottom-bar/notes';
|
|
const res = await fetchWithNotesFallback(endpoint, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: title || '', content: content || '' })
|
|
});
|
|
if (res.status === 404) {
|
|
notesApiUnavailable = true;
|
|
const now = new Date().toISOString();
|
|
const local = loadLocalNotes();
|
|
const created = {
|
|
id: Date.now(),
|
|
title: String(title || '').trim(),
|
|
content: String(content || '').trim(),
|
|
is_pinned: false,
|
|
is_archived: false,
|
|
created_at: now,
|
|
updated_at: now,
|
|
_local_only: true
|
|
};
|
|
local.unshift(created);
|
|
saveLocalNotes(local);
|
|
return created;
|
|
}
|
|
if (!res.ok) {
|
|
throw new Error(await readApiError(res, 'Kunne ikke oprette note (' + endpoint + ')'));
|
|
}
|
|
notesApiUnavailable = false;
|
|
return res.json().catch(function () { return {}; });
|
|
}
|
|
|
|
async function updateUserNote(noteId, payload) {
|
|
const endpoint = '/api/v1/bottom-bar/notes/' + Number(noteId || 0);
|
|
const res = await fetchWithNotesFallback(endpoint, {
|
|
method: 'PATCH',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload || {})
|
|
});
|
|
if (res.status === 404) {
|
|
notesApiUnavailable = true;
|
|
const local = loadLocalNotes();
|
|
const idNum = Number(noteId || 0);
|
|
const updated = local.map(function (row) {
|
|
if (Number((row || {}).id || 0) !== idNum) return row;
|
|
return {
|
|
...row,
|
|
...payload,
|
|
updated_at: new Date().toISOString(),
|
|
_local_only: true
|
|
};
|
|
});
|
|
saveLocalNotes(updated);
|
|
return updated.find(function (row) { return Number((row || {}).id || 0) === idNum; }) || {};
|
|
}
|
|
if (!res.ok) {
|
|
throw new Error(await readApiError(res, 'Kunne ikke opdatere note (' + endpoint + ')'));
|
|
}
|
|
notesApiUnavailable = false;
|
|
return res.json().catch(function () { return {}; });
|
|
}
|
|
|
|
async function deleteUserNote(noteId) {
|
|
const endpoint = '/api/v1/bottom-bar/notes/' + Number(noteId || 0);
|
|
const res = await fetchWithNotesFallback(endpoint, {
|
|
method: 'DELETE',
|
|
credentials: 'include'
|
|
});
|
|
if (res.status === 404) {
|
|
notesApiUnavailable = true;
|
|
const idNum = Number(noteId || 0);
|
|
const local = loadLocalNotes().filter(function (row) {
|
|
return Number((row || {}).id || 0) !== idNum;
|
|
});
|
|
saveLocalNotes(local);
|
|
return { status: 'deleted', note_id: idNum, _local_only: true };
|
|
}
|
|
if (!res.ok) {
|
|
throw new Error(await readApiError(res, 'Kunne ikke slette note (' + endpoint + ')'));
|
|
}
|
|
notesApiUnavailable = false;
|
|
return res.json().catch(function () { return {}; });
|
|
}
|
|
|
|
async function noteToCaseComment(noteId, caseId, excerpt) {
|
|
const endpoint = '/api/v1/bottom-bar/notes/' + Number(noteId || 0) + '/actions/sag-comment';
|
|
const res = await fetchWithNotesFallback(endpoint, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ sag_id: Number(caseId || 0), excerpt: excerpt || null })
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(await readApiError(res, 'Kunne ikke indsætte i sag-kommentar (' + endpoint + ')'));
|
|
}
|
|
return res.json().catch(function () { return {}; });
|
|
}
|
|
|
|
async function noteToContact(noteId, contactId, field, value, mode) {
|
|
const endpoint = '/api/v1/bottom-bar/notes/' + Number(noteId || 0) + '/actions/contact-update';
|
|
const res = await fetchWithNotesFallback(endpoint, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
contact_id: Number(contactId || 0),
|
|
field: String(field || ''),
|
|
value: value || '',
|
|
mode: mode || 'append'
|
|
})
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(await readApiError(res, 'Kunne ikke opdatere kontakt fra note (' + endpoint + ')'));
|
|
}
|
|
return res.json().catch(function () { return {}; });
|
|
}
|
|
|
|
async function noteToCustomer(noteId, customerId, field, value, mode) {
|
|
const endpoint = '/api/v1/bottom-bar/notes/' + Number(noteId || 0) + '/actions/customer-update';
|
|
const res = await fetchWithNotesFallback(endpoint, {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
customer_id: Number(customerId || 0),
|
|
field: String(field || ''),
|
|
value: value || '',
|
|
mode: mode || 'append'
|
|
})
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(await readApiError(res, 'Kunne ikke opdatere firma fra note (' + endpoint + ')'));
|
|
}
|
|
return res.json().catch(function () { return {}; });
|
|
}
|
|
|
|
function getNoteTargetModal() {
|
|
const modalEl = byId('bbNoteTargetModal');
|
|
if (!modalEl || !window.bootstrap || !window.bootstrap.Modal) {
|
|
return null;
|
|
}
|
|
if (!noteTargetModalInstance) {
|
|
noteTargetModalInstance = window.bootstrap.Modal.getOrCreateInstance(modalEl);
|
|
}
|
|
return noteTargetModalInstance;
|
|
}
|
|
|
|
function updateNoteTargetStatus(message, isError) {
|
|
const statusEl = byId('bbNoteTargetStatus');
|
|
if (!statusEl) {
|
|
return;
|
|
}
|
|
statusEl.textContent = String(message || '');
|
|
statusEl.classList.remove('text-muted', 'text-success', 'text-danger');
|
|
if (isError === true) {
|
|
statusEl.classList.add('text-danger');
|
|
return;
|
|
}
|
|
if (isError === false) {
|
|
statusEl.classList.add('text-success');
|
|
return;
|
|
}
|
|
statusEl.classList.add('text-muted');
|
|
}
|
|
|
|
function renderNoteTargetFieldOptions(target) {
|
|
const wrap = byId('bbNoteTargetFieldWrap');
|
|
const label = byId('bbNoteTargetFieldLabel');
|
|
const select = byId('bbNoteTargetFieldSelect');
|
|
if (!wrap || !label || !select) {
|
|
return;
|
|
}
|
|
|
|
if (target === 'case') {
|
|
wrap.classList.add('d-none');
|
|
select.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const options = target === 'contact'
|
|
? [
|
|
{ value: 'mobile', label: 'Mobile' },
|
|
{ value: 'phone', label: 'Telefon' },
|
|
{ value: 'email', label: 'Email' },
|
|
{ value: 'title', label: 'Titel' },
|
|
{ value: 'department', label: 'Afdeling' }
|
|
]
|
|
: [
|
|
{ value: 'note', label: 'Firma note' },
|
|
{ value: 'mobile_phone', label: 'Mobil' },
|
|
{ value: 'phone', label: 'Telefon' },
|
|
{ value: 'email', label: 'Email' },
|
|
{ value: 'address', label: 'Adresse' },
|
|
{ value: 'invoice_email', label: 'Faktura-email' }
|
|
];
|
|
|
|
wrap.classList.remove('d-none');
|
|
label.textContent = target === 'contact' ? 'Kontaktfelt' : 'Firmafelt';
|
|
select.innerHTML = options.map(function (item) {
|
|
return '<option value="' + escapeHtml(item.value) + '">' + escapeHtml(item.label) + '</option>';
|
|
}).join('');
|
|
}
|
|
|
|
function openNoteTargetModal(target, noteId) {
|
|
const modal = getNoteTargetModal();
|
|
if (!modal) {
|
|
return;
|
|
}
|
|
|
|
const note = noteById(noteId);
|
|
noteTargetState = {
|
|
target: String(target || 'case'),
|
|
noteId: Number(noteId || 0)
|
|
};
|
|
|
|
const titleEl = byId('bbNoteTargetModalLabel');
|
|
const idLabel = byId('bbNoteTargetIdLabel');
|
|
const idInput = byId('bbNoteTargetIdInput');
|
|
const textInput = byId('bbNoteTargetTextInput');
|
|
const submitBtn = byId('bbNoteTargetSubmitBtn');
|
|
|
|
if (titleEl) {
|
|
const targetTitle = noteTargetState.target === 'contact'
|
|
? 'kontakt'
|
|
: (noteTargetState.target === 'customer' ? 'firma' : 'sag-kommentar');
|
|
titleEl.innerHTML = '<i class="bi bi-journal-plus me-2"></i>Indsæt note i ' + escapeHtml(targetTitle);
|
|
}
|
|
|
|
if (idLabel) {
|
|
if (noteTargetState.target === 'contact') {
|
|
idLabel.textContent = 'Kontakt ID';
|
|
} else if (noteTargetState.target === 'customer') {
|
|
idLabel.textContent = 'Firma ID';
|
|
} else {
|
|
idLabel.textContent = 'Sag ID';
|
|
}
|
|
}
|
|
|
|
if (idInput) {
|
|
const defaultCaseId = resolveQuickNoteCaseId();
|
|
const defaultId = noteTargetState.target === 'case' && defaultCaseId > 0 ? defaultCaseId : 0;
|
|
idInput.value = defaultId > 0 ? String(defaultId) : '';
|
|
}
|
|
|
|
if (textInput) {
|
|
textInput.value = String((note && note.content) || '');
|
|
}
|
|
|
|
if (submitBtn) {
|
|
submitBtn.disabled = false;
|
|
submitBtn.dataset.target = noteTargetState.target;
|
|
submitBtn.dataset.noteId = String(noteTargetState.noteId);
|
|
}
|
|
|
|
renderNoteTargetFieldOptions(noteTargetState.target);
|
|
updateNoteTargetStatus('Vælg mål og indsæt tekst.', null);
|
|
modal.show();
|
|
}
|
|
|
|
function updateQuickNoteHint(message, isError) {
|
|
const hint = byId('bbQuickNoteHint');
|
|
quickNoteHintState.message = message;
|
|
quickNoteHintState.level = isError ? 'danger' : 'success';
|
|
if (!hint) {
|
|
return;
|
|
}
|
|
hint.textContent = message;
|
|
hint.classList.remove('text-muted');
|
|
hint.classList.toggle('text-danger', !!isError);
|
|
hint.classList.toggle('text-success', !isError);
|
|
}
|
|
|
|
function bindHeaderActions() {
|
|
const backBtn = byId('bbBackBtn');
|
|
const searchBtn = byId('bbSearchBtn');
|
|
const notificationsBtn = byId('bbNotificationsBtn');
|
|
const pauseBtn = byId('bbTimerPauseBtn');
|
|
const stopBtn = byId('bbTimerStopBtn');
|
|
const switchBtn = byId('bbTimerSwitchBtn');
|
|
const timerChip = byId('bbActiveTimerChip');
|
|
|
|
if (backBtn) {
|
|
backBtn.addEventListener('click', function () {
|
|
if (window.history.length > 1) {
|
|
window.history.back();
|
|
} else {
|
|
window.location.href = '/sag';
|
|
}
|
|
});
|
|
}
|
|
|
|
if (searchBtn) {
|
|
searchBtn.addEventListener('click', function () {
|
|
const trigger = byId('globalSearchBtn');
|
|
if (trigger) {
|
|
trigger.click();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (notificationsBtn) {
|
|
notificationsBtn.addEventListener('click', function () {
|
|
if (latestNotifications.length > 0) {
|
|
const first = latestNotifications[0] || {};
|
|
if (first.action) {
|
|
window.location.href = first.action;
|
|
return;
|
|
}
|
|
}
|
|
const trigger = byId('globalRemindersBtn');
|
|
if (trigger) {
|
|
trigger.click();
|
|
}
|
|
});
|
|
}
|
|
|
|
if (pauseBtn) {
|
|
pauseBtn.addEventListener('click', function () {
|
|
const activeTimer = (((latestSections || {}).timer || {}).active || {});
|
|
const own = (((latestSections || {}).timer || {}).own || {});
|
|
const paused = Array.isArray(own.paused) ? own.paused : [];
|
|
|
|
if (activeTimer.active) {
|
|
pauseActiveTimer()
|
|
.then(fetchBottomBarState)
|
|
.then(applyState)
|
|
.catch(function (err) {
|
|
console.warn('Failed pausing timer', err);
|
|
});
|
|
return;
|
|
}
|
|
|
|
const pausedTimeId = Number((((paused[0] || {}).time_entry_id) || ((paused[0] || {}).id) || 0));
|
|
resumeTimer(pausedTimeId || null)
|
|
.then(fetchBottomBarState)
|
|
.then(applyState)
|
|
.catch(function (err) {
|
|
console.warn('Failed resuming timer', err);
|
|
});
|
|
});
|
|
}
|
|
|
|
if (stopBtn) {
|
|
stopBtn.addEventListener('click', function () {
|
|
stopActiveTimer()
|
|
.then(fetchBottomBarState)
|
|
.then(applyState)
|
|
.catch(function (err) {
|
|
console.warn('Failed stopping timer', err);
|
|
});
|
|
});
|
|
}
|
|
|
|
if (switchBtn) {
|
|
switchBtn.addEventListener('click', function () {
|
|
openSwitchCaseModal();
|
|
});
|
|
}
|
|
|
|
if (timerChip) {
|
|
timerChip.addEventListener('click', function () {
|
|
window.location.href = '/timetracking';
|
|
});
|
|
}
|
|
|
|
document.addEventListener('click', function (e) {
|
|
const actionBtn = e.target && e.target.closest('[data-bb-create]');
|
|
if (!actionBtn) return;
|
|
|
|
const actionKey = actionBtn.getAttribute('data-bb-create');
|
|
const actions = (latestContextActions.context || []).concat(latestContextActions.global || []);
|
|
const matched = actions.find(function (item) { return item.id === actionKey; });
|
|
|
|
if (actionKey === 'new_case') {
|
|
const quickBtn = byId('quickCreateBtn');
|
|
if (quickBtn) {
|
|
quickBtn.click();
|
|
return;
|
|
}
|
|
}
|
|
if (matched && matched.action) {
|
|
window.location.href = matched.action;
|
|
}
|
|
});
|
|
}
|
|
|
|
function bindDynamicActions() {
|
|
document.addEventListener('click', function (e) {
|
|
const target = e.target;
|
|
const btn = target && target.closest('button');
|
|
if (!btn) return;
|
|
|
|
if (btn.id === 'bbNoteClearBtn') {
|
|
noteEditorState = { editingId: 0, title: '', content: '' };
|
|
renderTabPanel();
|
|
return;
|
|
}
|
|
|
|
if (btn.id === 'bbNoteSaveBtn') {
|
|
const titleInput = byId('bbNoteTitleInput');
|
|
const contentInput = byId('bbNoteContentInput');
|
|
const title = titleInput ? String(titleInput.value || '').trim() : '';
|
|
const content = contentInput ? String(contentInput.value || '').trim() : '';
|
|
if (!content) {
|
|
const detail = byId('bbCountDetail');
|
|
if (detail) {
|
|
detail.innerHTML = '<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 () {
|
|
switchCaseState.decision = 'pause';
|
|
switchCaseState.activeTimer = null;
|
|
switchCaseStatusMessage('<i class="bi bi-check-circle me-1 text-success"></i>Timer sat på pause. Du kan nu starte ny timer.');
|
|
return loadSwitchCaseData();
|
|
}).catch(function (err) {
|
|
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>' + escapeHtml(err.message || 'Kunne ikke pause timer.'));
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (switchAction === 'stop-now') {
|
|
stopActiveTimer().then(function () {
|
|
switchCaseState.decision = 'stop';
|
|
switchCaseState.activeTimer = null;
|
|
switchCaseStatusMessage('<i class="bi bi-check-circle me-1 text-success"></i>Aktiv timer stoppet. Du kan nu starte ny timer.');
|
|
return loadSwitchCaseData();
|
|
}).catch(function () {
|
|
switchCaseStatusMessage('<i class="bi bi-exclamation-triangle me-1 text-danger"></i>Kunne ikke stoppe timer.');
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
const openCaseId = Number(btn.getAttribute('data-bb-open-case') || 0);
|
|
if (openCaseId > 0) {
|
|
openCaseDetail(openCaseId);
|
|
return;
|
|
}
|
|
|
|
const startCaseId = Number(btn.getAttribute('data-bb-start-case') || 0);
|
|
if (startCaseId > 0) {
|
|
startTimerForCase(startCaseId);
|
|
return;
|
|
}
|
|
|
|
const stopTimeId = Number(btn.getAttribute('data-bb-stop-time') || 0);
|
|
if (stopTimeId > 0) {
|
|
fetch('/api/v1/timetracking/time/stop', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ time_id: stopTimeId })
|
|
})
|
|
.then(fetchBottomBarState)
|
|
.then(applyState)
|
|
.catch(function (err) {
|
|
console.warn('Failed stopping timer from list', err);
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (btn.id === 'bbQuickNoteSaveBtn') {
|
|
const input = byId('bbQuickNoteInput');
|
|
const value = input ? String(input.value || '').trim() : '';
|
|
quickNoteDraft = value;
|
|
if (!value) {
|
|
updateQuickNoteHint('Skriv en note først.', true);
|
|
return;
|
|
}
|
|
|
|
const caseId = resolveQuickNoteCaseId();
|
|
if (!(caseId > 0)) {
|
|
updateQuickNoteHint('Åbn en sag (eller start timer på en sag) før du gemmer quick note.', true);
|
|
return;
|
|
}
|
|
|
|
const originalHtml = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Gemmer...';
|
|
|
|
saveQuickNote(value, caseId)
|
|
.then(function () {
|
|
if (input) {
|
|
input.value = '';
|
|
}
|
|
quickNoteDraft = '';
|
|
updateQuickNoteHint('Gemte note på sag #' + caseId + '.', false);
|
|
const detail = byId('bbCountDetail');
|
|
if (detail) {
|
|
detail.innerHTML = '<i class="bi bi-check-circle me-1 text-success"></i> Quick note gemt på sag #' + caseId;
|
|
}
|
|
})
|
|
.catch(function (err) {
|
|
updateQuickNoteHint((err && err.message) ? err.message : 'Kunne ikke gemme note.', true);
|
|
})
|
|
.finally(function () {
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalHtml;
|
|
});
|
|
return;
|
|
}
|
|
|
|
const bossAction = btn.getAttribute('data-boss-action');
|
|
if (bossAction) {
|
|
if (bossAction === 'assign_next_to_owner') {
|
|
const ownerId = Number(btn.getAttribute('data-owner-id') || 0);
|
|
if (ownerId <= 0) {
|
|
return;
|
|
}
|
|
|
|
const originalHtml = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Tildeler...';
|
|
|
|
fetch('/api/v1/bottom-bar/boss/assign-next-to-user', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ assignee_user_id: ownerId })
|
|
})
|
|
.then(async r => {
|
|
const body = await r.json().catch(() => ({}));
|
|
if (!r.ok) {
|
|
const detail = body && body.detail ? body.detail : 'Kunne ikke tildele næste sag';
|
|
throw new Error(typeof detail === 'string' ? detail : 'Kunne ikke tildele næste sag');
|
|
}
|
|
return body;
|
|
})
|
|
.then(data => {
|
|
const detail = byId('bbCountDetail');
|
|
if (detail) {
|
|
if (data.status === 'assigned' && data.case) {
|
|
detail.innerHTML = '<i class="bi bi-check-circle me-1 text-success"></i> Tildelt: ' + escapeHtml(data.case.title || 'Sag') + ' til tekniker.';
|
|
} else {
|
|
detail.innerHTML = '<i class="bi bi-info-circle me-1 text-accent"></i> ' + escapeHtml(data.message || 'Ingen sager at tildele');
|
|
}
|
|
}
|
|
return fetchBottomBarState();
|
|
})
|
|
.then(applyState)
|
|
.catch(err => {
|
|
console.error('Assign next to owner failed', err);
|
|
const detail = byId('bbCountDetail');
|
|
if (detail) {
|
|
detail.innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-danger"></i> ' + escapeHtml(err.message || 'Fejl ved tildeling');
|
|
}
|
|
})
|
|
.finally(() => {
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalHtml;
|
|
});
|
|
return;
|
|
}
|
|
if (bossAction === 'auto_assign_next') {
|
|
const originalHtml = btn.innerHTML;
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>Fordeler...';
|
|
|
|
fetch('/api/v1/bottom-bar/boss/auto-assign-next', {
|
|
method: 'POST',
|
|
credentials: 'include'
|
|
})
|
|
.then(async r => {
|
|
const body = await r.json().catch(() => ({}));
|
|
if (!r.ok) {
|
|
const detail = body && body.detail ? body.detail : 'Kunne ikke auto-fordele sag';
|
|
throw new Error(typeof detail === 'string' ? detail : 'Kunne ikke auto-fordele sag');
|
|
}
|
|
return body;
|
|
})
|
|
.then(data => {
|
|
const detail = byId('bbCountDetail');
|
|
if (detail) {
|
|
if (data.status === 'assigned' && data.case && data.assignee) {
|
|
detail.innerHTML = '<i class="bi bi-check-circle me-1 text-success"></i> Auto-fordelt: ' + escapeHtml(data.case.title || 'Sag') + ' → ' + escapeHtml(data.assignee.name || 'medarbejder');
|
|
} else {
|
|
detail.innerHTML = '<i class="bi bi-info-circle me-1 text-accent"></i> ' + escapeHtml(data.message || 'Ingen sager at fordele');
|
|
}
|
|
}
|
|
return fetchBottomBarState();
|
|
})
|
|
.then(applyState)
|
|
.catch(err => {
|
|
console.error('Boss auto-assign failed', err);
|
|
const detail = byId('bbCountDetail');
|
|
if (detail) {
|
|
detail.innerHTML = '<i class="bi bi-exclamation-triangle me-1 text-danger"></i> ' + escapeHtml(err.message || 'Fejl ved auto-fordeling');
|
|
}
|
|
})
|
|
.finally(() => {
|
|
btn.disabled = false;
|
|
btn.innerHTML = originalHtml;
|
|
});
|
|
return;
|
|
}
|
|
if (bossAction === 'open_unassigned') {
|
|
openUnassignedCasesPanel();
|
|
return;
|
|
}
|
|
if (bossAction === 'open_escalations') {
|
|
window.location.href = '/sag?priority=urgent';
|
|
return;
|
|
}
|
|
if (bossAction === 'open_team') {
|
|
window.location.href = '/timetracking';
|
|
return;
|
|
}
|
|
if (bossAction === 'open_owner') {
|
|
const ownerId = Number(btn.getAttribute('data-owner-id') || 0);
|
|
window.location.href = ownerId > 0 ? ('/sag?ansvarlig=' + ownerId) : '/sag';
|
|
return;
|
|
}
|
|
if (bossAction === 'open_case') {
|
|
const caseId = Number(btn.getAttribute('data-case-id') || 0);
|
|
window.location.href = caseId > 0 ? ('/sag/' + caseId) : '/sag';
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (btn.id === 'btnNextTask') {
|
|
console.log("-> Beder backend om næste opgave...");
|
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> Omsætter kalender og SLA...';
|
|
btn.disabled = true;
|
|
|
|
fetch('/api/v1/bottom-bar/next_task', { method: 'POST', credentials: 'include' })
|
|
.then(r => {
|
|
if (!r.ok) {
|
|
throw new Error('Kunne ikke hente næste opgave');
|
|
}
|
|
return r.json();
|
|
})
|
|
.then(data => {
|
|
const task = data && data.task ? data.task : {};
|
|
const taskTitle = task.title || 'Ingen opgave fundet';
|
|
const caseId = task.case_id || '-';
|
|
const freeMins = data && data.free_time_calculated ? data.free_time_calculated : 0;
|
|
btn.innerHTML = '<i class="bi bi-magic me-2"></i>Du fik tildelt: ' + taskTitle + ' (Sag #' + caseId + ') <span class="badge bg-light text-dark ms-2">' + freeMins + 'm fri</span>';
|
|
btn.classList.add('btn-success');
|
|
btn.classList.remove('btn-primary');
|
|
})
|
|
.catch(err => {
|
|
console.error("Fejl:", err);
|
|
btn.innerHTML = "Fejl - prøv igen";
|
|
btn.disabled = false;
|
|
});
|
|
}
|
|
|
|
if (btn.id === 'btnSendMsg') {
|
|
const input = document.getElementById('chatInputQuick');
|
|
const recipientObj = document.getElementById('chatRecipient');
|
|
|
|
if (input && input.value.trim() !== '') {
|
|
if (recipientObj && recipientObj.selectedOptions && recipientObj.selectedOptions[0] && recipientObj.selectedOptions[0].disabled) {
|
|
alert('Brugerlisten kunne ikke indlæses endnu.');
|
|
return;
|
|
}
|
|
const recipientValue = recipientObj ? recipientObj.value : 'all';
|
|
const msgVal = input.value.trim();
|
|
chatComposerState.draft = msgVal;
|
|
chatComposerState.recipient = recipientValue || 'all';
|
|
chatComposerState.activeThreadKey = chatComposerState.recipient === 'all'
|
|
? 'broadcast'
|
|
: ('user:' + chatComposerState.recipient);
|
|
const requiresManualAck = !!chatComposerState.requiresManualAck;
|
|
btn.disabled = true;
|
|
|
|
fetch('/api/v1/bottom-bar/messages', {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
body: JSON.stringify({
|
|
message: msgVal,
|
|
recipient_user_id: recipientValue && recipientValue !== 'all' ? Number(recipientValue) : null,
|
|
requires_manual_ack: requiresManualAck
|
|
})
|
|
})
|
|
.then(async r => {
|
|
if (!r.ok) {
|
|
let detail = 'Kunne ikke sende besked';
|
|
try {
|
|
const payload = await r.json();
|
|
detail = payload.detail || payload.message || detail;
|
|
} catch (_) {
|
|
// Ignore parse failure
|
|
}
|
|
throw new Error(detail);
|
|
}
|
|
return r.json();
|
|
})
|
|
.then(() => {
|
|
input.value = '';
|
|
chatComposerState.draft = '';
|
|
chatComposerState.requiresManualAck = false;
|
|
clearChatReplyState();
|
|
return fetchBottomBarState();
|
|
})
|
|
.then((data) => {
|
|
applyState(data);
|
|
if (activeKey === 'messages') {
|
|
renderTabPanel();
|
|
}
|
|
const tabInner = document.getElementById('bbTabInnerContent');
|
|
if (tabInner) {
|
|
tabInner.scrollTop = tabInner.scrollHeight + 500;
|
|
}
|
|
})
|
|
.catch(err => {
|
|
console.error('Fejl ved afsendelse af bundmenu-besked:', err);
|
|
alert(err.message || 'Kunne ikke sende besked');
|
|
})
|
|
.finally(() => {
|
|
btn.disabled = false;
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keydown', function (e) {
|
|
if (e.key !== 'Enter') {
|
|
return;
|
|
}
|
|
const input = byId('bbQuickNoteInput');
|
|
if (!input || document.activeElement !== input) {
|
|
return;
|
|
}
|
|
e.preventDefault();
|
|
const saveBtn = byId('bbQuickNoteSaveBtn');
|
|
if (saveBtn) {
|
|
saveBtn.click();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('input', function (e) {
|
|
const target = e.target;
|
|
if (!target || target.id !== 'bbQuickNoteInput') {
|
|
if (target && target.id === 'bbNoteTitleInput') {
|
|
noteEditorState.title = String(target.value || '');
|
|
}
|
|
if (target && target.id === 'bbNoteContentInput') {
|
|
noteEditorState.content = String(target.value || '');
|
|
}
|
|
return;
|
|
}
|
|
quickNoteDraft = String(target.value || '');
|
|
if (quickNoteHintState.level !== 'muted') {
|
|
quickNoteHintState = {
|
|
message: 'Tip: gemmer som kommentar på aktiv/åben sag.',
|
|
level: 'muted'
|
|
};
|
|
const hint = byId('bbQuickNoteHint');
|
|
if (hint) {
|
|
hint.textContent = quickNoteHintState.message;
|
|
hint.classList.remove('text-danger', 'text-success');
|
|
hint.classList.add('text-muted');
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
activeKey = 'overview'; // Default overview state
|
|
bindChipClicks();
|
|
bindChipHoverPreview();
|
|
bindSheetToggle();
|
|
bindHeaderActions();
|
|
bindDynamicActions();
|
|
|
|
bindSideTabs();
|
|
|
|
startPollingFallback();
|
|
connectRealtime();
|
|
});
|
|
|
|
})();
|