- Removed outdated design_forslag_top3_ny_side.html file. - Updated bottom-bar.js to add back button functionality for better navigation. - Introduced new sidebar layout in design_forslag_1_sidebar.html for enhanced information display. - Created design_forslag_2_kompakt.html featuring a compact action ribbon for streamlined interactions. - Developed design_forslag_3_kort.html implementing a widget cards dashboard for a cleaner overview of case details.
2113 lines
91 KiB
JavaScript
2113 lines
91 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
|
|
};
|
|
const LOCAL_NOTES_KEY = 'bmc_bottom_bar_notes_v1';
|
|
let notesApiUnavailable = false;
|
|
|
|
function byId(id) {
|
|
return document.getElementById(id);
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
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) {
|
|
throw new Error('Could not load bottom bar state');
|
|
}
|
|
|
|
return response.json();
|
|
}
|
|
|
|
function applyState(data) {
|
|
if (data && data.enabled) {
|
|
latestSections = data.sections || {};
|
|
latestSections = hydrateNotesFromLocalIfNeeded(latestSections);
|
|
latestContextActions = (latestSections.context_actions || { global: [], context: [] });
|
|
latestNotificationCount = Number((((data || {}).notifications || {}).count) || 0);
|
|
latestNotifications = (((data || {}).notifications || {}).items || []);
|
|
syncBossTabVisibility();
|
|
updateBar(latestSections);
|
|
updateActivityZone();
|
|
const focusedId = document.activeElement && document.activeElement.id;
|
|
const keepCurrentRender = activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput');
|
|
if (!keepCurrentRender) {
|
|
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 unassigned = sections.unassigned || {};
|
|
const timer = sections.timer || {};
|
|
const kuma = 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 === '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) + ' <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 (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" 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') {
|
|
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 === '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',
|
|
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',
|
|
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 = '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',
|
|
timer: '/timetracking',
|
|
cases: '/sag'
|
|
};
|
|
|
|
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() {
|
|
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();
|
|
const focusedId = document.activeElement && document.activeElement.id;
|
|
const quickNoteFocused = activeKey === 'overview' && focusedId === 'bbQuickNoteInput';
|
|
const noteEditorFocused = activeKey === 'notes' && (focusedId === 'bbNoteTitleInput' || focusedId === 'bbNoteContentInput');
|
|
if (!quickNoteFocused && !noteEditorFocused) {
|
|
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 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;
|
|
}
|
|
|
|
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() !== '') {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
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();
|
|
});
|
|
|
|
})();
|