bmc_hub/app/dashboard/frontend/mission_control_v2.html

4131 lines
142 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "shared/frontend/base.html" %}
{% block title %}Mission Control - BMC Hub{% endblock %}
{% block extra_css %}
<style>
:root {
--mc-bg: #030712;
--mc-surface: #0b1628;
--mc-surface-2: #0f2036;
--mc-border: rgba(120, 172, 220, 0.24);
--mc-text: #eef7ff;
--mc-text-muted: #89a6c4;
--mc-accent: #16b8e6;
--mc-accent-soft: rgba(22, 184, 230, 0.16);
--mc-danger: #ef4444;
--mc-warning: #f59e0b;
--mc-success: #10b981;
--mc-violet: #8b5cf6;
--mc-glow: 0 18px 70px rgba(0, 0, 0, 0.34);
}
body {
background:
radial-gradient(circle at 78% -15%, rgba(22, 184, 230, 0.17), transparent 38%),
radial-gradient(circle at 8% 35%, rgba(139, 92, 246, 0.12), transparent 32%),
linear-gradient(145deg, #030712 0%, #071221 54%, #02050b 100%) !important;
background-attachment: fixed !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);
}
/* Touch-first defaults for kiosk monitors */
.mc-shell button,
.mc-shell [role="button"],
.mc-shell a.mc-case-link,
.mc-shell a.mc-email-link,
.mc-shell select,
.mc-shell input[type="checkbox"],
.mc-shell input[type="range"] {
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.mc-shell button,
.mc-shell select,
.mc-shell input[type="range"] {
min-height: 44px;
}
.mc-card {
position: relative;
overflow: hidden;
background: linear-gradient(145deg, rgba(15, 32, 54, 0.92), rgba(6, 18, 34, 0.94));
border: 1px solid var(--mc-border);
border-radius: 18px;
padding: 0.85rem 1rem;
box-shadow: var(--mc-glow);
backdrop-filter: blur(18px);
}
.mc-card::before {
content: "";
position: absolute;
inset: 0 auto auto 0;
width: 42%;
height: 1px;
background: linear-gradient(90deg, rgba(111, 224, 255, 0.9), transparent);
pointer-events: none;
}
.mc-header {
display: grid;
gap: 0.7rem;
padding: 1rem 1.15rem;
}
.mc-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
flex-wrap: wrap;
}
.mc-title {
font-size: clamp(1.65rem, 2.5vw, 2.55rem);
font-weight: 900;
letter-spacing: -0.035em;
line-height: 1;
background: linear-gradient(105deg, #ffffff 15%, #80e6ff 62%, #b6a3ff);
-webkit-background-clip: text;
color: transparent;
}
.mc-eyebrow {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.42rem;
color: #77dfff;
font-size: 0.68rem;
font-weight: 900;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.mc-live-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--mc-success);
box-shadow: 0 0 0 5px rgba(16, 185, 129, 0.11), 0 0 18px rgba(16, 185, 129, 0.85);
animation: mcLive 2s ease-in-out infinite;
}
@keyframes mcLive {
50% { opacity: 0.55; transform: scale(0.82); }
}
.mc-header-actions {
display: flex;
align-items: center;
gap: 0.65rem;
}
.mc-clock {
min-width: 112px;
text-align: right;
}
.mc-clock-time {
font-size: 1.22rem;
line-height: 1;
font-weight: 900;
font-variant-numeric: tabular-nums;
}
.mc-clock-date {
margin-top: 0.25rem;
color: var(--mc-text-muted);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.mc-icon-btn {
display: grid;
place-items: center;
width: 44px;
height: 44px;
border: 1px solid var(--mc-border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.04);
color: var(--mc-text);
transition: 0.2s ease;
}
.mc-icon-btn:hover {
color: #8be7ff;
border-color: rgba(82, 211, 255, 0.55);
background: rgba(22, 184, 230, 0.11);
transform: translateY(-1px);
}
.mc-search-trigger {
display: flex;
align-items: center;
gap: 0.55rem;
min-width: 154px;
height: 44px;
border: 1px solid var(--mc-border);
border-radius: 12px;
padding: 0 0.75rem;
background: rgba(255, 255, 255, 0.035);
color: var(--mc-text-muted);
font-size: 0.78rem;
font-weight: 700;
}
.mc-search-trigger kbd {
margin-left: auto;
border: 1px solid rgba(137, 166, 196, 0.35);
border-radius: 6px;
padding: 0.08rem 0.35rem;
background: rgba(255, 255, 255, 0.06);
color: #ccecff;
font: inherit;
}
.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-control-btn {
border: 1px solid var(--mc-border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.04);
color: var(--mc-text);
font-weight: 700;
font-size: 0.85rem;
padding: 0.38rem 0.75rem;
}
.mc-control-btn.active {
border-color: rgba(245, 158, 11, 0.75);
color: #ffe2a7;
background: rgba(245, 158, 11, 0.18);
}
.mc-controls input[type="checkbox"] {
width: 18px;
height: 18px;
vertical-align: middle;
}
.mc-controls label {
min-height: 44px;
display: inline-flex;
align-items: center;
gap: 0.45rem;
}
.mc-controls input[type="range"] {
min-width: 130px;
}
.mc-nav {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 0.6rem;
}
.mc-nav.project-only {
grid-template-columns: 1fr;
}
.mc-nav-btn {
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
column-gap: 0.65rem;
align-items: center;
text-align: left;
border: 1px solid var(--mc-border);
background: rgba(255, 255, 255, 0.025);
color: var(--mc-text);
border-radius: 14px;
min-height: 48px;
padding: 0.36rem 0.62rem;
font-size: 0.84rem;
font-weight: 700;
letter-spacing: 0.01em;
transition: 0.15s ease;
touch-action: manipulation;
}
.mc-nav-btn.active {
border-color: rgba(78, 213, 255, 0.72);
background: linear-gradient(145deg, rgba(22, 184, 230, 0.22), rgba(139, 92, 246, 0.11));
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.05), 0 10px 28px rgba(8, 145, 178, 0.13);
}
.mc-nav-btn i {
grid-row: 1 / 3;
font-size: 1rem;
color: #72dcfb;
}
.mc-nav-kicker {
color: var(--mc-text-muted);
font-size: 0.6rem;
font-weight: 600;
line-height: 1.1;
}
.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;
touch-action: manipulation;
}
.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;
touch-action: manipulation;
}
.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-row-click {
cursor: pointer;
}
.mc-row-click:hover {
border-color: rgba(157, 181, 210, 0.35);
background: rgba(255, 255, 255, 0.02);
}
.mc-project-detail {
margin-top: 0.9rem;
border: 1px solid var(--mc-border);
border-radius: 12px;
padding: 0.75rem;
background: rgba(5, 15, 35, 0.45);
}
.mc-project-detail.hidden {
display: none;
}
.mc-project-list.hidden {
display: none;
}
.mc-project-table-head,
.mc-project-portfolio-row {
display: grid;
grid-template-columns: minmax(250px, 1.55fr) minmax(145px, 0.75fr) minmax(220px, 1.15fr) minmax(150px, 0.75fr) minmax(110px, 0.55fr);
gap: 0.75rem;
align-items: center;
}
.mc-project-table-head {
padding: 0.5rem 0.7rem;
color: var(--mc-text-muted);
font-size: 0.68rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.mc-project-portfolio-row {
position: relative;
margin-bottom: 0.48rem;
border: 1px solid rgba(120, 172, 220, 0.18);
border-radius: 13px;
padding: 0.72rem;
background: rgba(255, 255, 255, 0.025);
cursor: pointer;
transition: 0.18s ease;
}
.mc-project-portfolio-row:hover {
transform: translateY(-2px);
border-color: rgba(95, 218, 255, 0.48);
background: rgba(22, 184, 230, 0.055);
}
.mc-project-entry {
margin-bottom: 0.58rem;
}
.mc-project-entry > .mc-project-portfolio-row {
margin-bottom: 0;
}
.mc-project-subcases {
margin: 0 0.65rem;
border: 1px solid rgba(95, 218, 255, 0.14);
border-top: 0;
border-radius: 0 0 12px 12px;
background: rgba(3, 12, 28, 0.5);
}
.mc-project-subcase {
display: grid;
grid-template-columns: minmax(220px, 1.55fr) minmax(145px, 0.75fr) minmax(220px, 1.15fr) minmax(150px, 0.75fr) minmax(110px, 0.55fr);
gap: 0.75rem;
align-items: start;
padding: 0.7rem 0.85rem;
border-top: 1px solid rgba(120, 172, 220, 0.12);
}
.mc-project-subcase:first-child {
border-top: 0;
}
.mc-project-subcase-title {
color: #dff7ff;
font-size: 0.8rem;
font-weight: 800;
}
.mc-project-subcase-title i {
margin-right: 0.4rem;
color: #70daf9;
}
.mc-project-todos {
display: grid;
gap: 0.28rem;
}
.mc-project-todo {
display: flex;
gap: 0.38rem;
align-items: flex-start;
color: #dcecff;
font-size: 0.73rem;
line-height: 1.25;
}
.mc-project-todo i {
color: #fbbf24;
}
.mc-project-todo-date {
color: var(--mc-text-muted);
white-space: nowrap;
}
.mc-project-name {
font-size: 0.92rem;
font-weight: 900;
line-height: 1.2;
}
.mc-project-meta-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.35rem;
}
.mc-project-period {
display: grid;
gap: 0.18rem;
color: #d9edff;
font-size: 0.76rem;
}
.mc-project-period span {
display: flex;
align-items: center;
gap: 0.35rem;
}
.mc-project-period i {
width: 14px;
color: #70daf9;
}
.mc-next-step {
display: flex;
align-items: flex-start;
gap: 0.5rem;
color: #e8f5ff;
font-size: 0.79rem;
font-weight: 700;
line-height: 1.3;
}
.mc-next-step i {
margin-top: 0.05rem;
color: #fbbf24;
}
.mc-responsible {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.mc-responsible-avatar {
display: grid;
place-items: center;
flex: 0 0 auto;
width: 30px;
height: 30px;
border: 1px solid rgba(95, 218, 255, 0.35);
border-radius: 9px;
background: linear-gradient(145deg, rgba(22, 184, 230, 0.2), rgba(139, 92, 246, 0.18));
color: #dff8ff;
font-size: 0.68rem;
font-weight: 900;
}
.mc-responsible-name {
overflow: hidden;
color: #e5f3ff;
font-size: 0.76rem;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.mc-project-status {
justify-self: start;
border-color: rgba(22, 184, 230, 0.4);
color: #c8f2ff;
background: rgba(22, 184, 230, 0.09);
}
.mc-project-detail-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
margin-bottom: 0.6rem;
}
.mc-project-kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 0.45rem;
margin-bottom: 0.7rem;
}
.mc-project-kpi {
border: 1px solid var(--mc-border);
border-radius: 10px;
padding: 0.45rem 0.55rem;
background: rgba(255, 255, 255, 0.03);
}
.mc-project-kpi .label {
color: var(--mc-text-muted);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.mc-project-kpi .value {
font-size: 1rem;
font-weight: 800;
}
.mc-kanban {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.55rem;
}
.mc-kanban-col {
border: 1px solid var(--mc-border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
padding: 0.45rem;
min-height: 140px;
}
.mc-kanban-col h6 {
margin: 0 0 0.45rem 0;
font-size: 0.78rem;
color: var(--mc-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.mc-kanban-card {
border: 1px solid rgba(157, 181, 210, 0.2);
border-radius: 8px;
padding: 0.38rem 0.45rem;
margin-bottom: 0.35rem;
background: rgba(255, 255, 255, 0.03);
}
.mc-kanban-title {
font-size: 0.84rem;
font-weight: 700;
}
.mc-kanban-meta {
color: var(--mc-text-muted);
font-size: 0.72rem;
}
.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-call-history {
margin-top: 0.8rem;
}
.mc-call-history-head,
.mc-call-history-row {
display: grid;
grid-template-columns: minmax(190px, 1.2fr) minmax(150px, 0.85fr) minmax(150px, 0.9fr) minmax(220px, 1.25fr);
gap: 0.7rem;
align-items: center;
}
.mc-call-history-head {
padding: 0.45rem 0.65rem;
color: var(--mc-text-muted);
font-size: 0.67rem;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.mc-call-history-row {
margin-bottom: 0.42rem;
border: 1px solid rgba(120, 172, 220, 0.18);
border-radius: 12px;
padding: 0.62rem 0.68rem;
background: rgba(255, 255, 255, 0.025);
}
.mc-call-direction {
display: inline-grid;
place-items: center;
width: 28px;
height: 28px;
margin-right: 0.45rem;
border-radius: 8px;
background: rgba(22, 184, 230, 0.11);
color: #79e2ff;
}
.mc-call-case {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.55rem;
}
.mc-call-link-btn {
min-height: 34px !important;
border: 1px solid rgba(95, 218, 255, 0.4);
border-radius: 9px;
padding: 0.3rem 0.55rem;
background: rgba(22, 184, 230, 0.1);
color: #cef5ff;
font-size: 0.72rem;
font-weight: 800;
white-space: nowrap;
}
.mc-case-search-results {
display: grid;
gap: 0.4rem;
max-height: 330px;
overflow: auto;
}
.mc-case-search-result {
width: 100%;
border: 1px solid rgba(120, 172, 220, 0.22);
border-radius: 10px;
padding: 0.55rem 0.65rem;
background: rgba(255, 255, 255, 0.035);
color: var(--mc-text);
text-align: left;
}
.mc-case-search-result:hover {
border-color: rgba(95, 218, 255, 0.52);
background: rgba(22, 184, 230, 0.08);
}
.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-day-tabs {
display: flex;
gap: 0.45rem;
flex-wrap: wrap;
margin-bottom: 0.72rem;
}
.mc-day-tab {
border: 1px solid var(--mc-border);
border-radius: 999px;
background: rgba(255, 255, 255, 0.03);
color: var(--mc-text-muted);
font-size: 0.82rem;
font-weight: 700;
padding: 0.3rem 0.78rem;
touch-action: manipulation;
}
.mc-day-tab.active {
color: #dff3ff;
border-color: #77b6df;
background: var(--mc-accent-soft);
}
.mc-day-pane {
display: none;
}
.mc-day-pane.active {
display: block;
}
.mc-day-case-card {
border: 1px solid rgba(157, 181, 210, 0.2);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
padding: 0.65rem 0.72rem;
margin-bottom: 0.55rem;
}
.mc-day-case-title {
font-weight: 800;
font-size: 0.95rem;
}
.mc-day-case-sub {
color: var(--mc-text-muted);
font-size: 0.78rem;
margin-top: 0.12rem;
}
.mc-day-case-desc {
margin-top: 0.4rem;
font-size: 0.85rem;
color: #d9ebff;
line-height: 1.25;
}
.mc-day-assign-row {
margin-top: 0.5rem;
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 0.45rem;
align-items: center;
}
.mc-day-select {
border: 1px solid var(--mc-border);
border-radius: 9px;
background: rgba(255, 255, 255, 0.03);
color: var(--mc-text);
padding: 0.36rem 0.5rem;
font-size: 0.82rem;
}
.mc-day-btn {
border: 1px solid rgba(126, 194, 239, 0.45);
border-radius: 9px;
background: rgba(15, 76, 117, 0.45);
color: #dff3ff;
font-size: 0.81rem;
font-weight: 700;
padding: 0.35rem 0.62rem;
white-space: nowrap;
touch-action: manipulation;
}
@media (pointer: coarse) {
.mc-shell {
gap: 1rem;
}
.mc-card {
padding: 1rem 1.1rem;
}
.mc-title {
font-size: 1.8rem;
}
.mc-subtle {
font-size: 1rem;
}
.mc-controls {
gap: 1rem;
}
.mc-controls label {
font-size: 0.95rem;
}
.mc-controls input[type="checkbox"] {
width: 22px;
height: 22px;
}
.mc-controls input[type="range"] {
min-width: 180px;
height: 28px;
}
.mc-shell button,
.mc-shell select,
.mc-shell input[type="range"],
.mc-shell a.mc-case-link,
.mc-shell a.mc-email-link {
min-height: 50px;
}
.mc-nav-btn {
min-height: 56px;
font-size: 0.96rem;
border-radius: 12px;
}
.mc-day-tab,
.mc-chip {
min-height: 44px;
font-size: 0.96rem;
padding: 0.5rem 1rem;
}
.mc-day-select {
min-height: 48px;
font-size: 0.95rem;
}
.mc-day-btn,
.mc-duration-btn {
min-height: 46px;
font-size: 0.95rem;
padding: 0.45rem 0.8rem;
}
.mc-day-actions {
grid-template-columns: 1fr;
}
.mc-day-touch-btn {
min-height: 56px;
font-size: 1rem;
padding: 0.55rem 0.82rem;
}
.mc-case-link,
.mc-email-link {
display: inline-flex;
align-items: center;
padding: 0.2rem 0;
}
.mc-case-title,
.mc-day-case-title {
font-size: 1.05rem;
}
.mc-case-sub,
.mc-day-case-sub,
.mc-feed-meta {
font-size: 0.9rem;
}
}
.mc-day-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mc-day-actions {
margin-top: 0.52rem;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.45rem;
}
.mc-day-touch-btn {
border: 1px solid rgba(126, 194, 239, 0.45);
border-radius: 10px;
background: rgba(15, 76, 117, 0.45);
color: #dff3ff;
font-size: 0.82rem;
font-weight: 800;
min-height: 44px;
padding: 0.42rem 0.58rem;
touch-action: manipulation;
}
.mc-day-touch-btn.secondary {
border-color: rgba(157, 181, 210, 0.35);
background: rgba(255, 255, 255, 0.05);
color: #e3f1ff;
}
.mc-day-touch-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.mc-day-modal-body {
display: grid;
gap: 0.75rem;
}
.mc-day-modal-meta {
font-size: 0.86rem;
color: var(--mc-text-muted);
}
.mc-day-agents {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.65rem;
}
.mc-agent-card {
border: 1px solid rgba(157, 181, 210, 0.22);
border-radius: 12px;
background: rgba(255, 255, 255, 0.03);
padding: 0.62rem 0.7rem;
}
.mc-agent-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.4rem;
margin-bottom: 0.45rem;
}
.mc-agent-name {
font-size: 0.9rem;
font-weight: 800;
}
.mc-agent-metrics {
display: flex;
gap: 0.32rem;
flex-wrap: wrap;
margin-bottom: 0.45rem;
}
.mc-day-mini {
font-size: 0.72rem;
padding: 0.17rem 0.42rem;
}
.mc-day-mini.alert {
border-color: rgba(239, 68, 68, 0.5);
color: #ffd0d0;
}
.mc-day-mini.warn {
border-color: rgba(245, 158, 11, 0.5);
color: #ffdb9f;
}
.mc-day-agent-cases {
display: grid;
gap: 0.32rem;
max-height: 220px;
overflow: auto;
}
.mc-day-agent-case {
border-radius: 10px;
border: 1px solid rgba(157, 181, 210, 0.18);
background: rgba(255, 255, 255, 0.02);
padding: 0.4rem 0.5rem;
font-size: 0.8rem;
}
.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);
}
.mc-command-deck {
display: grid;
grid-template-columns: 1.25fr repeat(4, minmax(0, 1fr));
gap: 0.55rem;
}
.mc-event-rail {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.75rem;
min-height: 42px;
overflow: hidden;
border: 1px solid var(--mc-border);
border-radius: 13px;
padding: 0.38rem 0.65rem;
background: rgba(5, 16, 30, 0.82);
}
.mc-event-rail.view-hidden {
display: none;
}
.mc-event-label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #7de3ff;
font-size: 0.66rem;
font-weight: 900;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.mc-event-content {
min-width: 0;
overflow: hidden;
color: #d9ebfa;
font-size: 0.78rem;
white-space: nowrap;
text-overflow: ellipsis;
}
.mc-event-time {
color: var(--mc-text-muted);
font-size: 0.7rem;
font-variant-numeric: tabular-nums;
}
.mc-shell.incident-mode .mc-header {
border-color: rgba(239, 68, 68, 0.56);
box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.14), 0 20px 85px rgba(127, 29, 29, 0.28);
}
.mc-shell.incident-mode .mc-eyebrow,
.mc-shell.incident-mode .mc-event-label {
color: #ff9f9f;
}
.mc-shell.incident-mode .mc-live-dot {
background: #fb7185;
box-shadow: 0 0 0 5px rgba(239, 68, 68, 0.12), 0 0 18px rgba(239, 68, 68, 0.9);
}
.mc-command-palette {
position: fixed;
inset: 0;
z-index: 4100;
display: none;
align-items: flex-start;
justify-content: center;
padding: min(14vh, 130px) 1rem 1rem;
background: rgba(1, 5, 12, 0.74);
backdrop-filter: blur(12px);
}
.mc-command-palette.open {
display: flex;
animation: mcFadeIn 0.16s ease;
}
.mc-command-panel {
width: min(720px, 96vw);
overflow: hidden;
border: 1px solid rgba(92, 215, 255, 0.4);
border-radius: 18px;
background: linear-gradient(145deg, rgba(15, 32, 54, 0.98), rgba(4, 13, 27, 0.99));
box-shadow: 0 34px 100px rgba(0, 0, 0, 0.62);
}
.mc-command-input-wrap {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--mc-border);
}
.mc-command-input-wrap i {
color: #75dfff;
font-size: 1.12rem;
}
.mc-command-input {
width: 100%;
border: 0;
outline: 0;
background: transparent;
color: var(--mc-text);
font-size: 1.02rem;
font-weight: 700;
}
.mc-command-results {
max-height: min(58vh, 520px);
overflow: auto;
padding: 0.5rem;
}
.mc-command-result {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.7rem;
width: 100%;
border: 1px solid transparent;
border-radius: 11px;
padding: 0.62rem 0.68rem;
background: transparent;
color: var(--mc-text);
text-align: left;
}
.mc-command-result:hover,
.mc-command-result.active {
border-color: rgba(92, 215, 255, 0.34);
background: rgba(22, 184, 230, 0.09);
}
.mc-command-result i {
color: #73ddfb;
font-size: 1rem;
}
.mc-command-result-title {
font-size: 0.84rem;
font-weight: 800;
}
.mc-command-result-meta,
.mc-command-kind {
color: var(--mc-text-muted);
font-size: 0.68rem;
}
.mc-command-empty {
padding: 1.6rem;
color: var(--mc-text-muted);
text-align: center;
font-size: 0.82rem;
}
.mc-signal {
position: relative;
min-height: 82px;
overflow: hidden;
border: 1px solid var(--mc-border);
border-radius: 15px;
padding: 0.7rem 0.78rem;
background: rgba(255, 255, 255, 0.025);
}
.mc-signal::after {
content: "";
position: absolute;
right: -22px;
bottom: -38px;
width: 82px;
height: 82px;
border-radius: 50%;
background: var(--signal-color, rgba(22, 184, 230, 0.32));
filter: blur(28px);
opacity: 0.5;
pointer-events: none;
}
.mc-signal-label {
color: var(--mc-text-muted);
font-size: 0.66rem;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.mc-signal-value {
display: flex;
align-items: center;
gap: 0.42rem;
margin-top: 0.38rem;
font-size: 1.05rem;
font-weight: 900;
line-height: 1.05;
}
.mc-signal-meta {
margin-top: 0.24rem;
color: var(--mc-text-muted);
font-size: 0.68rem;
}
.mc-signal.healthy { --signal-color: rgba(16, 185, 129, 0.7); }
.mc-signal.warn { --signal-color: rgba(245, 158, 11, 0.72); border-color: rgba(245, 158, 11, 0.38); }
.mc-signal.critical { --signal-color: rgba(239, 68, 68, 0.78); border-color: rgba(239, 68, 68, 0.5); }
.mc-signal.info { --signal-color: rgba(22, 184, 230, 0.72); }
.mc-signal-icon {
color: #84e7ff;
font-size: 0.95rem;
}
.mc-signal.healthy .mc-signal-icon { color: #6ee7b7; }
.mc-signal.warn .mc-signal-icon { color: #fbbf24; }
.mc-signal.critical .mc-signal-icon { color: #fb7185; }
.mc-kpi {
transition: transform 0.18s ease, border-color 0.18s ease, background 0.18s ease;
}
.mc-kpi:hover,
.mc-row-click:hover,
.mc-day-case-card:hover,
.mc-agent-card:hover {
transform: translateY(-2px);
border-color: rgba(95, 218, 255, 0.45);
background: rgba(22, 184, 230, 0.055);
}
.mc-kpi .value {
background: linear-gradient(105deg, #fff, #8ee8ff);
-webkit-background-clip: text;
color: transparent;
}
@keyframes mcPulse {
from { transform: scale(0.99); }
to { transform: scale(1); }
}
@media (max-width: 1300px) {
.mc-command-deck {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.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;
}
.mc-day-agents {
grid-template-columns: 1fr;
}
}
@media (max-width: 900px) {
.mc-command-deck {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.mc-title-row {
align-items: flex-start;
}
.mc-clock {
display: none;
}
.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-day-assign-row {
grid-template-columns: 1fr;
}
.mc-email-row {
grid-template-columns: 1fr;
}
.mc-call-history-head {
display: none;
}
.mc-call-history-row {
grid-template-columns: 1fr 1fr;
}
.mc-call-history-row .mc-call-case {
grid-column: 1 / -1;
}
.mc-row,
.mc-row-head {
grid-template-columns: 1fr;
gap: 0.2rem;
}
.mc-row-head {
display: none;
}
.mc-kanban {
grid-template-columns: 1fr;
}
.mc-project-table-head {
display: none;
}
.mc-project-portfolio-row {
grid-template-columns: 1fr 1fr;
}
.mc-project-portfolio-row > :first-child,
.mc-project-portfolio-row .mc-next-step {
grid-column: 1 / -1;
}
}
@media (max-width: 560px) {
.mc-command-deck {
grid-template-columns: 1fr;
}
.mc-header-actions {
margin-left: auto;
}
.mc-project-portfolio-row {
grid-template-columns: 1fr;
}
.mc-project-portfolio-row > * {
grid-column: 1 !important;
}
}
</style>
{% endblock %}
{% block content %}
{% set project_only = mission_project_only | default(false) %}
<div class="mc-shell">
<section class="mc-card mc-header">
<div class="mc-title-row">
<div>
<div class="mc-eyebrow"><span class="mc-live-dot"></span>BMC Operations · Live command surface</div>
<div class="mc-title">{% if project_only %}Projekt View{% else %}Mission Control{% endif %}</div>
<div class="mc-subtle" id="connectionState">{% if project_only %}Projektoversigt opdateres live{% else %}Forbinder...{% endif %}</div>
</div>
<div class="mc-header-actions">
<button type="button" class="mc-search-trigger" id="commandPaletteBtn" aria-label="Åbn kommandosøgning">
<i class="bi bi-search"></i><span>Søg & navigér</span><kbd>/</kbd>
</button>
<div class="mc-clock" aria-label="Aktuel tid">
<div class="mc-clock-time" id="missionClock">--:--:--</div>
<div class="mc-clock-date" id="missionDate">Indlæser dato</div>
</div>
<button type="button" class="mc-icon-btn" id="fullscreenBtn" title="Fuldskærm" aria-label="Skift fuldskærm">
<i class="bi bi-arrows-fullscreen"></i>
</button>
</div>
</div>
{% if not project_only %}
<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>
<button type="button" class="mc-control-btn" id="pauseAutoResetBtn">Pause auto reset 60 min</button>
<span class="mc-subtle" id="idleState">Auto reset: 10s inaktivitet</span>
</div>
{% endif %}
<div class="mc-nav {% if project_only %}project-only{% endif %}" id="missionNav">
{% if not project_only %}
<button class="mc-nav-btn" type="button" data-view="overview"><i class="bi bi-grid-1x2-fill"></i><span>Overblik</span><span class="mc-nav-kicker">Live drift</span></button>
<button class="mc-nav-btn" type="button" data-view="day"><i class="bi bi-sunrise-fill"></i><span>Dagen</span><span class="mc-nav-kicker">Fordeling</span></button>
<button class="mc-nav-btn" type="button" data-view="important"><i class="bi bi-exclamation-diamond-fill"></i><span>Vigtige sager</span><span class="mc-nav-kicker">Prioriteter</span></button>
{% endif %}
<button class="mc-nav-btn" type="button" data-view="project"><i class="bi bi-kanban-fill"></i><span>Projekt</span><span class="mc-nav-kicker">Leverancer</span></button>
{% if not project_only %}
<button class="mc-nav-btn" type="button" data-view="calls"><i class="bi bi-telephone-inbound-fill"></i><span>Opkald</span><span class="mc-nav-kicker">& deadlines</span></button>
<button class="mc-nav-btn" type="button" data-view="camera"><i class="bi bi-camera-video-fill"></i><span>Kamera</span><span class="mc-nav-kicker">Spotlight</span></button>
{% endif %}
</div>
</section>
{% if not project_only %}
<section class="mc-command-deck" aria-label="Aktuel operationsstatus">
<div class="mc-signal healthy" id="systemSignal">
<div class="mc-signal-label">Systemtilstand</div>
<div class="mc-signal-value"><i class="bi bi-shield-check mc-signal-icon"></i><span id="systemSignalValue">Forbinder</span></div>
<div class="mc-signal-meta" id="systemSignalMeta">Kontrollerer live services</div>
</div>
<div class="mc-signal info" id="callsSignal">
<div class="mc-signal-label">Aktive opkald</div>
<div class="mc-signal-value"><i class="bi bi-telephone mc-signal-icon"></i><span id="callsSignalValue">0</span></div>
<div class="mc-signal-meta">Live fra telefonikøer</div>
</div>
<div class="mc-signal info" id="queueSignal">
<div class="mc-signal-label">Uden ansvarlig</div>
<div class="mc-signal-value"><i class="bi bi-person-exclamation mc-signal-icon"></i><span id="queueSignalValue">0</span></div>
<div class="mc-signal-meta">Kræver triage</div>
</div>
<div class="mc-signal info" id="deadlineSignal">
<div class="mc-signal-label">Deadline-risiko</div>
<div class="mc-signal-value"><i class="bi bi-alarm mc-signal-icon"></i><span id="deadlineSignalValue">0</span></div>
<div class="mc-signal-meta">Overskredne sager</div>
</div>
<div class="mc-signal healthy" id="syncSignal">
<div class="mc-signal-label">Datastrøm</div>
<div class="mc-signal-value"><i class="bi bi-broadcast mc-signal-icon"></i><span id="syncSignalValue">Live</span></div>
<div class="mc-signal-meta" id="syncSignalMeta">Venter på første signal</div>
</div>
</section>
<section class="mc-event-rail" id="missionEventRail" aria-live="polite">
<div class="mc-event-label"><i class="bi bi-activity"></i> Live feed</div>
<div class="mc-event-content" id="eventRailContent">Venter på hændelser fra Mission Control…</div>
<div class="mc-event-time" id="eventRailTime">--:--</div>
</section>
{% endif %}
<section>
<div id="view-overview" class="mc-view">
<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 id="caseFilterChips" class="mc-filter-row mb-3" aria-label="Sagstype filtre"></div>
<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-project" class="mc-view">
<div class="mc-card">
<h4 class="mb-2">Projektportefølje</h4>
<div class="mc-subtle mb-3">Alle aktive projektsager med periode, næste handling og tydeligt ejerskab.</div>
<div id="projectListSection" class="mc-project-list">
<div class="mc-project-table-head">
<div>Projekt</div>
<div>Start / slut</div>
<div>Næste step</div>
<div>Ansvarlig</div>
<div>Status</div>
</div>
<div id="projectCasesList" class="mc-table"></div>
</div>
<div id="projectDetailPanel" class="mc-project-detail hidden">
<div class="mc-project-detail-head">
<div>
<div class="mc-case-title" id="projectDetailTitle">Projektdetalje</div>
<div class="mc-case-sub" id="projectDetailSubtitle">Tryk på et projekt for at se mere info.</div>
</div>
<button type="button" class="mc-control-btn" id="projectBackBtn">Tilbage</button>
</div>
<div id="projectDetailKpis" class="mc-project-kpis"></div>
<div id="projectKanbanBoard" class="mc-kanban"></div>
</div>
</div>
</div>
<div id="view-day" class="mc-view">
<div class="mc-card">
<h4 class="mb-2">Dagen</h4>
<div class="mc-subtle mb-3">Morgenmøde-overblik: nye ikke-tildelte sager og arbejdsfordeling i dag.</div>
<div class="mc-day-tabs" id="dayTabs">
<button type="button" class="mc-day-tab active" data-day-tab="new_cases">Nye sager</button>
<button type="button" class="mc-day-tab" data-day-tab="today">Idag</button>
</div>
<div id="dayPane-new_cases" class="mc-day-pane active">
<div id="dayUnassignedList"></div>
</div>
<div id="dayPane-today" class="mc-day-pane">
<div id="dayAgentsList" class="mc-day-agents"></div>
</div>
</div>
</div>
<div class="modal fade" id="dayCaseQuickModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="dayCaseQuickTitle">Sag detaljer</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body mc-day-modal-body">
<div class="mc-day-modal-meta" id="dayCaseQuickMeta">Vælg en sag fra listen.</div>
<div>
<label for="dayCaseQuickUser" class="form-label">Ansvarlig medarbejder</label>
<select id="dayCaseQuickUser" class="form-select"></select>
</div>
<div>
<label for="dayCaseQuickGroup" class="form-label">Gruppe</label>
<select id="dayCaseQuickGroup" class="form-select"></select>
</div>
<div class="row g-2">
<div class="col-md-6">
<label for="dayCaseQuickStart" class="form-label">Start</label>
<input id="dayCaseQuickStart" type="datetime-local" class="form-control">
</div>
<div class="col-md-6">
<label for="dayCaseQuickDeadline" class="form-label">Deadline</label>
<input id="dayCaseQuickDeadline" type="datetime-local" class="form-control">
</div>
</div>
<div>
<label for="dayCaseQuickDesc" class="form-label">Beskrivelse</label>
<textarea id="dayCaseQuickDesc" class="form-control" rows="4" readonly></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Luk</button>
<button type="button" class="btn btn-primary" id="dayCaseQuickSaveBtn">Gem aendringer</button>
</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 class="mc-card mc-call-history">
<div class="d-flex justify-content-between align-items-center gap-2 mb-2">
<div>
<h4 class="mb-1">Opkaldshistorik</h4>
<div class="mc-subtle">Seneste opkald og deres tilknyttede sag.</div>
</div>
<button type="button" class="mc-control-btn" id="refreshCallHistoryBtn">
<i class="bi bi-arrow-clockwise me-1"></i>Opdatér
</button>
</div>
<div class="mc-call-history-head">
<div>Opkald</div>
<div>Kontakt</div>
<div>Tid / varighed</div>
<div>Tilknyttet sag</div>
</div>
<div id="callHistoryList"></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>
</div>
<div class="modal fade" id="callCaseLinkModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<div>
<h5 class="modal-title">Tilknyt opkald til sag</h5>
<div class="small text-muted" id="callCaseLinkMeta">Vælg den relevante sag.</div>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Luk"></button>
</div>
<div class="modal-body">
<label for="callCaseSearchInput" class="form-label">Søg på sagsnummer, titel eller kunde</label>
<input id="callCaseSearchInput" type="search" class="form-control mb-3" placeholder="Fx 1234 eller kundenavn">
<div id="callCaseSearchResults" class="mc-case-search-results"></div>
</div>
</div>
</div>
</div>
<div class="mc-command-palette" id="commandPalette" role="dialog" aria-modal="true" aria-label="Mission Control søgning">
<div class="mc-command-panel">
<div class="mc-command-input-wrap">
<i class="bi bi-command"></i>
<input
type="search"
class="mc-command-input"
id="commandPaletteInput"
placeholder="Søg sag, projekt, e-mail eller visning…"
autocomplete="off"
>
<span class="mc-command-kind">ESC</span>
</div>
<div class="mc-command-results" id="commandPaletteResults"></div>
</div>
</div>
<script>
(() => {
const initialMissionView = "{{ mission_initial_view if mission_initial_view else 'overview' }}";
const projectOnlyMode = {{ 'true' if project_only else 'false' }};
const kpiLabels = {
open_cases: 'Åbne 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,
idlePauseTimer: null,
idlePauseUntil: null,
idleTimeoutMs: 10000,
currentView: initialMissionView,
caseFilter: 'all',
dayTab: 'new_cases',
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: [],
projectsPayload: { summary: {}, projects: [], workload: [] },
projects: [],
dayUnassignedCases: [],
dayAgentWorkloads: [],
assignmentUsers: [],
assignmentGroups: [],
recentEmails: [],
environmentReadings: [],
cameraMotion: null,
cameraPreviewToken: 0,
renderedCameraTarget: null,
renderedCameraUrl: null,
renderedCameraMode: null,
selectedProjectId: null,
selectedDayCaseId: null,
dayCaseModal: null,
lastUpdatedAt: null,
commandResults: [],
commandIndex: 0,
callHistory: [],
selectedCallId: null,
callCaseModal: null,
callCaseSearchTimer: 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}/v3`;
}
function normalizeTaskLane(status) {
const value = String(status || '').trim().toLowerCase();
if (['afsluttet', 'lukket', 'closed', 'done', 'resolved', 'completed'].includes(value)) return 'done';
if (['i gang', 'igang', 'in_progress', 'in progress', 'doing', 'active'].includes(value)) return 'doing';
return 'todo';
}
function renderOpenTodos(task) {
const count = Number(task?.open_todo_count || 0);
const titlesRaw = Array.isArray(task?.open_todo_titles) ? task.open_todo_titles : [];
const titles = titlesRaw.map((item) => String(item || '').trim()).filter(Boolean);
if (count <= 0 && !titles.length) return '';
const visible = titles.slice(0, 3);
const hiddenCount = Math.max(count - visible.length, 0);
const rows = visible.map((title) => `<div>• ${escapeHtml(title)}</div>`).join('');
const more = hiddenCount > 0 ? `<div>+${hiddenCount} flere</div>` : '';
return `
<div class="mc-kanban-meta" style="margin-top:6px; border-top:1px solid var(--line); padding-top:6px;">
<strong>Undersag todo (${count})</strong>
${rows || '<div>-</div>'}
${more}
</div>
`;
}
function isIdlePauseActive() {
return Boolean(state.idlePauseUntil && Date.now() < state.idlePauseUntil);
}
function setProjectDetailMode(enabled) {
const listSection = document.getElementById('projectListSection');
const panel = document.getElementById('projectDetailPanel');
if (!listSection || !panel) return;
if (enabled) {
listSection.classList.add('hidden');
panel.classList.remove('hidden');
} else {
listSection.classList.remove('hidden');
panel.classList.add('hidden');
}
}
function hideProjectDetail() {
setProjectDetailMode(false);
state.selectedProjectId = null;
}
function renderProjectDetail(detail) {
const panel = document.getElementById('projectDetailPanel');
const title = document.getElementById('projectDetailTitle');
const subtitle = document.getElementById('projectDetailSubtitle');
const kpis = document.getElementById('projectDetailKpis');
const board = document.getElementById('projectKanbanBoard');
if (!panel || !title || !subtitle || !kpis || !board) return;
const tasks = Array.isArray(detail?.tasks) ? detail.tasks : [];
const milestones = Array.isArray(detail?.milestones) ? detail.milestones : [];
const blockers = Array.isArray(detail?.blockers) ? detail.blockers : [];
const projectOpenTodoCount = Number(detail?.project_open_todo_count || 0);
const projectOpenTodoTitles = Array.isArray(detail?.project_open_todo_titles)
? detail.project_open_todo_titles.map((item) => String(item || '').trim()).filter(Boolean)
: [];
const grouped = { todo: [], doing: [], done: [] };
tasks.forEach((task) => {
grouped[normalizeTaskLane(task.status)].push(task);
});
title.textContent = `#${Number(detail?.id || 0)} ${detail?.name || detail?.titel || 'Projekt'}`;
subtitle.textContent = `${detail?.description || 'Ingen beskrivelse'} • Status: ${detail?.status || '-'} • Risiko: ${detail?.risk_level || 'low'} (${Number(detail?.risk_score || 0)})`;
kpis.innerHTML = [
{ label: 'Opgaver', value: tasks.length },
{ label: 'Projekt todo', value: projectOpenTodoCount },
{ label: 'Milepæle', value: milestones.length },
{ label: 'Blockers', value: blockers.length },
{ label: 'Deadline', value: formatShortDate(detail?.ended_at || detail?.deadline) },
].map((item) => `
<div class="mc-project-kpi">
<div class="label">${escapeHtml(item.label)}</div>
<div class="value">${escapeHtml(String(item.value))}</div>
</div>
`).join('');
const laneMeta = [
{ key: 'todo', label: 'Todo' },
{ key: 'doing', label: 'I gang' },
{ key: 'done', label: 'Lukket' },
];
const projectTodoPreview = projectOpenTodoTitles.slice(0, 5)
.map((title) => `<div>• ${escapeHtml(title)}</div>`)
.join('');
const projectTodoMore = Math.max(projectOpenTodoCount - Math.min(projectOpenTodoTitles.length, 5), 0);
const projectTodoCard = projectOpenTodoCount > 0
? `
<div class="mc-kanban-card" style="border-left:3px solid #0f4c75;">
<div class="mc-kanban-title">Projekt todo (${projectOpenTodoCount})</div>
<div class="mc-kanban-meta">
${projectTodoPreview || '<div>-</div>'}
${projectTodoMore > 0 ? `<div>+${projectTodoMore} flere</div>` : ''}
</div>
</div>
`
: '';
board.innerHTML = laneMeta.map((lane) => {
const laneTasks = grouped[lane.key] || [];
const extraCard = lane.key === 'todo' ? projectTodoCard : '';
return `
<div class="mc-kanban-col">
<h6>${escapeHtml(lane.label)} (${laneTasks.length + (lane.key === 'todo' && projectOpenTodoCount > 0 ? 1 : 0)})</h6>
${extraCard}
${laneTasks.length ? laneTasks.map((task) => `
<div class="mc-kanban-card">
<div class="mc-kanban-title">#${Number(task.id || 0)} ${escapeHtml(task.titel || task.title || 'Uden titel')}</div>
<div class="mc-kanban-meta">${escapeHtml(task.priority || '-')}${escapeHtml(formatShortDate(task.deadline))}</div>
${renderOpenTodos(task)}
</div>
`).join('') : '<div class="mc-feed-meta">Ingen opgaver</div>'}
</div>
`;
}).join('');
setProjectDetailMode(true);
}
async function openProjectDetail(projectId) {
const id = Number(projectId || 0);
if (!Number.isFinite(id) || id <= 0) return;
state.selectedProjectId = id;
const cachedDetail = state.projectsPayload
&& state.projectsPayload.details
&& state.projectsPayload.details[String(id)];
if (cachedDetail) {
renderProjectDetail(cachedDetail);
return;
}
try {
const res = await fetch(`/api/v1/mission/projects/${id}`, { credentials: 'include' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const detail = await res.json();
renderProjectDetail(detail);
} catch {
const fallback = (state.projects || []).find((p) => Number(p.id || 0) === id);
if (fallback) {
renderProjectDetail({ ...fallback, tasks: [], milestones: [], blockers: [] });
}
}
}
function toOptionalInt(value) {
const raw = String(value ?? '').trim();
if (!raw) return null;
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
}
function toDatetimeLocalValue(value) {
if (!value) return '';
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '';
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function truncateText(value, maxLength = 180) {
const raw = String(value || '').trim();
if (!raw) return '';
if (raw.length <= maxLength) return raw;
return `${raw.slice(0, maxLength - 1)}...`;
}
function getDayCaseById(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return null;
const source = Array.isArray(state.dayUnassignedCases) ? state.dayUnassignedCases : [];
const found = source.find((item) => Number(item.id || 0) === id);
return found || null;
}
function getOrCreateDayCaseModal() {
if (state.dayCaseModal) return state.dayCaseModal;
const el = document.getElementById('dayCaseQuickModal');
if (!el || !window.bootstrap) return null;
state.dayCaseModal = new bootstrap.Modal(el);
return state.dayCaseModal;
}
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 updateMissionClock() {
const now = new Date();
const clock = document.getElementById('missionClock');
const date = document.getElementById('missionDate');
if (clock) {
clock.textContent = now.toLocaleTimeString('da-DK', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
if (date) {
date.textContent = now.toLocaleDateString('da-DK', {
weekday: 'short',
day: '2-digit',
month: 'short'
});
}
const syncMeta = document.getElementById('syncSignalMeta');
if (syncMeta && state.lastUpdatedAt) {
const seconds = Math.max(0, Math.floor((Date.now() - state.lastUpdatedAt) / 1000));
syncMeta.textContent = seconds < 2 ? 'Opdateret nu' : `Opdateret for ${seconds}s siden`;
}
}
function setSignalVariant(element, variant) {
if (!element) return;
element.classList.remove('healthy', 'info', 'warn', 'critical');
element.classList.add(variant);
}
function renderCommandDeck() {
if (projectOnlyMode) return;
const alertCount = state.activeAlerts.length;
const callCount = state.activeCalls.length;
const unassigned = Number(state.kpis.unassigned_cases || 0);
const overdue = Number(state.kpis.overdue_deadlines || 0);
const systemSignal = document.getElementById('systemSignal');
const systemValue = document.getElementById('systemSignalValue');
const systemMeta = document.getElementById('systemSignalMeta');
if (systemValue) systemValue.textContent = alertCount ? 'Handling kræves' : 'Alle systemer stabile';
if (systemMeta) systemMeta.textContent = alertCount ? `${alertCount} aktiv${alertCount === 1 ? '' : 'e'} driftsalarm${alertCount === 1 ? '' : 'er'}` : 'Ingen aktive driftsalarmer';
setSignalVariant(systemSignal, alertCount ? 'critical' : 'healthy');
const callsSignal = document.getElementById('callsSignal');
const callsValue = document.getElementById('callsSignalValue');
if (callsValue) callsValue.textContent = String(callCount);
setSignalVariant(callsSignal, callCount >= 3 ? 'warn' : callCount > 0 ? 'info' : 'healthy');
const queueSignal = document.getElementById('queueSignal');
const queueValue = document.getElementById('queueSignalValue');
if (queueValue) queueValue.textContent = String(unassigned);
setSignalVariant(queueSignal, unassigned >= 8 ? 'critical' : unassigned > 0 ? 'warn' : 'healthy');
const deadlineSignal = document.getElementById('deadlineSignal');
const deadlineValue = document.getElementById('deadlineSignalValue');
if (deadlineValue) deadlineValue.textContent = String(overdue);
setSignalVariant(deadlineSignal, overdue >= 5 ? 'critical' : overdue > 0 ? 'warn' : 'healthy');
const syncSignal = document.getElementById('syncSignal');
const syncValue = document.getElementById('syncSignalValue');
if (syncValue) syncValue.textContent = state.failures > 0 ? 'Ustabil' : 'Live';
setSignalVariant(syncSignal, state.failures > 1 ? 'warn' : 'healthy');
updateMissionClock();
}
function renderEventRail() {
if (projectOnlyMode) return;
const shell = document.querySelector('.mc-shell');
const content = document.getElementById('eventRailContent');
const time = document.getElementById('eventRailTime');
shell?.classList.toggle('incident-mode', state.activeAlerts.length > 0);
if (!content || !time) return;
if (state.activeAlerts.length) {
const alert = state.activeAlerts[0];
content.textContent = `DRIFTALARM · ${alert.service_name || 'Ukendt service'}${alert.customer_name ? ` · ${alert.customer_name}` : ''}`;
time.textContent = 'KRITISK';
return;
}
const event = state.liveFeed[0];
if (event) {
content.textContent = event.title || event.event_type || 'Ny hændelse modtaget';
time.textContent = event.created_at
? new Date(event.created_at).toLocaleTimeString('da-DK', { hour: '2-digit', minute: '2-digit' })
: 'NU';
return;
}
content.textContent = 'Ingen kritiske hændelser · Mission Control overvåger live';
time.textContent = 'STABIL';
}
function commandItems() {
const views = projectOnlyMode
? [{ type: 'view', id: 'project', title: 'Projekt', meta: 'Åbn projektoversigt', icon: 'bi-kanban-fill' }]
: [
{ type: 'view', id: 'overview', title: 'Overblik', meta: 'Live drift og KPIer', icon: 'bi-grid-1x2-fill' },
{ type: 'view', id: 'day', title: 'Dagen', meta: 'Fordeling og dagens arbejde', icon: 'bi-sunrise-fill' },
{ type: 'view', id: 'important', title: 'Vigtige sager', meta: 'Prioriterede sager og e-mails', icon: 'bi-exclamation-diamond-fill' },
{ type: 'view', id: 'project', title: 'Projekt', meta: 'Leverancer og workload', icon: 'bi-kanban-fill' },
{ type: 'view', id: 'calls', title: 'Opkald', meta: 'Telefonikøer og deadlines', icon: 'bi-telephone-inbound-fill' },
{ type: 'view', id: 'camera', title: 'Kamera', meta: 'Live kamera og spotlight', icon: 'bi-camera-video-fill' }
];
const caseMap = new Map();
[...state.importantCases, ...state.dayUnassignedCases].forEach((item) => {
const id = Number(item.id || 0);
if (!id || caseMap.has(id)) return;
caseMap.set(id, {
type: 'case',
id,
title: `SAG-${id} · ${item.titel || 'Uden titel'}`,
meta: `${item.customer_name || 'Ukendt kunde'} · ${item.status || 'Ukendt status'}`,
icon: 'bi-briefcase-fill'
});
});
const projects = state.projects.map((item) => ({
type: 'project',
id: Number(item.id || 0),
title: item.name || item.title || `Projekt ${item.id || ''}`,
meta: `${item.status || 'Ukendt status'} · ${item.customer_name || 'Ingen kunde'}`,
icon: 'bi-kanban-fill'
}));
const emails = state.recentEmails.map((item) => ({
type: 'email',
id: Number(item.id || 0),
title: item.subject || '(Intet emne)',
meta: item.sender_name || item.sender_email || 'Ukendt afsender',
icon: 'bi-envelope-fill'
}));
return [...views, ...caseMap.values(), ...projects, ...emails];
}
function renderCommandResults(query = '') {
const host = document.getElementById('commandPaletteResults');
if (!host) return;
const needle = String(query || '').trim().toLowerCase();
const items = commandItems().filter((item) => {
if (!needle) return true;
return `${item.title} ${item.meta} ${item.type}`.toLowerCase().includes(needle);
}).slice(0, 24);
state.commandResults = items;
state.commandIndex = Math.min(state.commandIndex, Math.max(items.length - 1, 0));
if (!items.length) {
host.innerHTML = '<div class="mc-command-empty">Ingen resultater. Prøv et sagsnummer, kundenavn eller en visning.</div>';
return;
}
host.innerHTML = items.map((item, index) => `
<button type="button" class="mc-command-result ${index === state.commandIndex ? 'active' : ''}" data-command-index="${index}">
<i class="bi ${item.icon}"></i>
<span>
<span class="mc-command-result-title">${escapeHtml(item.title)}</span>
<span class="mc-command-result-meta d-block">${escapeHtml(item.meta)}</span>
</span>
<span class="mc-command-kind">${escapeHtml(item.type)}</span>
</button>
`).join('');
host.querySelectorAll('.mc-command-result').forEach((button) => {
button.addEventListener('click', () => executeCommand(Number(button.dataset.commandIndex || 0)));
});
host.querySelector('.mc-command-result.active')?.scrollIntoView({ block: 'nearest' });
}
function openCommandPalette() {
const palette = document.getElementById('commandPalette');
const input = document.getElementById('commandPaletteInput');
if (!palette || !input) return;
state.commandIndex = 0;
palette.classList.add('open');
input.value = '';
renderCommandResults();
window.setTimeout(() => input.focus(), 0);
}
function closeCommandPalette() {
document.getElementById('commandPalette')?.classList.remove('open');
document.getElementById('commandPaletteInput')?.blur();
}
function executeCommand(index) {
const item = state.commandResults[index];
if (!item) return;
closeCommandPalette();
if (item.type === 'view') {
activateView(item.id);
} else if (item.type === 'case') {
window.location.href = getCaseHref(item.id);
} else if (item.type === 'email') {
window.location.href = getEmailHref(item.id);
} else if (item.type === 'project') {
activateView('project');
openProjectDetail(item.id);
}
resetIdleTimer();
}
function resetIdleTimer() {
if (isIdlePauseActive()) {
updateIdleStateLabel();
return;
}
if (state.idleTimer) {
clearTimeout(state.idleTimer);
state.idleTimer = null;
}
state.idleTimer = setTimeout(() => {
if (isIdlePauseActive()) {
updateIdleStateLabel();
return;
}
activateView('overview');
state.caseFilter = 'all';
renderCaseFilterChips();
renderImportantCases();
}, state.idleTimeoutMs);
updateIdleStateLabel();
}
function formatDurationCompact(ms) {
const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}m ${seconds}s`;
}
function formatCallDuration(secondsValue) {
const seconds = Math.max(0, Number(secondsValue || 0));
const minutes = Math.floor(seconds / 60);
const remainder = Math.floor(seconds % 60);
return minutes > 0 ? `${minutes}m ${String(remainder).padStart(2, '0')}s` : `${remainder}s`;
}
function updateIdleStateLabel() {
const label = document.getElementById('idleState');
const btn = document.getElementById('pauseAutoResetBtn');
if (!label) return;
if (isIdlePauseActive()) {
const remaining = state.idlePauseUntil - Date.now();
label.textContent = `Auto reset pausert: ${formatDurationCompact(remaining)} tilbage`;
if (btn) {
btn.classList.add('active');
btn.textContent = 'Fjern pause';
}
return;
}
label.textContent = 'Auto reset: 10s inaktivitet';
if (btn) {
btn.classList.remove('active');
btn.textContent = 'Pause auto reset 60 min';
}
}
function clearIdlePause() {
state.idlePauseUntil = null;
if (state.idlePauseTimer) {
clearTimeout(state.idlePauseTimer);
state.idlePauseTimer = null;
}
resetIdleTimer();
}
function pauseAutoResetFor60Minutes() {
state.idlePauseUntil = Date.now() + (60 * 60 * 1000);
if (state.idleTimer) {
clearTimeout(state.idleTimer);
state.idleTimer = null;
}
if (state.idlePauseTimer) {
clearTimeout(state.idlePauseTimer);
}
state.idlePauseTimer = setTimeout(() => {
clearIdlePause();
}, 60 * 60 * 1000);
updateIdleStateLabel();
}
function toggleIdlePause() {
if (isIdlePauseActive()) {
clearIdlePause();
return;
}
pauseAutoResetFor60Minutes();
}
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 now = context.currentTime;
const isAlert = type === 'uptime_down';
const baseFreq = isAlert ? 392 : 784;
const overtoneFreq = isAlert ? 523.25 : 1046.5;
const totalDuration = isAlert ? 0.9 : 0.65;
const strikeDelay = isAlert ? 0.2 : 0.14;
function strike(startAt, ampScale) {
const fundamental = context.createOscillator();
const overtone = context.createOscillator();
const gainNode = context.createGain();
fundamental.type = 'sine';
overtone.type = 'triangle';
fundamental.frequency.setValueAtTime(baseFreq, startAt);
overtone.frequency.setValueAtTime(overtoneFreq, startAt);
gainNode.gain.setValueAtTime(0.0001, startAt);
gainNode.gain.exponentialRampToValueAtTime(Math.max(0.0002, gainValue * ampScale), startAt + 0.01);
gainNode.gain.exponentialRampToValueAtTime(0.0001, startAt + totalDuration);
fundamental.connect(gainNode);
overtone.connect(gainNode);
gainNode.connect(context.destination);
fundamental.start(startAt);
overtone.start(startAt);
fundamental.stop(startAt + totalDuration);
overtone.stop(startAt + totalDuration);
}
strike(now, isAlert ? 0.22 : 0.18);
strike(now + strikeDelay, isAlert ? 0.16 : 0.12);
window.setTimeout(() => {
context.close().catch(() => {});
}, Math.ceil((totalDuration + strikeDelay + 0.1) * 1000));
}
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);
});
document.getElementById('missionEventRail')?.classList.toggle(
'view-hidden',
viewKey === 'project' || viewKey === 'calls'
);
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 kø'} - ${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 kø')} - ${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 renderCallHistory() {
const list = document.getElementById('callHistoryList');
if (!list) return;
if (!state.callHistory.length) {
list.innerHTML = '<div class="mc-feed-meta p-3 text-center">Ingen opkald fundet endnu.</div>';
return;
}
list.innerHTML = state.callHistory.map((call) => {
const callId = Number(call.id || 0);
const inbound = String(call.direction || '').toLowerCase() !== 'outbound';
const contact = call.contact_name || call.company_name || 'Ukendt kontakt';
const company = call.company_name && call.company_name !== contact ? call.company_name : '';
const caseHtml = call.sag_id
? `
<div>
<a class="mc-case-link" href="${getCaseHref(call.sag_id)}">
<strong>SAG-${Number(call.sag_id)}</strong> · ${escapeHtml(call.sag_titel || 'Uden titel')}
</a>
<div class="mc-feed-meta">${escapeHtml(call.sag_status || '')}</div>
</div>
`
: '<span class="mc-feed-meta">Ingen sag valgt</span>';
return `
<div class="mc-call-history-row">
<div>
<span class="mc-call-direction"><i class="bi ${inbound ? 'bi-telephone-inbound' : 'bi-telephone-outbound'}"></i></span>
<strong>${escapeHtml(call.display_number || 'Skjult nummer')}</strong>
<div class="mc-feed-meta mt-1">${inbound ? 'Indgående' : 'Udgående'}</div>
</div>
<div>
<div>${escapeHtml(contact)}</div>
${company ? `<div class="mc-feed-meta">${escapeHtml(company)}</div>` : ''}
${call.employee_name ? `<div class="mc-feed-meta">Medarbejder: ${escapeHtml(call.employee_name)}</div>` : ''}
</div>
<div>
<div>${escapeHtml(formatDate(call.started_at))}</div>
<div class="mc-feed-meta">${formatCallDuration(call.duration_sec)}</div>
</div>
<div class="mc-call-case">
${caseHtml}
<button type="button" class="mc-call-link-btn" data-call-link-id="${callId}">
<i class="bi bi-link-45deg me-1"></i>${call.sag_id ? 'Skift sag' : 'Vælg sag'}
</button>
</div>
</div>
`;
}).join('');
list.querySelectorAll('[data-call-link-id]').forEach((button) => {
button.addEventListener('click', () => openCallCaseLink(Number(button.dataset.callLinkId || 0)));
});
}
async function loadCallHistory() {
const list = document.getElementById('callHistoryList');
if (list && !state.callHistory.length) {
list.innerHTML = '<div class="mc-feed-meta p-3 text-center">Henter opkaldshistorik…</div>';
}
try {
const response = await fetch('/api/v1/mission/calls/history?limit=150', { credentials: 'include' });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
state.callHistory = Array.isArray(payload.calls) ? payload.calls : [];
renderCallHistory();
} catch (error) {
if (list) list.innerHTML = '<div class="mc-feed-meta p-3 text-center">Kunne ikke hente opkaldshistorik.</div>';
console.error('Call history failed', error);
}
}
function getCallCaseModal() {
const element = document.getElementById('callCaseLinkModal');
if (!element || !window.bootstrap?.Modal) return null;
if (!state.callCaseModal) state.callCaseModal = bootstrap.Modal.getOrCreateInstance(element);
return state.callCaseModal;
}
function openCallCaseLink(callId) {
const call = state.callHistory.find((item) => Number(item.id) === Number(callId));
if (!call) return;
state.selectedCallId = Number(callId);
const meta = document.getElementById('callCaseLinkMeta');
const input = document.getElementById('callCaseSearchInput');
if (meta) meta.textContent = `${call.display_number || 'Skjult nummer'} · ${formatDate(call.started_at)}`;
if (input) input.value = call.sag_id ? String(call.sag_id) : '';
getCallCaseModal()?.show();
searchCallCases(input?.value || '');
window.setTimeout(() => input?.focus(), 150);
}
async function searchCallCases(query) {
const host = document.getElementById('callCaseSearchResults');
if (!host) return;
host.innerHTML = '<div class="mc-feed-meta p-2">Søger…</div>';
try {
const response = await fetch(`/api/v1/mission/cases/search?q=${encodeURIComponent(query || '')}&limit=25`, {
credentials: 'include'
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
const cases = Array.isArray(payload.cases) ? payload.cases : [];
if (!cases.length) {
host.innerHTML = '<div class="mc-feed-meta p-2">Ingen sager matcher søgningen.</div>';
return;
}
host.innerHTML = cases.map((item) => `
<button type="button" class="mc-case-search-result" data-select-case-id="${Number(item.id || 0)}">
<strong>SAG-${Number(item.id || 0)} · ${escapeHtml(item.titel || 'Uden titel')}</strong>
<span class="mc-feed-meta d-block">${escapeHtml(item.customer_name || 'Ingen kunde')} · ${escapeHtml(item.status || '-')}</span>
</button>
`).join('');
host.querySelectorAll('[data-select-case-id]').forEach((button) => {
button.addEventListener('click', () => linkSelectedCallToCase(Number(button.dataset.selectCaseId || 0)));
});
} catch (error) {
host.innerHTML = '<div class="mc-feed-meta p-2">Søgningen fejlede.</div>';
}
}
async function linkSelectedCallToCase(caseId) {
if (!state.selectedCallId || !caseId) return;
const host = document.getElementById('callCaseSearchResults');
if (host) host.innerHTML = '<div class="mc-feed-meta p-2">Tilknytter sag…</div>';
try {
const response = await fetch(`/api/v1/mission/calls/${state.selectedCallId}/case`, {
method: 'PATCH',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sag_id: caseId })
});
if (!response.ok) {
const payload = await response.json().catch(() => ({}));
throw new Error(payload.detail || `HTTP ${response.status}`);
}
getCallCaseModal()?.hide();
await loadCallHistory();
} catch (error) {
if (host) {
host.innerHTML = `<div class="alert alert-danger mb-0">Kunne ikke tilknytte sagen: ${escapeHtml(error.message || 'Ukendt fejl')}</div>`;
}
}
}
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 renderProjectCases() {
const list = document.getElementById('projectCasesList');
if (!list) return;
let rows = Array.isArray(state.projects) ? state.projects.slice(0, 120) : [];
if (!rows.length) {
rows = (state.importantCases || [])
.filter((item) => {
const template = String(item.template_key || '').trim().toLowerCase();
const taskType = String(item.project_task_type || '').trim().toLowerCase();
return template === 'projekt' || template === 'project' || taskType === 'project';
})
.slice(0, 120)
.map((item) => ({
id: item.id,
name: item.titel,
description: item.customer_name || '',
status: item.status,
started_at: item.start_date || item.created_at,
risk_level: 'low',
risk_score: 0,
open_tasks: 1,
open_blockers: 0,
ended_at: item.deadline,
next_step: Array.isArray(item.open_todo_titles) ? item.open_todo_titles[0] : null,
responsible_name: item.ansvarlig_navn || null,
}));
}
if (!rows.length) {
list.innerHTML = '<div class="mc-feed-meta">Ingen projekter fundet endnu. Opret projekt eller tilknyt projektsager.</div>';
return;
}
const details = state.projectsPayload?.details || {};
list.innerHTML = rows.map((item) => {
const projectId = Number(item.id || 0);
const detail = details[String(projectId)] || {};
const milestone = (detail.milestones || []).find((entry) => !['completed', 'cancelled'].includes(String(entry.status || '').toLowerCase()));
const taskWithTodo = (detail.tasks || []).find((task) => Array.isArray(task.open_todo_titles) && task.open_todo_titles.length);
const nextStep = item.next_step
|| (detail.project_open_todo_titles || [])[0]
|| milestone?.title
|| taskWithTodo?.open_todo_titles?.[0]
|| 'Næste step er ikke defineret';
const responsible = item.responsible_name || item.ansvarlig_navn || 'Ikke tildelt';
const initials = responsible === 'Ikke tildelt'
? ''
: responsible.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
const startDate = item.started_at || item.start_date || item.created_at;
const endDate = item.ended_at || item.deadline;
const openTasks = Number(item.open_tasks || 0);
const blockers = Number(item.open_blockers || 0);
const risk = String(item.risk_level || 'low').toLowerCase();
const subcases = Array.isArray(detail.tasks) ? detail.tasks : [];
const subcasesHtml = subcases.map((task) => {
const taskResponsible = task.responsible_name || 'Ikke tildelt';
const taskInitials = taskResponsible === 'Ikke tildelt'
? ''
: taskResponsible.split(/\s+/).filter(Boolean).slice(0, 2).map((part) => part[0]).join('').toUpperCase();
const taskStart = task.start_date || task.created_at;
const taskEnd = task.deadline;
const todos = Array.isArray(task.open_todos) ? task.open_todos : [];
const todosHtml = todos.length
? todos.map((todo) => `
<div class="mc-project-todo">
<i class="bi ${todo.is_next ? 'bi-arrow-right-circle-fill' : 'bi-square'}"></i>
<span>${escapeHtml(todo.title || 'Uden titel')}</span>
<span class="mc-project-todo-date">${todo.due_date ? `frist ${escapeHtml(formatShortDate(todo.due_date))}` : ''}</span>
</div>
`).join('')
: '<div class="mc-feed-meta">Ingen åbne todo-punkter</div>';
return `
<div class="mc-project-subcase">
<div>
<a class="mc-project-subcase-title" href="${getCaseHref(task.id)}">
<i class="bi bi-diagram-2"></i>#${Number(task.id || 0)} ${escapeHtml(task.titel || 'Uden titel')}
</a>
<div class="mc-case-sub">Undersag</div>
</div>
<div class="mc-project-period">
<span><i class="bi bi-play-circle"></i>${escapeHtml(formatShortDate(taskStart))}</span>
<span><i class="bi bi-flag"></i>${escapeHtml(formatShortDate(taskEnd))}</span>
</div>
<div class="mc-project-todos">${todosHtml}</div>
<div class="mc-responsible">
<span class="mc-responsible-avatar">${escapeHtml(taskInitials)}</span>
<span class="mc-responsible-name">${escapeHtml(taskResponsible)}</span>
</div>
<div><span class="mc-badge mc-project-status">${escapeHtml(task.status || '-')}</span></div>
</div>
`;
}).join('');
return `
<div class="mc-project-entry">
<div class="mc-project-portfolio-row" data-project-id="${projectId}" tabindex="0" role="button" aria-label="Åbn projekt ${escapeHtml(item.name || item.titel || '')}">
<div>
<div class="mc-project-name">#${projectId} ${escapeHtml(item.name || item.titel || 'Uden titel')}</div>
<div class="mc-case-sub">${escapeHtml(item.customer_name || item.description || 'Ingen kunde angivet')}</div>
<div class="mc-project-meta-row">
<span class="mc-badge">${openTasks} åbne</span>
${blockers ? `<span class="mc-badge" style="color:#ffc1c1;border-color:rgba(239,68,68,.45)">${blockers} blocker${blockers === 1 ? '' : 's'}</span>` : ''}
<span class="mc-badge">${escapeHtml(risk)} risiko</span>
</div>
</div>
<div class="mc-project-period">
<span><i class="bi bi-play-circle"></i>${escapeHtml(formatShortDate(startDate))}</span>
<span><i class="bi bi-flag"></i>${escapeHtml(formatShortDate(endDate))}</span>
</div>
<div class="mc-next-step">
<i class="bi bi-arrow-right-circle-fill"></i>
<span>${escapeHtml(nextStep)}</span>
</div>
<div class="mc-responsible">
<span class="mc-responsible-avatar">${escapeHtml(initials)}</span>
<span class="mc-responsible-name">${escapeHtml(responsible)}</span>
</div>
<div><span class="mc-badge mc-project-status">${escapeHtml(item.status || '-')}</span></div>
</div>
${subcasesHtml ? `<div class="mc-project-subcases">${subcasesHtml}</div>` : ''}
</div>
`;
}).join('');
list.querySelectorAll('.mc-project-portfolio-row').forEach((el) => {
const open = () => {
openProjectDetail(el.dataset.projectId);
resetIdleTimer();
};
el.addEventListener('click', open);
el.addEventListener('keydown', (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
open();
}
});
});
}
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 renderDayTabs() {
const host = document.getElementById('dayTabs');
if (!host) return;
host.querySelectorAll('.mc-day-tab').forEach((btn) => {
const key = btn.dataset.dayTab || 'new_cases';
btn.classList.toggle('active', key === state.dayTab);
});
document.querySelectorAll('.mc-day-pane').forEach((pane) => {
pane.classList.remove('active');
});
const activePane = document.getElementById(`dayPane-${state.dayTab}`);
if (activePane) {
activePane.classList.add('active');
}
}
function renderDayUnassignedCases() {
const list = document.getElementById('dayUnassignedList');
if (!list) return;
const rows = Array.isArray(state.dayUnassignedCases) ? state.dayUnassignedCases : [];
if (!rows.length) {
list.innerHTML = '<div class="mc-feed-meta">Ingen ikke-tildelte sager lige nu.</div>';
return;
}
const userOptions = (state.assignmentUsers || []).map((user) => (
`<option value="${Number(user.user_id)}">${escapeHtml(user.display_name || 'Ukendt')}</option>`
)).join('');
const groupOptions = (state.assignmentGroups || []).map((group) => (
`<option value="${Number(group.id)}">${escapeHtml(group.name || 'Ukendt')}</option>`
)).join('');
list.innerHTML = rows.slice(0, 120).map((item) => {
const caseId = Number(item.id || 0);
const title = item.titel || 'Uden titel';
const desc = truncateText(item.beskrivelse || '', 220);
const currentUserId = Number(item.ansvarlig_bruger_id || 0);
const currentGroupId = Number(item.assigned_group_id || 0);
const hasUser = Number.isFinite(currentUserId) && currentUserId > 0;
const hasGroup = Number.isFinite(currentGroupId) && currentGroupId > 0;
const missingParts = [];
if (!hasUser) missingParts.push('mangler tekniker');
if (!hasGroup) missingParts.push('mangler gruppe');
const userSelectOptions = [
'<option value="">Tildel tekniker...</option>',
userOptions,
].join('');
const groupSelectOptions = [
'<option value="">Tildel gruppe...</option>',
groupOptions,
].join('');
return `
<div class="mc-day-case-card" id="dayCase-${caseId}">
<div class="mc-day-case-title">
<a class="mc-case-link" href="${getCaseHref(caseId)}">#${caseId} ${escapeHtml(title)}</a>
</div>
<div class="mc-day-case-sub">
${escapeHtml(item.customer_name || 'Ukendt kunde')} • Status: ${escapeHtml(item.status || '-')} • Start: ${escapeHtml(formatShortDate(item.start_date))} • Deadline: ${escapeHtml(formatShortDate(item.deadline))}
${missingParts.length ? `${escapeHtml(missingParts.join(' + '))}` : ''}
</div>
${desc ? `<div class="mc-day-case-desc">${escapeHtml(desc)}</div>` : ''}
<div class="mc-day-assign-row">
<select class="mc-day-select" id="assignUser-${caseId}">
${userSelectOptions}
</select>
<select class="mc-day-select" id="assignGroup-${caseId}">
${groupSelectOptions}
</select>
<button type="button" class="mc-day-btn" onclick="quickAssignCase(${caseId})">Tildel begge</button>
</div>
<div class="mc-day-actions">
<button type="button" class="mc-day-touch-btn" onclick="quickAssignUser(${caseId})">Tildel medarbejder</button>
<button type="button" class="mc-day-touch-btn" onclick="quickAssignGroup(${caseId})">Tildel gruppe</button>
<button type="button" class="mc-day-touch-btn secondary" onclick="openDayCaseQuick(${caseId})">Mere info</button>
</div>
</div>
`;
}).join('');
rows.slice(0, 120).forEach((item) => {
const caseId = Number(item.id || 0);
const userSelect = document.getElementById(`assignUser-${caseId}`);
const groupSelect = document.getElementById(`assignGroup-${caseId}`);
const currentUserId = Number(item.ansvarlig_bruger_id || 0);
const currentGroupId = Number(item.assigned_group_id || 0);
if (userSelect && Number.isFinite(currentUserId) && currentUserId > 0) {
userSelect.value = String(currentUserId);
}
if (groupSelect && Number.isFinite(currentGroupId) && currentGroupId > 0) {
groupSelect.value = String(currentGroupId);
}
});
}
function renderDayAgentWorkloads() {
const host = document.getElementById('dayAgentsList');
if (!host) return;
const rows = Array.isArray(state.dayAgentWorkloads) ? state.dayAgentWorkloads : [];
if (!rows.length) {
host.innerHTML = '<div class="mc-feed-meta">Ingen aktive agent-opgaver med start/deadline i dag eller tidligere.</div>';
return;
}
host.innerHTML = rows.map((agent) => {
const cases = Array.isArray(agent.case_list) ? agent.case_list : [];
return `
<div class="mc-agent-card">
<div class="mc-agent-head">
<div class="mc-agent-name">${escapeHtml(agent.assignee_name || 'Ufordelt')}</div>
<span class="mc-badge mc-day-mini">${Number(agent.total_cases || 0)} sager</span>
</div>
<div class="mc-agent-metrics">
<span class="mc-badge mc-day-mini warn">I dag: ${Number(agent.due_today_cases || 0)}</span>
<span class="mc-badge mc-day-mini alert">Overskredet: ${Number(agent.overdue_cases || 0)}</span>
<span class="mc-badge mc-day-mini">Startet: ${Number(agent.started_cases || 0)}</span>
</div>
<div class="mc-day-agent-cases">
${cases.length ? cases.map((item) => `
<div class="mc-day-agent-case">
<a class="mc-case-link" href="${getCaseHref(item.id)}">#${Number(item.id || 0)} ${escapeHtml(item.titel || 'Uden titel')}</a>
<div class="mc-case-sub">${escapeHtml(item.customer_name || 'Ukendt kunde')} • Start: ${escapeHtml(formatShortDate(item.start_date))} • Deadline: ${escapeHtml(formatShortDate(item.deadline))}</div>
</div>
`).join('') : '<div class="mc-feed-meta">Ingen sager i listen</div>'}
</div>
</div>
`;
}).join('');
}
async function quickAssignCase(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return;
const userSelect = document.getElementById(`assignUser-${id}`);
const groupSelect = document.getElementById(`assignGroup-${id}`);
const card = document.getElementById(`dayCase-${id}`);
const button = card?.querySelector('.mc-day-btn');
const ansvarlig_bruger_id = toOptionalInt(userSelect?.value);
const assigned_group_id = toOptionalInt(groupSelect?.value);
if (ansvarlig_bruger_id === null && assigned_group_id === null) {
alert('Vælg tekniker eller gruppe først.');
return;
}
const payload = {
ansvarlig_bruger_id,
assigned_group_id,
};
if (button) {
button.disabled = true;
button.textContent = 'Gemmer...';
}
try {
const res = await fetch(`/api/v1/sag/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.detail || `HTTP ${res.status}`);
}
await loadInitialState();
} catch (error) {
alert(`Kunne ikke tildele sag: ${error?.message || 'ukendt fejl'}`);
} finally {
if (button) {
button.disabled = false;
button.textContent = 'Tildel';
}
}
}
async function patchDayCase(caseId, payload, button, buttonText = 'Gemmer...') {
if (!payload || typeof payload !== 'object') return;
const btn = button || null;
const originalText = btn ? btn.textContent : null;
if (btn) {
btn.disabled = true;
btn.textContent = buttonText;
}
try {
const res = await fetch(`/api/v1/sag/${caseId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.detail || `HTTP ${res.status}`);
}
await loadInitialState();
} catch (error) {
alert(`Kunne ikke opdatere sag: ${error?.message || 'ukendt fejl'}`);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = originalText || 'Gem';
}
}
}
async function quickAssignUser(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return;
const userSelect = document.getElementById(`assignUser-${id}`);
const userId = toOptionalInt(userSelect?.value);
if (userId === null) {
alert('Vælg en medarbejder først.');
return;
}
const card = document.getElementById(`dayCase-${id}`);
const button = card?.querySelector('.mc-day-actions .mc-day-touch-btn');
await patchDayCase(id, { ansvarlig_bruger_id: userId }, button, 'Tildeler...');
}
async function quickAssignGroup(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return;
const groupSelect = document.getElementById(`assignGroup-${id}`);
const groupId = toOptionalInt(groupSelect?.value);
if (groupId === null) {
alert('Vælg en gruppe først.');
return;
}
const card = document.getElementById(`dayCase-${id}`);
const buttons = card?.querySelectorAll('.mc-day-actions .mc-day-touch-btn');
const button = buttons && buttons.length > 1 ? buttons[1] : null;
await patchDayCase(id, { assigned_group_id: groupId }, button, 'Tildeler...');
}
function openDayCaseQuick(caseId) {
const id = Number(caseId || 0);
if (!Number.isFinite(id) || id <= 0) return;
const item = getDayCaseById(id);
if (!item) {
alert('Kunne ikke finde sag i Dagen-listen.');
return;
}
const title = document.getElementById('dayCaseQuickTitle');
const meta = document.getElementById('dayCaseQuickMeta');
const userSelect = document.getElementById('dayCaseQuickUser');
const groupSelect = document.getElementById('dayCaseQuickGroup');
const startInput = document.getElementById('dayCaseQuickStart');
const deadlineInput = document.getElementById('dayCaseQuickDeadline');
const descInput = document.getElementById('dayCaseQuickDesc');
if (!title || !meta || !userSelect || !groupSelect || !startInput || !deadlineInput || !descInput) {
return;
}
state.selectedDayCaseId = id;
title.textContent = `#${id} ${item.titel || 'Uden titel'}`;
meta.textContent = `${item.customer_name || 'Ukendt kunde'} • Status: ${item.status || '-'} • Type: ${item.case_type || item.template_key || '-'}`;
descInput.value = String(item.beskrivelse || '').trim();
const userOptions = [
'<option value="">Ingen medarbejder</option>',
...(state.assignmentUsers || []).map((user) => (`
<option value="${Number(user.user_id)}">${escapeHtml(user.display_name || 'Ukendt')}</option>
`)),
];
const groupOptions = [
'<option value="">Ingen gruppe</option>',
...(state.assignmentGroups || []).map((group) => (`
<option value="${Number(group.id)}">${escapeHtml(group.name || 'Ukendt')}</option>
`)),
];
userSelect.innerHTML = userOptions.join('');
groupSelect.innerHTML = groupOptions.join('');
userSelect.value = Number(item.ansvarlig_bruger_id || 0) > 0 ? String(Number(item.ansvarlig_bruger_id)) : '';
groupSelect.value = Number(item.assigned_group_id || 0) > 0 ? String(Number(item.assigned_group_id)) : '';
startInput.value = toDatetimeLocalValue(item.start_date);
deadlineInput.value = toDatetimeLocalValue(item.deadline);
const modal = getOrCreateDayCaseModal();
if (modal) modal.show();
}
async function saveDayCaseQuick() {
const id = Number(state.selectedDayCaseId || 0);
if (!Number.isFinite(id) || id <= 0) return;
const userSelect = document.getElementById('dayCaseQuickUser');
const groupSelect = document.getElementById('dayCaseQuickGroup');
const startInput = document.getElementById('dayCaseQuickStart');
const deadlineInput = document.getElementById('dayCaseQuickDeadline');
const saveBtn = document.getElementById('dayCaseQuickSaveBtn');
if (!userSelect || !groupSelect || !startInput || !deadlineInput) return;
const payload = {
ansvarlig_bruger_id: toOptionalInt(userSelect.value),
assigned_group_id: toOptionalInt(groupSelect.value),
start_date: startInput.value ? new Date(startInput.value).toISOString() : null,
deadline: deadlineInput.value ? new Date(deadlineInput.value).toISOString() : null,
};
await patchDayCase(id, payload, saveBtn, 'Gemmer...');
const modal = getOrCreateDayCaseModal();
if (modal) modal.hide();
}
window.quickAssignCase = quickAssignCase;
window.quickAssignUser = quickAssignUser;
window.quickAssignGroup = quickAssignGroup;
window.openDayCaseQuick = openDayCaseQuick;
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(`Bevægelse: ${cameraName}${formatDate(state.cameraMotion.timestamp)}`);
return;
}
setMotionBadge('');
}
function renderAll() {
renderCommandDeck();
renderEventRail();
renderCaseFilterChips();
renderKpis();
renderAlerts();
renderCallHero();
renderActiveCalls();
renderCallHistory();
renderDeadlines();
renderImportantCases();
renderProjectCases();
renderDayTabs();
renderDayUnassignedCases();
renderDayAgentWorkloads();
renderRecentEmails();
renderEnvironmentReadings();
renderFeed();
renderMotionBadge();
applyDurationButtons();
renderCameraPreview();
}
function applyMissionState(payload) {
if (!payload) return;
state.lastUpdatedAt = Date.now();
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.projectsPayload = payload.projects && typeof payload.projects === 'object'
? payload.projects
: state.projectsPayload;
state.projects = Array.isArray(state.projectsPayload.projects)
? state.projectsPayload.projects
: state.projects;
state.dayUnassignedCases = Array.isArray(payload.day_unassigned_cases) ? payload.day_unassigned_cases : state.dayUnassignedCases;
state.dayAgentWorkloads = Array.isArray(payload.day_agent_workloads) ? payload.day_agent_workloads : state.dayAgentWorkloads;
state.assignmentUsers = Array.isArray(payload.assignment_users) ? payload.assignment_users : state.assignmentUsers;
state.assignmentGroups = Array.isArray(payload.assignment_groups) ? payload.assignment_groups : state.assignmentGroups;
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;
state.lastUpdatedAt = Date.now();
renderKpis();
renderCommandDeck();
return;
}
if (event === 'call_ringing') {
state.activeCalls = [data, ...state.activeCalls.filter((c) => c.call_id !== data.call_id)];
state.lastUpdatedAt = Date.now();
renderCallHero();
renderActiveCalls();
renderCommandDeck();
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);
state.lastUpdatedAt = Date.now();
renderCallHero();
renderActiveCalls();
renderCommandDeck();
if (event === 'call_hangup') window.setTimeout(loadCallHistory, 500);
return;
}
if (event === 'uptime_alert') {
state.activeAlerts = Array.isArray(data.active_alerts) ? data.active_alerts : state.activeAlerts;
state.lastUpdatedAt = Date.now();
renderAlerts();
renderCommandDeck();
renderEventRail();
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);
state.lastUpdatedAt = Date.now();
renderFeed();
renderEventRail();
}
} catch (error) {
console.error('Mission message parse failed', error);
}
};
}
function bindUiEvents() {
const pauseBtn = document.getElementById('pauseAutoResetBtn');
if (pauseBtn) {
pauseBtn.addEventListener('click', () => {
toggleIdlePause();
resetIdleTimer();
});
}
document.getElementById('missionNav')?.querySelectorAll('.mc-nav-btn').forEach((btn) => {
btn.addEventListener('click', () => {
activateView(btn.dataset.view || 'overview');
resetIdleTimer();
});
});
document.getElementById('projectBackBtn')?.addEventListener('click', () => {
hideProjectDetail();
resetIdleTimer();
});
if (!projectOnlyMode) {
document.getElementById('dayTabs')?.querySelectorAll('.mc-day-tab').forEach((btn) => {
btn.addEventListener('click', () => {
state.dayTab = btn.dataset.dayTab || 'new_cases';
renderDayTabs();
resetIdleTimer();
});
});
}
document.getElementById('dayCaseQuickSaveBtn')?.addEventListener('click', () => {
saveDayCaseQuick();
resetIdleTimer();
});
document.getElementById('fullscreenBtn')?.addEventListener('click', async () => {
try {
if (document.fullscreenElement) {
await document.exitFullscreen();
} else {
await document.documentElement.requestFullscreen();
}
} catch (error) {
console.warn('Fullscreen er ikke tilgængelig', error);
}
resetIdleTimer();
});
document.getElementById('commandPaletteBtn')?.addEventListener('click', () => {
openCommandPalette();
resetIdleTimer();
});
document.getElementById('commandPaletteInput')?.addEventListener('input', (event) => {
state.commandIndex = 0;
renderCommandResults(event.target.value);
});
document.getElementById('commandPalette')?.addEventListener('pointerdown', (event) => {
if (event.target === event.currentTarget) closeCommandPalette();
});
document.getElementById('refreshCallHistoryBtn')?.addEventListener('click', () => {
loadCallHistory();
resetIdleTimer();
});
document.getElementById('callCaseSearchInput')?.addEventListener('input', (event) => {
if (state.callCaseSearchTimer) clearTimeout(state.callCaseSearchTimer);
state.callCaseSearchTimer = window.setTimeout(() => searchCallCases(event.target.value), 220);
});
document.addEventListener('fullscreenchange', () => {
const icon = document.querySelector('#fullscreenBtn i');
if (icon) {
icon.className = document.fullscreenElement
? 'bi bi-fullscreen-exit'
: 'bi bi-arrows-fullscreen';
}
});
const hotkeyViews = projectOnlyMode
? ['project']
: ['overview', 'day', 'important', 'project', 'calls', 'camera'];
document.addEventListener('keydown', (event) => {
const paletteOpen = document.getElementById('commandPalette')?.classList.contains('open');
if (paletteOpen) {
if (event.key === 'Escape') {
event.preventDefault();
closeCommandPalette();
return;
}
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const direction = event.key === 'ArrowDown' ? 1 : -1;
const count = state.commandResults.length;
if (count) {
state.commandIndex = (state.commandIndex + direction + count) % count;
renderCommandResults(document.getElementById('commandPaletteInput')?.value || '');
}
return;
}
if (event.key === 'Enter') {
event.preventDefault();
executeCommand(state.commandIndex);
}
return;
}
const target = event.target;
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target instanceof HTMLSelectElement) {
return;
}
if (event.key === '/') {
event.preventDefault();
openCommandPalette();
return;
}
const index = Number(event.key) - 1;
if (Number.isInteger(index) && index >= 0 && index < hotkeyViews.length) {
activateView(hotkeyViews[index]);
resetIdleTimer();
}
if (event.key.toLowerCase() === 'f') {
document.getElementById('fullscreenBtn')?.click();
}
});
['pointerdown', 'keydown', 'mousemove', 'touchstart'].forEach((name) => {
document.addEventListener(name, resetIdleTimer, { passive: true });
});
setInterval(() => {
updateIdleStateLabel();
updateMissionClock();
}, 1000);
}
document.addEventListener('DOMContentLoaded', async () => {
bindUiEvents();
activateView(initialMissionView);
resetIdleTimer();
updateIdleStateLabel();
updateMissionClock();
try {
await loadInitialState();
await loadCallHistory();
updateConnectionLabel('Mission loaded');
} catch (error) {
updateConnectionLabel('Fejl ved initial load');
console.error(error);
}
startPollingFallback(3000);
connectWs();
});
})();
</script>
{% endblock %}