bmc_hub/app/dashboard/frontend/mission_control.html

1449 lines
46 KiB
HTML
Raw Normal View History

{% extends "shared/frontend/base.html" %}
{% block title %}Mission Control - BMC Hub{% endblock %}
{% block extra_css %}
<style>
:root {
--mc-bg: #081422;
--mc-surface: #10243a;
--mc-surface-2: #14304d;
--mc-border: #2c4564;
--mc-text: #e7f2ff;
--mc-text-muted: #9db5d2;
--mc-accent: #0f4c75;
--mc-accent-soft: rgba(15, 76, 117, 0.3);
--mc-danger: #ef4444;
--mc-warning: #f59e0b;
--mc-success: #10b981;
}
body {
background: radial-gradient(circle at 85% -10%, #1b3f63 0%, var(--mc-bg) 55%) !important;
color: var(--mc-text);
}
main.container-fluid {
max-width: 100% !important;
padding: 0.8rem 1rem 1.1rem 1rem !important;
}
.mc-shell {
display: grid;
grid-template-rows: auto auto 1fr auto;
gap: 0.85rem;
min-height: calc(100vh - 95px);
}
.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.85rem 1rem;
}
.mc-header {
display: grid;
gap: 0.7rem;
}
.mc-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.mc-title {
font-size: 1.5rem;
font-weight: 800;
letter-spacing: 0.01em;
}
.mc-subtle {
color: var(--mc-text-muted);
font-size: 0.9rem;
}
.mc-controls {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.mc-controls label {
color: var(--mc-text-muted);
font-size: 0.85rem;
}
.mc-nav {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 0.6rem;
}
.mc-nav-btn {
border: 1px solid var(--mc-border);
background: rgba(255, 255, 255, 0.03);
color: var(--mc-text);
border-radius: 12px;
min-height: 56px;
font-size: 1rem;
font-weight: 700;
letter-spacing: 0.01em;
transition: 0.15s ease;
}
.mc-nav-btn.active {
border-color: #6db5e5;
background: linear-gradient(180deg, rgba(31, 106, 157, 0.6), rgba(16, 55, 86, 0.7));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
}
.mc-nav-btn:focus-visible,
.mc-chip:focus-visible,
.mc-duration-btn:focus-visible {
outline: 2px solid #9ad8ff;
outline-offset: 2px;
}
.mc-filter-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.45rem;
}
.mc-chip {
border: 1px solid var(--mc-border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.03);
color: var(--mc-text-muted);
padding: 0.33rem 0.8rem;
font-size: 0.84rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.mc-chip.active {
color: #dff3ff;
border-color: #77b6df;
background: var(--mc-accent-soft);
}
.mc-view {
display: none;
}
.mc-view.active {
display: block;
animation: mcFadeIn 0.2s ease;
}
@keyframes mcFadeIn {
from { opacity: 0; transform: translateY(3px); }
to { opacity: 1; transform: translateY(0); }
}
.mc-view-grid {
display: grid;
grid-template-columns: 1.3fr 1fr;
gap: 0.8rem;
}
.mc-alert-box {
display: grid;
gap: 0.45rem;
}
.mc-alert {
display: flex;
align-items: center;
gap: 0.55rem;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.58);
background: rgba(239, 68, 68, 0.18);
padding: 0.58rem 0.75rem;
font-weight: 700;
}
.mc-alert-empty {
color: var(--mc-text-muted);
}
.mc-kpis {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0.6rem;
margin-top: 0.8rem;
}
.mc-kpi {
border: 1px solid rgba(157, 181, 210, 0.25);
border-radius: 12px;
padding: 0.7rem;
background: rgba(255, 255, 255, 0.03);
}
.mc-kpi .label {
color: var(--mc-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.75rem;
}
.mc-kpi .value {
margin-top: 0.3rem;
font-size: 1.7rem;
font-weight: 800;
line-height: 1;
}
.mc-kpi.warning {
border-color: rgba(245, 158, 11, 0.55);
}
.mc-kpi.danger {
border-color: rgba(239, 68, 68, 0.55);
}
.mc-call-hero {
margin-top: 0.75rem;
border-radius: 12px;
border: 1px solid var(--mc-border);
padding: 0.75rem;
background: rgba(59, 130, 246, 0.11);
}
.mc-call-hero-title {
font-size: 1.15rem;
font-weight: 800;
}
.mc-call-hero-sub {
color: var(--mc-text-muted);
margin-top: 0.25rem;
}
.mc-environment {
margin-top: 0.75rem;
border-top: 1px solid rgba(157, 181, 210, 0.18);
padding-top: 0.65rem;
}
.mc-environment-head {
font-size: 0.86rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--mc-text-muted);
margin-bottom: 0.35rem;
}
.mc-env-list {
display: grid;
gap: 0.35rem;
}
.mc-env-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.6rem;
border-bottom: 1px solid rgba(157, 181, 210, 0.12);
padding: 0.26rem 0;
}
.mc-env-row:last-child {
border-bottom: 0;
}
.mc-env-name {
font-size: 0.9rem;
font-weight: 600;
}
.mc-env-value {
font-size: 1.05rem;
font-weight: 800;
color: #c8e9ff;
}
.mc-env-meta {
color: var(--mc-text-muted);
font-size: 0.72rem;
}
.mc-camera-card {
display: grid;
gap: 0.6rem;
}
.mc-camera-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
flex-wrap: wrap;
}
.mc-camera-status {
font-size: 0.78rem;
border-radius: 999px;
border: 1px solid var(--mc-border);
padding: 0.2rem 0.56rem;
color: var(--mc-text-muted);
background: rgba(255, 255, 255, 0.04);
}
.mc-camera-status.live {
color: #c0ffe1;
border-color: rgba(16, 185, 129, 0.6);
background: rgba(16, 185, 129, 0.14);
}
.mc-camera-status.connecting {
color: #ffe6b4;
border-color: rgba(245, 158, 11, 0.55);
background: rgba(245, 158, 11, 0.14);
}
.mc-camera-status.error {
color: #ffd0d0;
border-color: rgba(239, 68, 68, 0.55);
background: rgba(239, 68, 68, 0.14);
}
.mc-camera-preview {
border: 1px solid var(--mc-border);
border-radius: 12px;
overflow: hidden;
background: #000;
min-height: 190px;
}
.mc-camera-preview img,
.mc-camera-preview iframe {
width: 100%;
min-height: 190px;
display: block;
border: 0;
object-fit: contain;
}
.mc-camera-preview.large {
min-height: 53vh;
}
.mc-camera-preview.large img,
.mc-camera-preview.large iframe {
min-height: 53vh;
}
.mc-camera-preview.empty {
display: flex;
align-items: center;
justify-content: center;
color: var(--mc-text-muted);
background: rgba(0, 0, 0, 0.35);
padding: 1rem;
text-align: center;
}
.mc-motion-pill {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.78rem;
border-radius: 999px;
border: 1px solid rgba(245, 158, 11, 0.5);
background: rgba(245, 158, 11, 0.15);
color: #ffdb9f;
padding: 0.23rem 0.55rem;
}
.mc-motion-pill.hidden {
display: none;
}
.mc-duration-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.45rem;
}
.mc-duration-label {
color: var(--mc-text-muted);
font-size: 0.84rem;
}
.mc-duration-btn {
border: 1px solid var(--mc-border);
border-radius: 9px;
background: rgba(255, 255, 255, 0.03);
color: var(--mc-text);
padding: 0.32rem 0.62rem;
font-size: 0.84rem;
font-weight: 700;
min-width: 54px;
}
.mc-duration-btn.active {
border-color: #7ec2ef;
background: var(--mc-accent-soft);
}
.mc-table {
width: 100%;
}
.mc-row,
.mc-row-head {
display: grid;
grid-template-columns: 2fr 1fr 1fr 1fr;
gap: 0.55rem;
padding: 0.5rem 0;
border-bottom: 1px solid rgba(157, 181, 210, 0.15);
align-items: center;
}
.mc-row-head {
color: var(--mc-text-muted);
text-transform: uppercase;
font-size: 0.72rem;
letter-spacing: 0.05em;
font-weight: 700;
}
.mc-row:last-child {
border-bottom: 0;
}
.mc-case-title {
font-weight: 700;
font-size: 0.93rem;
line-height: 1.2;
}
.mc-case-sub {
color: var(--mc-text-muted);
font-size: 0.78rem;
}
.mc-case-link,
.mc-email-link {
color: var(--mc-text);
text-decoration: none;
border-bottom: 1px solid transparent;
}
.mc-case-link:hover,
.mc-email-link:hover {
color: #d9eeff;
border-bottom-color: rgba(126, 194, 239, 0.6);
}
.mc-email-list {
margin-top: 1rem;
border-top: 1px solid rgba(157, 181, 210, 0.18);
padding-top: 0.8rem;
}
.mc-email-row {
display: grid;
grid-template-columns: 1.5fr 1fr 1fr;
gap: 0.5rem;
align-items: center;
padding: 0.45rem 0;
border-bottom: 1px solid rgba(157, 181, 210, 0.14);
}
.mc-email-row:last-child {
border-bottom: 0;
}
.mc-badge {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid var(--mc-border);
background: rgba(255, 255, 255, 0.04);
padding: 0.2rem 0.55rem;
font-size: 0.75rem;
color: var(--mc-text-muted);
}
.mc-feed {
max-height: 25vh;
overflow: auto;
display: grid;
gap: 0.55rem;
}
.mc-feed-item {
border-bottom: 1px solid rgba(157, 181, 210, 0.14);
padding-bottom: 0.45rem;
}
.mc-feed-item:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.mc-feed-title {
font-weight: 700;
font-size: 0.9rem;
}
.mc-feed-meta {
color: var(--mc-text-muted);
font-size: 0.79rem;
}
.mc-camera-stage.is-spotlight {
border: 2px solid rgba(245, 158, 11, 0.62);
border-radius: 14px;
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.18);
padding: 0.5rem;
animation: mcPulse 0.8s ease;
}
.mc-camera-preview.mc-spotlight-active {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: min(94vw, 1260px);
min-height: min(78vh, 900px);
z-index: 3200;
border: 2px solid rgba(245, 158, 11, 0.62);
box-shadow: 0 0 0 4px rgba(245, 158, 11, 0.18), 0 20px 60px rgba(0, 0, 0, 0.55);
animation: mcPulse 0.2s ease;
}
.mc-camera-preview.mc-spotlight-active img,
.mc-camera-preview.mc-spotlight-active iframe {
min-height: min(78vh, 900px);
}
@keyframes mcPulse {
from { transform: scale(0.99); }
to { transform: scale(1); }
}
@media (max-width: 1300px) {
.mc-view-grid {
grid-template-columns: 1fr;
}
.mc-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mc-row,
.mc-row-head {
grid-template-columns: 1.7fr 1fr 1fr 1fr;
}
}
@media (max-width: 900px) {
.mc-nav {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mc-camera-preview.mc-spotlight-active {
width: 96vw;
min-height: min(70vh, 720px);
}
.mc-camera-preview.mc-spotlight-active img,
.mc-camera-preview.mc-spotlight-active iframe {
min-height: min(70vh, 720px);
}
.mc-email-row {
grid-template-columns: 1fr;
}
.mc-row,
.mc-row-head {
grid-template-columns: 1fr;
gap: 0.2rem;
}
.mc-row-head {
display: none;
}
}
</style>
{% endblock %}
{% block content %}
<div class="mc-shell">
<section class="mc-card mc-header">
<div class="mc-title-row">
<div>
<div class="mc-title">Mission Control</div>
<div class="mc-subtle" id="connectionState">Forbinder...</div>
</div>
<div class="mc-subtle" id="idleState">Auto reset: 10s inaktivitet</div>
</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>
</div>
<div class="mc-nav" id="missionNav">
<button class="mc-nav-btn active" type="button" data-view="overview">Overblik</button>
<button class="mc-nav-btn" type="button" data-view="important">Vigtige sager</button>
<button class="mc-nav-btn" type="button" data-view="calls">Opkald</button>
<button class="mc-nav-btn" type="button" data-view="camera">Kamera</button>
</div>
</section>
<section class="mc-card">
<div class="mc-filter-row" id="caseFilterChips"></div>
</section>
<section>
<div id="view-overview" class="mc-view active">
<div class="mc-view-grid">
<div class="mc-card">
<h4 class="mb-2">Driftsstatus</h4>
<div id="alertContainer" class="mc-alert-empty">Ingen aktive driftsalarmer</div>
<div class="mc-kpis" id="kpiGrid"></div>
<div class="mc-call-hero" id="callHero">
<div class="mc-call-hero-title" id="callHeroTitle">Ingen aktive opkald</div>
<div class="mc-call-hero-sub" id="callHeroMeta">Mission overvager opkald og opdaterer live.</div>
</div>
<div class="mc-environment">
<div class="mc-environment-head">Temperatur sensorer</div>
<div id="environmentReadings" class="mc-env-list"></div>
</div>
</div>
<div class="mc-card mc-camera-card">
<div class="mc-camera-head">
<h5 class="mb-0">Kamera (startvisning)</h5>
<span id="cameraStreamStatusSmall" class="mc-camera-status">Ikke aktiv</span>
</div>
<div id="cameraMotionBadgeSmall" class="mc-motion-pill hidden"></div>
<div id="cameraPreviewSmall" class="mc-camera-preview empty">Feed er ikke aktiveret endnu.</div>
</div>
</div>
</div>
<div id="view-important" class="mc-view">
<div class="mc-card">
<h4 class="mb-3">Vigtige sager</h4>
<div class="mc-row-head">
<div>Sag</div>
<div>Type</div>
<div>Status</div>
<div>Deadline</div>
</div>
<div id="importantCasesList" class="mc-table"></div>
<div class="mc-email-list">
<h5 class="mb-2">Seneste emails</h5>
<div id="recentEmailsList" class="mc-table"></div>
</div>
</div>
</div>
<div id="view-calls" class="mc-view">
<div class="mc-view-grid">
<div class="mc-card">
<h4 class="mb-3">Aktive opkald</h4>
<div id="activeCallsList" class="mc-feed"></div>
</div>
<div class="mc-card">
<h4 class="mb-3">Deadlines pr. medarbejder</h4>
<div class="mc-row-head">
<div>Medarbejder</div>
<div>I dag</div>
<div>Overskredet</div>
<div></div>
</div>
<div id="deadlineTable" class="mc-table"></div>
</div>
</div>
</div>
<div id="view-camera" class="mc-view">
<div class="mc-card mc-camera-card mc-camera-stage" id="cameraStage">
<div class="mc-camera-head">
<h4 class="mb-0">Kamera spotlight</h4>
<span id="cameraStreamStatusLarge" class="mc-camera-status">Ikke aktiv</span>
</div>
<div class="mc-duration-row" id="spotlightDurationRow">
<span class="mc-duration-label">Spotlight varighed</span>
<button type="button" class="mc-duration-btn" data-seconds="10">10s</button>
<button type="button" class="mc-duration-btn" data-seconds="20">20s</button>
<button type="button" class="mc-duration-btn" data-seconds="30">30s</button>
</div>
<div id="cameraMotionBadgeLarge" class="mc-motion-pill hidden"></div>
<div id="cameraPreviewLarge" class="mc-camera-preview large empty">Feed er ikke aktiveret endnu.</div>
</div>
</div>
</section>
<section class="mc-card">
<h5 class="mb-2">Live aktivitetsfeed</h5>
<div id="liveFeed" class="mc-feed"></div>
</section>
</div>
<script>
(() => {
const kpiLabels = {
open_cases: 'Aabne sager',
new_cases: 'Nye sager',
unassigned_cases: 'Uden ansvarlig',
deadlines_today: 'Deadline i dag',
overdue_deadlines: 'Overskredne'
};
const caseTypeMeta = [
{ key: 'all', label: 'Alle' },
{ key: 'ticket', label: 'Ticket' },
{ key: 'opgave', label: 'Opgave' },
{ key: 'ordre', label: 'Ordre' },
{ key: 'projekt', label: 'Projekt' },
{ key: 'service', label: 'Service' }
];
const state = {
ws: null,
reconnectAttempts: 0,
reconnectTimer: null,
pollTimer: null,
failures: 0,
idleTimer: null,
idleTimeoutMs: 10000,
currentView: 'overview',
caseFilter: 'all',
preSpotlightView: null,
cameraSpotlightTimer: null,
spotlightTargetId: null,
quickSpotlightSeconds: 20,
config: {
sound_enabled: true,
sound_volume: 70,
sound_events: ['incoming_call', 'uptime_down', 'critical_event'],
kpi_visible: Object.keys(kpiLabels),
display_queues: [],
camera_enabled: false,
camera_name: 'Mission Kamera',
camera_feed_url: '',
camera_spotlight_seconds: 20,
},
kpis: {},
activeCalls: [],
employeeDeadlines: [],
activeAlerts: [],
liveFeed: [],
importantCases: [],
recentEmails: [],
environmentReadings: [],
cameraMotion: null,
cameraPreviewToken: 0,
renderedCameraTarget: null,
renderedCameraUrl: null,
renderedCameraMode: null,
};
function escapeHtml(str) {
return String(str ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#039;');
}
function formatDate(value) {
if (!value) return '-';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '-';
return d.toLocaleString('da-DK');
}
function formatShortDate(value) {
if (!value) return '-';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '-';
return d.toLocaleDateString('da-DK');
}
function getCaseHref(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return '/sag';
return `/sag/${id}`;
}
function getEmailHref(emailId) {
const id = Number(emailId || 0);
if (!Number.isFinite(id) || id <= 0) return '/emails';
return `/emails?open=${id}`;
}
function updateConnectionLabel(text) {
const el = document.getElementById('connectionState');
if (el) el.textContent = text;
}
function resetIdleTimer() {
if (state.idleTimer) {
clearTimeout(state.idleTimer);
state.idleTimer = null;
}
state.idleTimer = setTimeout(() => {
activateView('overview');
state.caseFilter = 'all';
renderCaseFilterChips();
renderImportantCases();
}, state.idleTimeoutMs);
}
function setCameraStatus(text, variant = '') {
['cameraStreamStatusSmall', 'cameraStreamStatusLarge'].forEach((id) => {
const el = document.getElementById(id);
if (!el) return;
el.textContent = text;
el.className = `mc-camera-status ${variant}`.trim();
});
}
function setMotionBadge(text) {
['cameraMotionBadgeSmall', 'cameraMotionBadgeLarge'].forEach((id) => {
const el = document.getElementById(id);
if (!el) return;
if (!text) {
el.textContent = '';
el.classList.add('hidden');
} else {
el.textContent = text;
el.classList.remove('hidden');
}
});
}
function getCurrentCameraTargetId() {
return state.currentView === 'camera' ? 'cameraPreviewLarge' : 'cameraPreviewSmall';
}
function clearOtherCameraTarget(activeTargetId) {
const otherId = activeTargetId === 'cameraPreviewLarge' ? 'cameraPreviewSmall' : 'cameraPreviewLarge';
const other = document.getElementById(otherId);
if (!other) return;
other.classList.add('empty');
other.textContent = 'Kamera vises i den aktive visning.';
}
async function renderCameraPreview() {
const targetId = getCurrentCameraTargetId();
const target = document.getElementById(targetId);
if (!target) return;
clearOtherCameraTarget(targetId);
const enabled = !!state.config.camera_enabled;
const feedUrl = (state.config.camera_feed_url || '').trim();
if (!enabled || !feedUrl) {
state.renderedCameraTarget = null;
state.renderedCameraUrl = null;
state.renderedCameraMode = null;
target.classList.add('empty');
target.textContent = enabled ? 'Manglende feed URL.' : 'Feed er ikke aktiveret endnu.';
setCameraStatus(enabled ? 'Mangler URL' : 'Ikke aktiv', enabled ? 'error' : '');
return;
}
const isRtsp = feedUrl.toLowerCase().startsWith('rtsp://');
const mode = isRtsp ? 'rtsp' : 'iframe';
const shouldReuse = (
state.renderedCameraTarget === targetId
&& state.renderedCameraUrl === feedUrl
&& state.renderedCameraMode === mode
);
if (shouldReuse) {
return;
}
state.cameraPreviewToken += 1;
const token = state.cameraPreviewToken;
state.renderedCameraTarget = targetId;
state.renderedCameraUrl = feedUrl;
state.renderedCameraMode = mode;
target.classList.remove('empty');
if (isRtsp) {
setCameraStatus('Forbinder RTSP...', 'connecting');
target.innerHTML = `<img id="cameraMjpegStream" src="/api/v1/mission/camera/mjpeg?fps=5&t=${Date.now()}" alt="Mission kamera">`;
const img = target.querySelector('#cameraMjpegStream');
if (!img) return;
img.onload = () => {
if (token !== state.cameraPreviewToken) return;
setCameraStatus('Live', 'live');
};
img.onerror = async () => {
if (token !== state.cameraPreviewToken) return;
let detail = 'Kunne ikke hente RTSP stream via proxy.';
try {
const statusRes = await fetch('/api/v1/mission/camera/status', { credentials: 'include' });
const statusData = await statusRes.json().catch(() => ({}));
if (statusData?.detail) detail = statusData.detail;
} catch {
// Keep generic detail on network issues.
}
setCameraStatus('Stream fejl', 'error');
target.classList.add('empty');
target.innerHTML = `<div>${escapeHtml(detail)}</div>`;
};
return;
}
target.innerHTML = `<iframe src="${escapeHtml(feedUrl)}" title="Mission kamera feed" allowfullscreen></iframe>`;
setCameraStatus('Ekstern stream', 'live');
}
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 activateView(viewKey) {
state.currentView = viewKey;
document.querySelectorAll('.mc-view').forEach((el) => {
el.classList.toggle('active', el.id === `view-${viewKey}`);
});
document.querySelectorAll('.mc-nav-btn').forEach((btn) => {
btn.classList.toggle('active', btn.dataset.view === viewKey);
});
if (viewKey !== 'important') {
state.caseFilter = 'all';
}
renderCaseFilterChips();
renderCameraPreview();
}
function renderCaseFilterChips() {
const row = document.getElementById('caseFilterChips');
if (!row) return;
const counts = state.importantCases.reduce((acc, item) => {
const key = (item.case_type || 'opgave').toLowerCase();
acc[key] = (acc[key] || 0) + 1;
return acc;
}, {});
row.innerHTML = caseTypeMeta.map((entry) => {
const count = entry.key === 'all'
? state.importantCases.length
: (counts[entry.key] || 0);
const active = state.caseFilter === entry.key;
return `
<button
type="button"
class="mc-chip ${active ? 'active' : ''}"
data-filter="${entry.key}"
>${escapeHtml(entry.label)} (${count})</button>
`;
}).join('');
row.querySelectorAll('.mc-chip').forEach((btn) => {
btn.addEventListener('click', () => {
if (state.currentView !== 'important') {
activateView('important');
}
state.caseFilter = btn.dataset.filter || 'all';
renderCaseFilterChips();
renderImportantCases();
resetIdleTimer();
});
});
}
function renderKpis() {
const grid = document.getElementById('kpiGrid');
if (!grid) return;
const visible = Array.isArray(state.config.kpi_visible) && state.config.kpi_visible.length
? state.config.kpi_visible
: Object.keys(kpiLabels);
grid.innerHTML = visible.map((key) => {
const value = Number(state.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 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 = 'mc-alert-box';
container.innerHTML = state.activeAlerts.map((alert) => `
<div class="mc-alert">
<span>DRIFT NED</span>
<span>${escapeHtml(alert.service_name || 'Ukendt service')}</span>
${alert.customer_name ? `<span class="mc-badge">${escapeHtml(alert.customer_name)}</span>` : ''}
</div>
`).join('');
}
function renderCallHero() {
const title = document.getElementById('callHeroTitle');
const meta = document.getElementById('callHeroMeta');
if (!title || !meta) return;
if (!state.activeCalls.length) {
title.textContent = 'Ingen aktive opkald';
meta.textContent = 'Mission overvager opkald og opdaterer live.';
return;
}
const call = state.activeCalls[0];
title.textContent = `${call.queue_name || 'Ukendt koe'} - ${call.caller_number || 'Ukendt nummer'}`;
const parts = [];
if (call.contact_name) parts.push(call.contact_name);
if (call.company_name) parts.push(call.company_name);
parts.push(`Start: ${formatDate(call.started_at)}`);
meta.textContent = parts.join(' • ');
}
function renderActiveCalls() {
const list = document.getElementById('activeCallsList');
if (!list) return;
if (!state.activeCalls.length) {
list.innerHTML = '<div class="mc-feed-meta">Ingen aktive opkald</div>';
return;
}
list.innerHTML = state.activeCalls.map((call) => `
<div class="mc-feed-item">
<div class="mc-feed-title">${escapeHtml(call.queue_name || 'Ukendt koe')} - ${escapeHtml(call.caller_number || '-')}</div>
<div class="mc-feed-meta">
${escapeHtml(call.contact_name || 'Ukendt kontakt')}
${call.company_name ? ` • ${escapeHtml(call.company_name)}` : ''}
${call.started_at ? ` • ${escapeHtml(formatDate(call.started_at))}` : ''}
</div>
</div>
`).join('');
}
function renderDeadlines() {
const table = document.getElementById('deadlineTable');
if (!table) return;
if (!state.employeeDeadlines.length) {
table.innerHTML = '<div class="mc-feed-meta">Ingen deadlines i dag eller overskredne</div>';
return;
}
table.innerHTML = state.employeeDeadlines.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 ? '#ffaaaa' : 'inherit'}">${Number(row.overdue_deadlines || 0)}</div>
<div></div>
</div>
`).join('');
}
function renderImportantCases() {
const list = document.getElementById('importantCasesList');
if (!list) return;
let rows = state.importantCases;
if (state.caseFilter !== 'all') {
rows = rows.filter((item) => (item.case_type || 'opgave').toLowerCase() === state.caseFilter);
}
if (!rows.length) {
list.innerHTML = '<div class="mc-feed-meta">Ingen sager for det valgte filter</div>';
return;
}
list.innerHTML = rows.slice(0, 80).map((item) => `
<div class="mc-row">
<div>
<a class="mc-case-link" href="${getCaseHref(item.id)}">
<div class="mc-case-title">#${Number(item.id || 0)} ${escapeHtml(item.titel || 'Uden titel')}</div>
</a>
<div class="mc-case-sub">${escapeHtml(item.customer_name || 'Ukendt kunde')}</div>
</div>
<div><span class="mc-badge">${escapeHtml(item.case_type || 'opgave')}</span></div>
<div>${escapeHtml(item.status || '-')}</div>
<div>${escapeHtml(formatShortDate(item.deadline))}</div>
</div>
`).join('');
}
function renderRecentEmails() {
const list = document.getElementById('recentEmailsList');
if (!list) return;
if (!state.recentEmails.length) {
list.innerHTML = '<div class="mc-feed-meta">Ingen emails fundet</div>';
return;
}
list.innerHTML = state.recentEmails.slice(0, 25).map((email) => `
<div class="mc-email-row">
<div>
<a class="mc-email-link" href="${getEmailHref(email.id)}">${escapeHtml(email.subject || '(Ingen emne)')}</a>
<div class="mc-case-sub">${escapeHtml(email.sender_name || email.sender_email || 'Ukendt afsender')}</div>
</div>
<div>
<span class="mc-badge">${escapeHtml(email.classification || 'general')}</span>
${email.linked_case_id ? `<a class="mc-email-link ms-2" href="${getCaseHref(email.linked_case_id)}">SAG #${Number(email.linked_case_id)}</a>` : ''}
</div>
<div class="mc-feed-meta">${escapeHtml(formatDate(email.received_date))}</div>
</div>
`).join('');
}
function renderEnvironmentReadings() {
const container = document.getElementById('environmentReadings');
if (!container) return;
if (!state.environmentReadings.length) {
container.innerHTML = '<div class="mc-feed-meta">Ingen temperaturdata endnu</div>';
return;
}
container.innerHTML = state.environmentReadings.slice(0, 12).map((reading) => {
const temp = Number(reading.temperature);
const unit = reading.unit || '°C';
const value = Number.isFinite(temp) ? `${temp.toFixed(1)}${unit}` : `-${unit}`;
return `
<div class="mc-env-row">
<div>
<div class="mc-env-name">${escapeHtml(reading.sensor_name || reading.sensor_id || 'Sensor')}</div>
<div class="mc-env-meta">${escapeHtml(formatDate(reading.timestamp))}</div>
</div>
<div class="mc-env-value">${escapeHtml(value)}</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 applyDurationButtons() {
const row = document.getElementById('spotlightDurationRow');
if (!row) return;
row.querySelectorAll('.mc-duration-btn').forEach((btn) => {
const seconds = Number(btn.dataset.seconds || 20);
btn.classList.toggle('active', seconds === state.quickSpotlightSeconds);
btn.addEventListener('click', () => {
state.quickSpotlightSeconds = seconds;
applyDurationButtons();
resetIdleTimer();
});
});
}
function triggerCameraSpotlight() {
const activeTargetId = getCurrentCameraTargetId();
const activePreview = document.getElementById(activeTargetId);
if (!activePreview || activePreview.classList.contains('empty')) return;
if (state.cameraSpotlightTimer) {
clearTimeout(state.cameraSpotlightTimer);
state.cameraSpotlightTimer = null;
}
if (state.spotlightTargetId && state.spotlightTargetId !== activeTargetId) {
const prevTarget = document.getElementById(state.spotlightTargetId);
if (prevTarget) prevTarget.classList.remove('mc-spotlight-active');
}
activePreview.classList.add('mc-spotlight-active');
state.spotlightTargetId = activeTargetId;
const durationMs = Math.max(5000, Math.min(state.quickSpotlightSeconds * 1000, 30000));
state.cameraSpotlightTimer = setTimeout(() => {
if (state.spotlightTargetId) {
const target = document.getElementById(state.spotlightTargetId);
if (target) target.classList.remove('mc-spotlight-active');
state.spotlightTargetId = null;
}
state.cameraSpotlightTimer = null;
}, durationMs);
}
function renderMotionBadge() {
if (state.cameraMotion && state.cameraMotion.motion) {
const cameraName = state.cameraMotion.camera_name || state.config.camera_name || 'Mission Kamera';
setMotionBadge(`Bevaegelse: ${cameraName} • ${formatDate(state.cameraMotion.timestamp)}`);
return;
}
setMotionBadge('');
}
function renderAll() {
renderCaseFilterChips();
renderKpis();
renderAlerts();
renderCallHero();
renderActiveCalls();
renderDeadlines();
renderImportantCases();
renderRecentEmails();
renderEnvironmentReadings();
renderFeed();
renderMotionBadge();
applyDurationButtons();
renderCameraPreview();
}
function applyMissionState(payload) {
if (!payload) return;
state.config = { ...state.config, ...(payload.config || {}) };
state.kpis = payload.kpis || state.kpis;
state.activeCalls = Array.isArray(payload.active_calls) ? payload.active_calls : state.activeCalls;
state.employeeDeadlines = Array.isArray(payload.employee_deadlines) ? payload.employee_deadlines : state.employeeDeadlines;
state.activeAlerts = Array.isArray(payload.active_alerts) ? payload.active_alerts : state.activeAlerts;
state.liveFeed = Array.isArray(payload.live_feed) ? payload.live_feed : state.liveFeed;
state.importantCases = Array.isArray(payload.important_cases) ? payload.important_cases : state.importantCases;
state.recentEmails = Array.isArray(payload.recent_emails) ? payload.recent_emails : state.recentEmails;
state.environmentReadings = Array.isArray(payload.environment_readings) ? payload.environment_readings : state.environmentReadings;
const settingSeconds = Number(state.config.camera_spotlight_seconds || 20);
if (![10, 20, 30].includes(state.quickSpotlightSeconds) && [10, 20, 30].includes(settingSeconds)) {
state.quickSpotlightSeconds = settingSeconds;
}
const soundToggle = document.getElementById('soundEnabledToggle');
const soundVolume = document.getElementById('soundVolume');
if (soundToggle) soundToggle.checked = !!state.config.sound_enabled;
if (soundVolume) soundVolume.value = String(state.config.sound_volume || 70);
renderAll();
}
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();
applyMissionState(payload);
}
function startPollingFallback(intervalMs = 3000) {
if (state.pollTimer) return;
state.pollTimer = setInterval(async () => {
try {
await loadInitialState();
} catch {
// Poll fallback stays silent by design.
}
}, Math.max(1000, Number(intervalMs) || 3000));
}
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 (WS)');
};
state.ws.onclose = () => {
state.failures += 1;
updateConnectionLabel('WS afbrudt - fallback polling aktiv');
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') {
applyMissionState(data);
return;
}
if (event === 'kpi_update') {
state.kpis = data || state.kpis;
renderKpis();
return;
}
if (event === 'call_ringing') {
state.activeCalls = [data, ...state.activeCalls.filter((c) => c.call_id !== data.call_id)];
renderCallHero();
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);
renderCallHero();
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 === 'camera_motion') {
state.cameraMotion = data;
renderMotionBadge();
if (data.motion) {
playTone('critical_event');
triggerCameraSpotlight();
}
return;
}
if (event === 'mission_environment_temperature') {
if (Array.isArray(data.environment_readings)) {
state.environmentReadings = data.environment_readings;
renderEnvironmentReadings();
}
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);
}
};
}
function bindUiEvents() {
document.getElementById('missionNav')?.querySelectorAll('.mc-nav-btn').forEach((btn) => {
btn.addEventListener('click', () => {
activateView(btn.dataset.view || 'overview');
resetIdleTimer();
});
});
['pointerdown', 'keydown', 'mousemove', 'touchstart'].forEach((name) => {
document.addEventListener(name, resetIdleTimer, { passive: true });
});
}
document.addEventListener('DOMContentLoaded', async () => {
bindUiEvents();
resetIdleTimer();
try {
await loadInitialState();
updateConnectionLabel('Mission loaded');
} catch (error) {
updateConnectionLabel('Fejl ved initial load');
console.error(error);
}
startPollingFallback(3000);
connectWs();
});
})();
</script>
{% endblock %}