bmc_hub/static/js/bottom-bar.js

1012 lines
44 KiB
JavaScript
Raw Normal View History

(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 = [];
function byId(id) {
return document.getElementById(id);
}
function escapeHtml(value) {
return String(value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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) {
throw new Error('Could not load bottom bar state');
}
return response.json();
}
function applyState(data) {
if (data && data.enabled) {
latestSections = data.sections || {};
latestContextActions = (latestSections.context_actions || { global: [], context: [] });
latestNotificationCount = Number((((data || {}).notifications || {}).count) || 0);
latestNotifications = (((data || {}).notifications || {}).items || []);
syncBossTabVisibility();
updateBar(latestSections);
updateActivityZone();
renderTabPanel();
setVisibility(true);
return;
}
setVisibility(false);
}
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');
}
}
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 kuma = 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),
kuma: Number(kuma.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',
kuma: 'Kuma alerts',
eset: 'ESET incidents'
};
const val = counts[key] || 0;
return nameMap[key] + ': ' + val;
}
function severityClassFor(key, value) {
const val = Number(value || 0);
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 timer = sections.timer || {};
const kuma = sections.kuma || {};
const eset = sections.eset || {};
const messages = sections.messages || {};
const tasks = sections.tasks || {};
const boss = sections.boss || {};
function esc(str) {
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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">Vis sag</button></div>') : ['Ingen hastesager.'];
if (overviewFilter === 'kuma') return kuma.list ? kuma.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) + '</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>'];
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 (kuma.down > 0) out.push('<div><i class="bi bi-activity text-warning"></i> Uptime Kuma nedetid: <strong>' + kuma.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" class="form-control form-control-sm" placeholder="Skriv en quick note..."><button class="btn btn-outline-secondary btn-sm"><i class="bi bi-pencil"></i> Gem Note</button></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"><i class="bi bi-stop-fill"></i> Stop</button></div>';
});
}
return ['Ingen aktive timere lige nu.'];
}
if (key === 'messages') {
if (messages.count > 0) {
return (messages.list || []).map(m => '<div><strong class="' + (m.from === 'System' ? 'text-primary' : 'text-accent') + '">' + esc(m.from) + ':</strong> ' + esc(m.text) + '</div>');
}
return ['Ingen nye beskeder.'];
}
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 === '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 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',
kuma: 'Kuma',
eset: 'ESET'
};
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 computed = Number(latestNotificationCount || ((latestSections.messages || {}).count || 0));
notifCount.textContent = String(computed);
}
}
function renderTabPanel() {
const titleContainer = byId('bbTabTitle');
const innerContent = byId('bbTabInnerContent');
if (!titleContainer || !innerContent) {
return;
}
const titleText = titleContainer.querySelector('.bb-tab-title-text');
const titleByKey = {
overview: 'Overblik',
timer: 'Timere',
messages: 'Beskeder',
tasks: 'Opgaver',
boss: 'Chef Dashboard'
};
const iconByKey = {
overview: 'bi-bell',
timer: 'bi-stopwatch',
messages: 'bi-chat-dots',
tasks: 'bi-calendar-check',
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 = 'd-flex flex-column h-100';
ul.classList.add('flex-grow-1', 'mb-3');
const replyBox = document.createElement('div');
replyBox.className = 'mt-2 border-top pt-2 border-primary-subtle';
replyBox.innerHTML = `
<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">Indlæser brugere...</option>
</select>
</div>
<div class="input-group">
<input type="text" id="chatInputQuick" class="form-control form-control-sm" placeholder="Skriv en besked...">
<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);
// Fetch users dynamically
fetch('/api/v1/users?is_active=true', { credentials: 'include' })
.then(r => r.json())
.then(payload => {
const users = Array.isArray(payload) ? payload : ((payload && payload.data && Array.isArray(payload.data)) ? payload.data : []);
const sel = document.getElementById('chatRecipient');
if (sel) {
sel.innerHTML = '<option value="all">Alle på vagt</option><option value="system">System (Bot)</option>';
users.forEach(u => {
sel.innerHTML += `<option value="${u.id}">${u.full_name || u.username || u.email || ('Bruger #' + u.id)}</option>`;
});
}
})
.catch(e => console.error("Error fetching users for chat:", e));
} 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',
unassigned: '/sag?unassigned=true',
timer: '/timetracking',
cases: '/sag'
};
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() {
fetchBottomBarState().then(function (data) {
applyState(data);
}).catch(function (err) {
console.warn('Bottom bar poll failed', err);
}).finally(function () {
pollTimer = window.setTimeout(pollOnce, 15000);
});
}
function startPollingFallback() {
stopPolling();
pollOnce();
}
function updateFromRealtimeEvent(payload) {
if (!payload || !payload.event) {
return;
}
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.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);
latestSections.boss.stats = latestSections.boss.stats || {};
latestSections.boss.stats.unassigned = Number(status.sager_unassigned || 0);
}
if (payload.event === 'notification_delta') {
const notifications = payload.data || {};
const items = Array.isArray(notifications.items) ? notifications.items : [];
latestSections.messages = latestSections.messages || {};
latestSections.tasks = latestSections.tasks || {};
latestSections.messages.count = items.length;
latestSections.messages.list = items.slice(0, 5).map(function (item) {
return {
from: (item.type || 'System').toString(),
text: (item.title || item.message || 'Notifikation').toString()
};
});
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);
updateActivityZone();
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 () {
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 bindHeaderActions() {
const searchBtn = byId('bbSearchBtn');
const notificationsBtn = byId('bbNotificationsBtn');
const userStatusBtn = byId('bbUserStatusBtn');
const pauseBtn = byId('bbTimerPauseBtn');
const stopBtn = byId('bbTimerStopBtn');
const switchBtn = byId('bbTimerSwitchBtn');
const timerChip = byId('bbActiveTimerChip');
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 (userStatusBtn) {
userStatusBtn.addEventListener('click', function () {
const trigger = document.querySelector('[data-bs-target="#profileModal"]');
if (trigger) {
trigger.click();
}
});
}
if (pauseBtn) {
pauseBtn.addEventListener('click', function () {
stopActiveTimer();
});
}
if (stopBtn) {
stopBtn.addEventListener('click', function () {
stopActiveTimer();
});
}
if (switchBtn) {
switchBtn.addEventListener('click', function () {
window.location.href = '/sag';
});
}
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;
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') {
window.location.href = '/sag?unassigned=true';
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() !== '') {
const recipient = recipientObj ? recipientObj.options[recipientObj.selectedIndex].text : 'Alle';
console.log("-> Sender besked til", recipient, ":", input.value);
const msgVal = input.value;
input.value = '';
const msgContainer = document.createElement('div');
msgContainer.className = 'mb-2 text-end';
msgContainer.innerHTML = '<div class="small text-muted mb-1 me-1" style="font-size:0.7rem;">Til: ' + escapeHtml(recipient) + '</div><div class="d-inline-block bg-primary text-white p-2 rounded-3 text-start shadow-sm" style="max-width: 85%;"><strong>Mig:</strong> ' + escapeHtml(msgVal) + '</div>';
const chatContainer = document.querySelector('#bbTabInnerContent .d-flex.flex-column.h-100');
if (!chatContainer) {
return;
}
const listUl = chatContainer.querySelector('ul.bb-tab-list');
if (!listUl) {
return;
}
listUl.appendChild(msgContainer);
// Simple hacky scroll down
const tabInner = document.getElementById('bbTabInnerContent');
if(tabInner) {
tabInner.scrollTop = tabInner.scrollHeight + 500;
}
}
}
});
}
async function loadUserStatus() {
const el = byId('bbUserStatusText');
if (!el) return;
try {
const res = await fetch('/api/v1/auth/me/profile', { credentials: 'include' });
if (!res.ok) return;
const payload = await res.json();
const user = (payload || {}).user || payload || {};
const name = user.full_name || user.username || user.email || 'Bruger';
el.textContent = name;
} catch (_err) {
// Ignore user status load errors to keep bar non-blocking.
}
}
document.addEventListener('DOMContentLoaded', function () {
activeKey = 'overview'; // Default overview state
bindChipClicks();
bindChipHoverPreview();
bindSheetToggle();
bindHeaderActions();
bindDynamicActions();
bindSideTabs();
loadUserStatus();
startPollingFallback();
connectRealtime();
});
})();