577 lines
18 KiB
HTML
577 lines
18 KiB
HTML
{% extends "shared/frontend/base.html" %}
|
|
|
|
{% block title %}Mission Control - BMC Hub{% endblock %}
|
|
|
|
{% block extra_css %}
|
|
<style>
|
|
:root {
|
|
--mc-bg: #0b1320;
|
|
--mc-surface: #121d2f;
|
|
--mc-surface-2: #16243a;
|
|
--mc-border: #2c3c58;
|
|
--mc-text: #e9f1ff;
|
|
--mc-text-muted: #9fb3d1;
|
|
--mc-danger: #ef4444;
|
|
--mc-warning: #f59e0b;
|
|
--mc-success: #10b981;
|
|
--mc-info: #3b82f6;
|
|
}
|
|
|
|
body {
|
|
background: var(--mc-bg) !important;
|
|
color: var(--mc-text);
|
|
}
|
|
|
|
main.container-fluid {
|
|
max-width: 100% !important;
|
|
padding: 0.75rem 1rem 1rem 1rem !important;
|
|
}
|
|
|
|
.mc-grid {
|
|
display: grid;
|
|
gap: 0.75rem;
|
|
grid-template-rows: auto 1fr auto;
|
|
min-height: calc(100vh - 90px);
|
|
}
|
|
|
|
.mc-card {
|
|
background: linear-gradient(180deg, var(--mc-surface) 0%, var(--mc-surface-2) 100%);
|
|
border: 1px solid var(--mc-border);
|
|
border-radius: 14px;
|
|
padding: 0.75rem 1rem;
|
|
}
|
|
|
|
.mc-top {
|
|
display: grid;
|
|
grid-template-columns: 1fr;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.mc-alert-bar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
font-size: 1.15rem;
|
|
font-weight: 700;
|
|
padding: 0.9rem 1rem;
|
|
border-radius: 12px;
|
|
}
|
|
|
|
.mc-alert-bar.down {
|
|
background: rgba(239, 68, 68, 0.18);
|
|
border: 1px solid rgba(239, 68, 68, 0.55);
|
|
color: #ffd6d6;
|
|
}
|
|
|
|
.mc-alert-empty {
|
|
color: var(--mc-text-muted);
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.mc-middle {
|
|
display: grid;
|
|
grid-template-columns: 2fr 1fr;
|
|
gap: 0.75rem;
|
|
min-height: 0;
|
|
}
|
|
|
|
.mc-kpi-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(5, minmax(0, 1fr));
|
|
gap: 0.65rem;
|
|
}
|
|
|
|
.mc-kpi {
|
|
background: rgba(255, 255, 255, 0.03);
|
|
border: 1px solid var(--mc-border);
|
|
border-radius: 12px;
|
|
padding: 0.85rem;
|
|
}
|
|
|
|
.mc-kpi .label {
|
|
font-size: 0.8rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
color: var(--mc-text-muted);
|
|
}
|
|
|
|
.mc-kpi .value {
|
|
font-size: 2rem;
|
|
line-height: 1;
|
|
font-weight: 800;
|
|
margin-top: 0.45rem;
|
|
}
|
|
|
|
.mc-kpi.warning { border-color: rgba(245, 158, 11, 0.55); }
|
|
.mc-kpi.danger { border-color: rgba(239, 68, 68, 0.55); }
|
|
|
|
.mc-call-overlay {
|
|
display: none;
|
|
margin-top: 0.75rem;
|
|
background: rgba(59, 130, 246, 0.14);
|
|
border: 2px solid rgba(59, 130, 246, 0.65);
|
|
border-radius: 14px;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.mc-call-overlay.active {
|
|
display: block;
|
|
}
|
|
|
|
.mc-call-title {
|
|
font-size: 1.7rem;
|
|
font-weight: 800;
|
|
margin-bottom: 0.35rem;
|
|
}
|
|
|
|
.mc-call-meta {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 0.5rem;
|
|
font-size: 1.05rem;
|
|
}
|
|
|
|
.mc-badge {
|
|
border-radius: 999px;
|
|
border: 1px solid var(--mc-border);
|
|
background: rgba(255, 255, 255, 0.05);
|
|
padding: 0.18rem 0.55rem;
|
|
font-size: 0.85rem;
|
|
color: var(--mc-text-muted);
|
|
}
|
|
|
|
.mc-bottom {
|
|
display: grid;
|
|
grid-template-columns: 1.2fr 1fr;
|
|
gap: 0.75rem;
|
|
min-height: 0;
|
|
}
|
|
|
|
.mc-table,
|
|
.mc-feed {
|
|
max-height: 30vh;
|
|
overflow: auto;
|
|
}
|
|
|
|
.mc-row {
|
|
display: grid;
|
|
grid-template-columns: 2fr 1fr 1fr;
|
|
gap: 0.5rem;
|
|
padding: 0.4rem 0;
|
|
border-bottom: 1px solid rgba(159, 179, 209, 0.12);
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.mc-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.mc-feed-item {
|
|
padding: 0.5rem 0;
|
|
border-bottom: 1px solid rgba(159, 179, 209, 0.12);
|
|
}
|
|
|
|
.mc-feed-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.mc-feed-title {
|
|
font-weight: 600;
|
|
}
|
|
|
|
.mc-feed-meta {
|
|
color: var(--mc-text-muted);
|
|
font-size: 0.8rem;
|
|
}
|
|
|
|
.mc-controls {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.7rem;
|
|
flex-wrap: wrap;
|
|
margin-top: 0.4rem;
|
|
}
|
|
|
|
.mc-controls label {
|
|
color: var(--mc-text-muted);
|
|
font-size: 0.85rem;
|
|
}
|
|
|
|
.mc-connection {
|
|
font-size: 0.8rem;
|
|
color: var(--mc-text-muted);
|
|
}
|
|
|
|
.mc-hidden {
|
|
display: none !important;
|
|
}
|
|
|
|
@media (max-width: 1300px) {
|
|
.mc-middle,
|
|
.mc-bottom {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.mc-kpi-grid {
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
}
|
|
}
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="mc-grid">
|
|
<section class="mc-top">
|
|
<div class="mc-card">
|
|
<div id="alertContainer" class="mc-alert-empty">Ingen aktive driftsalarmer</div>
|
|
<div class="mc-controls">
|
|
<label><input type="checkbox" id="soundEnabledToggle" checked> Lyd aktiv</label>
|
|
<label>Lydniveau <input type="range" id="soundVolume" min="0" max="100" value="70"></label>
|
|
<span id="connectionState" class="mc-connection">Forbinder...</span>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="mc-middle">
|
|
<div class="mc-card">
|
|
<h4 class="mb-3">Opgave-overblik</h4>
|
|
<div id="kpiGrid" class="mc-kpi-grid"></div>
|
|
<div id="callOverlay" class="mc-call-overlay">
|
|
<div class="mc-call-title">Indgående opkald</div>
|
|
<div id="callPrimary" style="font-size:1.35rem;font-weight:700;"></div>
|
|
<div id="callSecondary" class="mc-call-meta mt-2"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mc-card">
|
|
<h4 class="mb-3">Aktive opkald</h4>
|
|
<div id="activeCallsList" class="mc-feed"></div>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="mc-bottom">
|
|
<div class="mc-card">
|
|
<h4 class="mb-3">Deadlines pr. medarbejder</h4>
|
|
<div class="mc-row" style="font-weight:700;color:var(--mc-text-muted);text-transform:uppercase;font-size:0.75rem;">
|
|
<div>Medarbejder</div>
|
|
<div>I dag</div>
|
|
<div>Overskredet</div>
|
|
</div>
|
|
<div id="deadlineTable" class="mc-table"></div>
|
|
</div>
|
|
|
|
<div class="mc-card">
|
|
<h4 class="mb-3">Live aktivitetsfeed</h4>
|
|
<div id="liveFeed" class="mc-feed"></div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<script>
|
|
(() => {
|
|
const kpiLabels = {
|
|
open_cases: 'Åbne sager',
|
|
new_cases: 'Nye sager',
|
|
unassigned_cases: 'Uden ansvarlig',
|
|
deadlines_today: 'Deadline i dag',
|
|
overdue_deadlines: 'Overskredne'
|
|
};
|
|
|
|
const state = {
|
|
ws: null,
|
|
reconnectAttempts: 0,
|
|
reconnectTimer: null,
|
|
failures: 0,
|
|
config: {
|
|
sound_enabled: true,
|
|
sound_volume: 70,
|
|
sound_events: ['incoming_call', 'uptime_down', 'critical_event'],
|
|
kpi_visible: Object.keys(kpiLabels),
|
|
display_queues: []
|
|
},
|
|
activeCalls: [],
|
|
activeAlerts: [],
|
|
liveFeed: []
|
|
};
|
|
|
|
function updateConnectionLabel(text) {
|
|
const el = document.getElementById('connectionState');
|
|
if (el) el.textContent = text;
|
|
}
|
|
|
|
function playTone(type) {
|
|
const soundEnabledToggle = document.getElementById('soundEnabledToggle');
|
|
if (!soundEnabledToggle || !soundEnabledToggle.checked) return;
|
|
|
|
if (!state.config.sound_events.includes(type)) return;
|
|
|
|
const volumeSlider = document.getElementById('soundVolume');
|
|
const volumePct = Number(volumeSlider?.value || state.config.sound_volume || 70);
|
|
const gainValue = Math.max(0, Math.min(1, volumePct / 100));
|
|
|
|
const AudioCtx = window.AudioContext || window.webkitAudioContext;
|
|
if (!AudioCtx) return;
|
|
|
|
const context = new AudioCtx();
|
|
const oscillator = context.createOscillator();
|
|
const gainNode = context.createGain();
|
|
|
|
oscillator.type = 'sine';
|
|
oscillator.frequency.value = type === 'uptime_down' ? 260 : 620;
|
|
gainNode.gain.value = gainValue * 0.2;
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(context.destination);
|
|
oscillator.start();
|
|
oscillator.stop(context.currentTime + (type === 'uptime_down' ? 0.35 : 0.15));
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return String(str ?? '')
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
function formatDate(value) {
|
|
if (!value) return '-';
|
|
const d = new Date(value);
|
|
if (Number.isNaN(d.getTime())) return '-';
|
|
return d.toLocaleString('da-DK');
|
|
}
|
|
|
|
function renderKpis(kpis = {}) {
|
|
const container = document.getElementById('kpiGrid');
|
|
if (!container) return;
|
|
|
|
const visible = Array.isArray(state.config.kpi_visible) && state.config.kpi_visible.length
|
|
? state.config.kpi_visible
|
|
: Object.keys(kpiLabels);
|
|
|
|
container.innerHTML = visible.map((key) => {
|
|
const value = Number(kpis[key] ?? 0);
|
|
const variant = key === 'overdue_deadlines' && value > 0
|
|
? 'danger'
|
|
: key === 'deadlines_today' && value > 0
|
|
? 'warning'
|
|
: '';
|
|
return `
|
|
<div class="mc-kpi ${variant}">
|
|
<div class="label">${escapeHtml(kpiLabels[key] || key)}</div>
|
|
<div class="value">${value}</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function renderActiveCalls() {
|
|
const list = document.getElementById('activeCallsList');
|
|
const overlay = document.getElementById('callOverlay');
|
|
const primary = document.getElementById('callPrimary');
|
|
const secondary = document.getElementById('callSecondary');
|
|
|
|
if (!list || !overlay || !primary || !secondary) return;
|
|
|
|
const queueFilter = Array.isArray(state.config.display_queues) ? state.config.display_queues : [];
|
|
const calls = state.activeCalls.filter(c => {
|
|
if (!queueFilter.length) return true;
|
|
return queueFilter.includes(c.queue_name);
|
|
});
|
|
|
|
if (!calls.length) {
|
|
list.innerHTML = '<div class="mc-feed-meta">Ingen aktive opkald</div>';
|
|
overlay.classList.remove('active');
|
|
return;
|
|
}
|
|
|
|
const call = calls[0];
|
|
overlay.classList.add('active');
|
|
primary.textContent = `${call.queue_name || 'Ukendt kø'} • ${call.caller_number || 'Ukendt nummer'}`;
|
|
secondary.innerHTML = [
|
|
call.contact_name ? `<span class="mc-badge">${escapeHtml(call.contact_name)}</span>` : '',
|
|
call.company_name ? `<span class="mc-badge">${escapeHtml(call.company_name)}</span>` : '',
|
|
call.customer_tag ? `<span class="mc-badge">${escapeHtml(call.customer_tag)}</span>` : '',
|
|
call.started_at ? `<span class="mc-badge">${escapeHtml(formatDate(call.started_at))}</span>` : ''
|
|
].join(' ');
|
|
|
|
list.innerHTML = calls.map((item) => `
|
|
<div class="mc-feed-item">
|
|
<div class="mc-feed-title">${escapeHtml(item.queue_name || 'Ukendt kø')} • ${escapeHtml(item.caller_number || '-')}</div>
|
|
<div class="mc-feed-meta">
|
|
${escapeHtml(item.contact_name || 'Ukendt kontakt')}
|
|
${item.company_name ? ` • ${escapeHtml(item.company_name)}` : ''}
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderAlerts() {
|
|
const container = document.getElementById('alertContainer');
|
|
if (!container) return;
|
|
|
|
if (!state.activeAlerts.length) {
|
|
container.className = 'mc-alert-empty';
|
|
container.textContent = 'Ingen aktive driftsalarmer';
|
|
return;
|
|
}
|
|
|
|
container.className = '';
|
|
container.innerHTML = state.activeAlerts.map((alert) => `
|
|
<div class="mc-alert-bar down mb-2">
|
|
<span>🚨</span>
|
|
<span>${escapeHtml(alert.service_name || 'Ukendt service')}</span>
|
|
${alert.customer_name ? `<span class="mc-badge">${escapeHtml(alert.customer_name)}</span>` : ''}
|
|
<span class="mc-badge">Start: ${escapeHtml(formatDate(alert.started_at))}</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderDeadlines(rows = []) {
|
|
const table = document.getElementById('deadlineTable');
|
|
if (!table) return;
|
|
if (!rows.length) {
|
|
table.innerHTML = '<div class="mc-feed-meta py-2">Ingen deadlines i dag eller overskredne</div>';
|
|
return;
|
|
}
|
|
table.innerHTML = rows.map((row) => `
|
|
<div class="mc-row">
|
|
<div>${escapeHtml(row.employee_name || 'Ukendt')}</div>
|
|
<div>${Number(row.deadlines_today || 0)}</div>
|
|
<div style="color:${Number(row.overdue_deadlines || 0) > 0 ? '#ff9d9d' : 'inherit'}">${Number(row.overdue_deadlines || 0)}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderFeed() {
|
|
const feed = document.getElementById('liveFeed');
|
|
if (!feed) return;
|
|
|
|
if (!state.liveFeed.length) {
|
|
feed.innerHTML = '<div class="mc-feed-meta">Ingen events endnu</div>';
|
|
return;
|
|
}
|
|
|
|
feed.innerHTML = state.liveFeed.slice(0, 20).map((event) => `
|
|
<div class="mc-feed-item">
|
|
<div class="mc-feed-title">${escapeHtml(event.title || event.event_type || 'Event')}</div>
|
|
<div class="mc-feed-meta">${escapeHtml(event.event_type || 'event')} • ${escapeHtml(formatDate(event.created_at))}</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderState(payload) {
|
|
if (!payload) return;
|
|
state.config = { ...state.config, ...(payload.config || {}) };
|
|
state.activeCalls = Array.isArray(payload.active_calls) ? payload.active_calls : state.activeCalls;
|
|
state.activeAlerts = Array.isArray(payload.active_alerts) ? payload.active_alerts : state.activeAlerts;
|
|
state.liveFeed = Array.isArray(payload.live_feed) ? payload.live_feed : state.liveFeed;
|
|
|
|
const soundToggle = document.getElementById('soundEnabledToggle');
|
|
const volumeSlider = document.getElementById('soundVolume');
|
|
if (soundToggle) soundToggle.checked = !!state.config.sound_enabled;
|
|
if (volumeSlider) volumeSlider.value = String(state.config.sound_volume || 70);
|
|
|
|
renderKpis(payload.kpis || {});
|
|
renderActiveCalls();
|
|
renderAlerts();
|
|
renderDeadlines(Array.isArray(payload.employee_deadlines) ? payload.employee_deadlines : []);
|
|
renderFeed();
|
|
}
|
|
|
|
async function loadInitialState() {
|
|
const res = await fetch('/api/v1/mission/state', { credentials: 'include' });
|
|
if (!res.ok) throw new Error('Kunne ikke hente mission state');
|
|
const payload = await res.json();
|
|
renderState(payload);
|
|
}
|
|
|
|
function scheduleReconnect() {
|
|
if (state.reconnectTimer) return;
|
|
state.reconnectAttempts += 1;
|
|
const delay = Math.min(30000, 1500 * state.reconnectAttempts);
|
|
updateConnectionLabel(`Frakoblet • reconnect om ${Math.round(delay / 1000)}s`);
|
|
state.reconnectTimer = setTimeout(() => {
|
|
state.reconnectTimer = null;
|
|
connectWs();
|
|
}, delay);
|
|
}
|
|
|
|
function connectWs() {
|
|
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
|
const url = `${proto}://${window.location.host}/api/v1/mission/ws`;
|
|
state.ws = new WebSocket(url);
|
|
|
|
state.ws.onopen = () => {
|
|
state.reconnectAttempts = 0;
|
|
updateConnectionLabel('Live forbindelse aktiv');
|
|
};
|
|
|
|
state.ws.onclose = () => {
|
|
state.failures += 1;
|
|
if (state.failures >= 12) {
|
|
window.location.reload();
|
|
return;
|
|
}
|
|
scheduleReconnect();
|
|
};
|
|
|
|
state.ws.onerror = () => {};
|
|
|
|
state.ws.onmessage = (evt) => {
|
|
try {
|
|
const msg = JSON.parse(evt.data);
|
|
const event = msg?.event;
|
|
const data = msg?.data || {};
|
|
|
|
if (event === 'mission_state') {
|
|
renderState(data);
|
|
return;
|
|
}
|
|
if (event === 'kpi_update') {
|
|
renderKpis(data);
|
|
return;
|
|
}
|
|
if (event === 'call_ringing') {
|
|
state.activeCalls = [data, ...state.activeCalls.filter(c => c.call_id !== data.call_id)];
|
|
renderActiveCalls();
|
|
playTone('incoming_call');
|
|
return;
|
|
}
|
|
if (event === 'call_answered' || event === 'call_hangup') {
|
|
const id = data.call_id;
|
|
state.activeCalls = state.activeCalls.filter(c => c.call_id !== id);
|
|
renderActiveCalls();
|
|
return;
|
|
}
|
|
if (event === 'uptime_alert') {
|
|
state.activeAlerts = Array.isArray(data.active_alerts) ? data.active_alerts : state.activeAlerts;
|
|
renderAlerts();
|
|
if ((data.status || '').toUpperCase() === 'DOWN') {
|
|
playTone('uptime_down');
|
|
}
|
|
return;
|
|
}
|
|
if (event === 'live_feed_event') {
|
|
state.liveFeed = [data, ...state.liveFeed.filter(item => item.id !== data.id)].slice(0, 20);
|
|
renderFeed();
|
|
}
|
|
} catch (error) {
|
|
console.error('Mission message parse failed', error);
|
|
}
|
|
};
|
|
}
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
try {
|
|
await loadInitialState();
|
|
} catch (error) {
|
|
updateConnectionLabel('Fejl ved initial load');
|
|
console.error(error);
|
|
}
|
|
connectWs();
|
|
});
|
|
})();
|
|
</script>
|
|
{% endblock %}
|