1020 lines
45 KiB
JavaScript
1020 lines
45 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 = [];
|
|
|
|
function byId(id) {
|
|
return document.getElementById(id);
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
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 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',
|
|
kuma: 'Kuma',
|
|
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 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();
|
|
});
|
|
|
|
})();
|