bmc_hub/app/dashboard/frontend/mission_control_v2.html

4131 lines
142 KiB
HTML
Raw Permalink Normal View History

{% 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 %}