4131 lines
142 KiB
HTML
4131 lines
142 KiB
HTML
{% 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">Kø & 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('&', '&')
|
||
.replaceAll('<', '<')
|
||
.replaceAll('>', '>')
|
||
.replaceAll('"', '"')
|
||
.replaceAll("'", ''');
|
||
}
|
||
|
||
function formatDate(value) {
|
||
if (!value) return '-';
|
||
const d = new Date(value);
|
||
if (Number.isNaN(d.getTime())) return '-';
|
||
return d.toLocaleString('da-DK');
|
||
}
|
||
|
||
function 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 KPI’er', 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 %}
|