2104 lines
68 KiB
HTML
2104 lines
68 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||
<title>ServiceDesk Monitor — OMC</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||
<link href="https://fonts.googleapis.com/css2?family=Barlow+Condensed:wght@400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@300;400;500;600&display=swap" rel="stylesheet" />
|
||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||
<script src="https://cdn.jsdelivr.net/npm/papaparse@5.4.1/papaparse.min.js"></script>
|
||
<style>
|
||
:root {
|
||
--bg: #eef1f6;
|
||
--bg-alt: #e4e9f0;
|
||
--surface: #f7f9fc;
|
||
--surface-2: #ffffff;
|
||
--rule: #cdd4de;
|
||
--rule-strong: #b0bbc9;
|
||
--rule-faint: #e0e5ed;
|
||
--navy: #0d2444;
|
||
--navy-mid: #1a3a6b;
|
||
--steel: #2c5282;
|
||
--sky: #1a73c8;
|
||
--sky-light: #e8f1fb;
|
||
--go: #0a7c4e;
|
||
--go-bg: #e6f5ef;
|
||
--warn: #b45309;
|
||
--warn-bg: #fef3e2;
|
||
--alert: #c0392b;
|
||
--alert-bg: #fdecea;
|
||
--info: #1565c0;
|
||
--info-bg: #e3edf9;
|
||
--text: #1c2b3a;
|
||
--text-2: #4a5f75;
|
||
--text-3: #7a8fa3;
|
||
--r: 4px;
|
||
--r-lg: 6px;
|
||
--shadow-sm: 0 1px 3px rgba(13, 36, 68, 0.08), 0 1px 2px rgba(13, 36, 68, 0.04);
|
||
--shadow: 0 2px 8px rgba(13, 36, 68, 0.1), 0 1px 3px rgba(13, 36, 68, 0.06);
|
||
--shadow-md: 0 4px 16px rgba(13, 36, 68, 0.12), 0 2px 6px rgba(13, 36, 68, 0.07);
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
html, body { margin: 0; padding: 0; }
|
||
body {
|
||
font-family: "IBM Plex Sans", system-ui, sans-serif;
|
||
font-size: 13px;
|
||
color: var(--text);
|
||
background: var(--bg);
|
||
min-height: 100vh;
|
||
position: relative;
|
||
}
|
||
body::before {
|
||
content: "";
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 0;
|
||
pointer-events: none;
|
||
background-image:
|
||
linear-gradient(rgba(44, 82, 130, 0.04) 1px, transparent 1px),
|
||
linear-gradient(90deg, rgba(44, 82, 130, 0.04) 1px, transparent 1px);
|
||
background-size: 48px 48px;
|
||
}
|
||
|
||
::-webkit-scrollbar { width: 7px; height: 7px; }
|
||
::-webkit-scrollbar-track { background: var(--bg-alt); }
|
||
::-webkit-scrollbar-thumb { background: var(--rule-strong); border-radius: 4px; }
|
||
::-webkit-scrollbar-thumb:hover { background: var(--steel); }
|
||
|
||
.page {
|
||
max-width: 1480px;
|
||
margin: 0 auto;
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
|
||
/* Topbar */
|
||
.topbar {
|
||
height: 52px;
|
||
background: var(--navy);
|
||
display: flex;
|
||
align-items: stretch;
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
.topbar::after {
|
||
content: "";
|
||
position: absolute;
|
||
right: 320px;
|
||
width: 60px;
|
||
height: 100%;
|
||
background: var(--sky);
|
||
clip-path: polygon(16px 0, 100% 0, calc(100% - 16px) 100%, 0 100%);
|
||
opacity: 0.35;
|
||
pointer-events: none;
|
||
}
|
||
.topbar-brand {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 0 18px;
|
||
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
.brand-hex {
|
||
width: 34px;
|
||
height: 34px;
|
||
background: var(--sky);
|
||
clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
|
||
flex-shrink: 0;
|
||
position: relative;
|
||
}
|
||
.brand-hex::after {
|
||
content: "◈";
|
||
position: absolute;
|
||
inset: 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #fff;
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
}
|
||
.brand-text-main {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
letter-spacing: 2.5px;
|
||
text-transform: uppercase;
|
||
color: #fff;
|
||
line-height: 1.2;
|
||
}
|
||
.brand-text-sub {
|
||
font-family: "IBM Plex Mono", monospace;
|
||
font-size: 9px;
|
||
letter-spacing: 2px;
|
||
color: rgba(255, 255, 255, 0.45);
|
||
margin-top: 2px;
|
||
}
|
||
.topbar-seg {
|
||
display: flex;
|
||
flex-direction: column;
|
||
justify-content: center;
|
||
padding: 0 18px;
|
||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||
position: relative;
|
||
z-index: 1;
|
||
}
|
||
.topbar-seg:last-child { margin-left: auto; border-right: none; }
|
||
.seg-label {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 10px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 2px;
|
||
color: rgba(255, 255, 255, 0.4);
|
||
}
|
||
.seg-value {
|
||
font-family: "IBM Plex Mono", monospace;
|
||
font-size: 11px;
|
||
color: rgba(255, 255, 255, 0.85);
|
||
margin-top: 2px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.status-pip {
|
||
width: 7px;
|
||
height: 7px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
.status-pip.pip-green {
|
||
background: #22c55e;
|
||
box-shadow: 0 0 6px rgba(34, 197, 94, 0.8);
|
||
}
|
||
#control-date {
|
||
background: rgba(255, 255, 255, 0.08);
|
||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||
color: rgba(255, 255, 255, 0.9);
|
||
border-radius: var(--r);
|
||
padding: 2px 6px;
|
||
font-family: "IBM Plex Mono", monospace;
|
||
font-size: 11px;
|
||
color-scheme: dark;
|
||
}
|
||
.btn-today {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
padding: 4px 10px;
|
||
border-radius: var(--r);
|
||
border: 1px solid var(--sky);
|
||
background: rgba(26, 115, 200, 0.6);
|
||
color: #fff;
|
||
cursor: pointer;
|
||
}
|
||
.btn-today:hover { background: rgba(26, 115, 200, 0.85); }
|
||
|
||
/* Section head */
|
||
.section-head {
|
||
height: 36px;
|
||
background: var(--navy-mid);
|
||
border-top: 2px solid var(--sky);
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 20px;
|
||
gap: 12px;
|
||
}
|
||
.section-head-label {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
letter-spacing: 3px;
|
||
text-transform: uppercase;
|
||
color: rgba(255, 255, 255, 0.55);
|
||
}
|
||
.section-head-title {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: #fff;
|
||
}
|
||
.section-head-rule {
|
||
flex: 1;
|
||
height: 1px;
|
||
background: rgba(255, 255, 255, 0.1);
|
||
}
|
||
.section-head-info {
|
||
font-family: "IBM Plex Mono", monospace;
|
||
font-size: 10px;
|
||
color: rgba(255, 255, 255, 0.4);
|
||
}
|
||
|
||
.main { padding: 16px 20px 32px; }
|
||
|
||
/* Upload */
|
||
.upload-panel {
|
||
background: #fff;
|
||
border: 1px solid var(--rule);
|
||
border-left: 3px solid var(--sky);
|
||
border-radius: var(--r);
|
||
display: flex;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
gap: 14px;
|
||
padding: 12px 16px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.upload-panel label.lbl {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
color: var(--text-3);
|
||
}
|
||
.btn-upload {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1.5px;
|
||
padding: 8px 16px;
|
||
border: none;
|
||
border-radius: var(--r);
|
||
background: var(--steel);
|
||
color: #fff;
|
||
cursor: pointer;
|
||
}
|
||
.btn-upload:hover { background: var(--navy-mid); }
|
||
.upload-hint {
|
||
font-family: "IBM Plex Mono", monospace;
|
||
font-size: 11px;
|
||
color: var(--text-3);
|
||
}
|
||
|
||
/* Messages */
|
||
.msg {
|
||
display: none;
|
||
padding: 10px 14px;
|
||
margin-bottom: 10px;
|
||
border-radius: var(--r);
|
||
border-left: 3px solid;
|
||
font-family: "IBM Plex Mono", monospace;
|
||
font-size: 11px;
|
||
}
|
||
.msg.show { display: block; }
|
||
.msg-ok {
|
||
background: var(--go-bg);
|
||
border-left-color: var(--go);
|
||
color: var(--text);
|
||
}
|
||
.msg-load {
|
||
background: var(--info-bg);
|
||
border-left-color: var(--sky);
|
||
color: var(--text);
|
||
position: relative;
|
||
padding-left: 36px;
|
||
}
|
||
.msg-load::before {
|
||
content: "";
|
||
position: absolute;
|
||
left: 12px;
|
||
top: 50%;
|
||
margin-top: -9px;
|
||
width: 16px;
|
||
height: 16px;
|
||
border: 2px solid var(--rule);
|
||
border-top-color: var(--sky);
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
.msg-err {
|
||
background: var(--alert-bg);
|
||
border-left-color: var(--alert);
|
||
color: var(--text);
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
#dashboard-content { display: none; }
|
||
#dashboard-content.visible { display: block; }
|
||
|
||
/* KPI */
|
||
.kpi-strip {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 10px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.kpi-card {
|
||
background: #fff;
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-lg);
|
||
overflow: hidden;
|
||
box-shadow: var(--shadow-sm);
|
||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||
}
|
||
.kpi-card:hover {
|
||
transform: translateY(-1px);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.kpi-accent { height: 3px; }
|
||
.kpi-card.total .kpi-accent { background: var(--steel); }
|
||
.kpi-card.closed .kpi-accent { background: var(--go); }
|
||
.kpi-card.progress .kpi-accent { background: var(--warn); }
|
||
.kpi-card.overdue .kpi-accent { background: var(--alert); }
|
||
.kpi-card.overdue {
|
||
animation: kpi-pulse 2.8s ease-in-out infinite;
|
||
}
|
||
@keyframes kpi-pulse {
|
||
0%, 100% { box-shadow: var(--shadow-sm); }
|
||
50% { box-shadow: 0 0 0 3px rgba(192, 57, 43, 0.1), var(--shadow-sm); }
|
||
}
|
||
.kpi-body { padding: 12px 14px; }
|
||
.kpi-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 8px;
|
||
}
|
||
.kpi-num {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 36px;
|
||
font-weight: 800;
|
||
line-height: 1;
|
||
}
|
||
.kpi-card.total .kpi-num { color: var(--steel); }
|
||
.kpi-card.closed .kpi-num { color: var(--go); }
|
||
.kpi-card.progress .kpi-num { color: var(--warn); }
|
||
.kpi-card.overdue .kpi-num { color: var(--alert); }
|
||
.kpi-label {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 2px;
|
||
color: var(--text-3);
|
||
margin-top: 6px;
|
||
}
|
||
.kpi-sub {
|
||
font-family: "IBM Plex Mono", monospace;
|
||
font-size: 10px;
|
||
color: var(--text-3);
|
||
margin-top: 4px;
|
||
}
|
||
.kpi-icon {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 28px;
|
||
font-weight: 600;
|
||
opacity: 0.07;
|
||
line-height: 1;
|
||
user-select: none;
|
||
}
|
||
|
||
/* Filters */
|
||
.filters-bar {
|
||
background: #fff;
|
||
border: 1px solid var(--rule);
|
||
box-shadow: var(--shadow-sm);
|
||
padding: 8px 14px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
margin-bottom: 12px;
|
||
border-radius: var(--r);
|
||
}
|
||
.filters-bar > .lbl {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
color: var(--text-3);
|
||
padding-top: 9px;
|
||
}
|
||
.search-wrap {
|
||
flex: 1;
|
||
min-width: 200px;
|
||
position: relative;
|
||
}
|
||
.search-wrap::before {
|
||
content: "⌕";
|
||
position: absolute;
|
||
left: 10px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
font-size: 14px;
|
||
color: var(--text-3);
|
||
pointer-events: none;
|
||
}
|
||
.search-wrap input {
|
||
width: 100%;
|
||
padding: 8px 12px 8px 32px;
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r);
|
||
background: var(--bg);
|
||
font-family: "IBM Plex Sans", sans-serif;
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
}
|
||
.search-wrap input:focus {
|
||
outline: none;
|
||
border-color: var(--sky);
|
||
box-shadow: 0 0 0 3px rgba(26, 115, 200, 0.1);
|
||
}
|
||
|
||
.filter-group {
|
||
min-width: 150px;
|
||
position: relative;
|
||
}
|
||
.filter-select {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r);
|
||
background: var(--bg);
|
||
font-family: "IBM Plex Sans", sans-serif;
|
||
font-size: 12px;
|
||
color: var(--text-2);
|
||
cursor: pointer;
|
||
text-align: left;
|
||
}
|
||
.filter-select:hover { border-color: var(--rule-strong); }
|
||
.filter-options {
|
||
display: none;
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
top: 100%;
|
||
margin-top: 4px;
|
||
background: #fff;
|
||
border: 1px solid var(--rule-strong);
|
||
border-radius: var(--r);
|
||
box-shadow: var(--shadow-md);
|
||
max-height: 210px;
|
||
overflow-y: auto;
|
||
z-index: 50;
|
||
padding: 8px 0;
|
||
}
|
||
.filter-options.show { display: block; }
|
||
.filter-options label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 6px 12px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
color: var(--text-2);
|
||
}
|
||
.filter-options label:hover { background: var(--sky-light); }
|
||
.filter-options input[type="checkbox"] {
|
||
accent-color: var(--sky);
|
||
}
|
||
.selected-filters-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
margin-top: 6px;
|
||
}
|
||
.selected-filter {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 2px 10px;
|
||
border-radius: 20px;
|
||
background: var(--sky-light);
|
||
border: 1px solid rgba(26, 115, 200, 0.3);
|
||
color: var(--sky);
|
||
font-family: "IBM Plex Sans", sans-serif;
|
||
font-size: 11px;
|
||
}
|
||
.selected-filter button {
|
||
border: none;
|
||
background: none;
|
||
color: var(--sky);
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
padding: 0;
|
||
}
|
||
|
||
/* Tabs */
|
||
.tabs-bar {
|
||
background: #fff;
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r);
|
||
padding: 4px;
|
||
display: flex;
|
||
gap: 0;
|
||
margin-bottom: 12px;
|
||
}
|
||
.tab {
|
||
flex: 1;
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1.5px;
|
||
padding: 10px 12px;
|
||
border: none;
|
||
border-radius: var(--r);
|
||
background: transparent;
|
||
color: var(--text-3);
|
||
cursor: pointer;
|
||
}
|
||
.tab:hover { color: var(--text); background: var(--bg-alt); }
|
||
.tab.active {
|
||
background: var(--navy);
|
||
color: #fff;
|
||
}
|
||
.tab-content {
|
||
display: none;
|
||
animation: fadeup 0.22s ease forwards;
|
||
}
|
||
.tab-content.active { display: block; }
|
||
@keyframes fadeup {
|
||
from { opacity: 0; transform: translateY(5px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
/* Charts */
|
||
.charts-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 12px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.chart-panel {
|
||
background: #fff;
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-lg);
|
||
box-shadow: var(--shadow-sm);
|
||
overflow: hidden;
|
||
}
|
||
.chart-panel-head {
|
||
background: var(--bg);
|
||
border-bottom: 1px solid var(--rule);
|
||
padding: 8px 14px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
}
|
||
.chart-panel-indicator {
|
||
width: 3px;
|
||
height: 14px;
|
||
background: var(--sky);
|
||
border-radius: 2px;
|
||
flex-shrink: 0;
|
||
}
|
||
.chart-panel-title {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 2px;
|
||
text-transform: uppercase;
|
||
color: var(--text-2);
|
||
}
|
||
.chart-panel-body {
|
||
height: 260px;
|
||
display: flex;
|
||
align-items: stretch;
|
||
padding: 8px 8px 8px 12px;
|
||
}
|
||
.chart-canvas-wrap {
|
||
flex: 1;
|
||
min-width: 0;
|
||
position: relative;
|
||
}
|
||
.chart-legend-wrap {
|
||
width: 190px;
|
||
flex-shrink: 0;
|
||
padding-left: 14px;
|
||
overflow-y: auto;
|
||
}
|
||
.legend-item {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 6px;
|
||
}
|
||
.legend-dot {
|
||
width: 9px;
|
||
height: 9px;
|
||
border-radius: 50%;
|
||
flex-shrink: 0;
|
||
}
|
||
.legend-lbl {
|
||
font-family: "IBM Plex Sans", sans-serif;
|
||
font-size: 11px;
|
||
color: var(--text-2);
|
||
flex: 1;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.legend-val {
|
||
font-family: "IBM Plex Mono", monospace;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: var(--text);
|
||
}
|
||
|
||
.timeline-panel {
|
||
background: #fff;
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-lg);
|
||
box-shadow: var(--shadow-sm);
|
||
margin-bottom: 12px;
|
||
overflow: hidden;
|
||
}
|
||
.timeline-panel .chart-panel-body { height: auto; flex-direction: column; padding: 12px 14px 8px; }
|
||
.line-legend {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 14px;
|
||
margin-bottom: 8px;
|
||
font-family: "IBM Plex Sans", sans-serif;
|
||
font-size: 11px;
|
||
color: var(--text-2);
|
||
}
|
||
.line-legend span { display: inline-flex; align-items: center; gap: 6px; }
|
||
.line-legend i {
|
||
display: inline-block;
|
||
width: 12px;
|
||
height: 3px;
|
||
border-radius: 1px;
|
||
}
|
||
.line-chart-wrap { height: 240px; position: relative; }
|
||
|
||
.wt-head {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 2.5px;
|
||
color: var(--text-3);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
margin: 16px 0 8px;
|
||
padding: 0 4px;
|
||
}
|
||
.wt-head::before {
|
||
content: "";
|
||
width: 16px;
|
||
height: 2px;
|
||
background: var(--sky);
|
||
flex-shrink: 0;
|
||
}
|
||
.weekly-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 12px;
|
||
}
|
||
.weekly-table th {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
color: var(--text-3);
|
||
text-align: left;
|
||
padding: 8px 10px;
|
||
border-bottom: 1px solid var(--rule-faint);
|
||
background: var(--surface);
|
||
}
|
||
.weekly-table td {
|
||
padding: 8px 10px;
|
||
border-bottom: 1px solid var(--rule-faint);
|
||
font-family: "IBM Plex Mono", monospace;
|
||
font-size: 11px;
|
||
color: var(--text-2);
|
||
}
|
||
.weekly-table td.num {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: var(--text);
|
||
}
|
||
|
||
/* Table tab */
|
||
.table-panel {
|
||
background: #fff;
|
||
border: 1px solid var(--rule);
|
||
box-shadow: var(--shadow-sm);
|
||
border-radius: var(--r-lg);
|
||
overflow: hidden;
|
||
}
|
||
.table-scroll { overflow-x: auto; max-height: 70vh; }
|
||
.inc-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
min-width: 960px;
|
||
}
|
||
.inc-table thead {
|
||
background: var(--navy);
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 2;
|
||
}
|
||
.inc-table th {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1.8px;
|
||
color: rgba(255, 255, 255, 0.65);
|
||
text-align: left;
|
||
padding: 10px 13px;
|
||
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
white-space: nowrap;
|
||
}
|
||
.inc-table th:hover { color: #fff; }
|
||
.inc-table th .sort-indicator { font-size: 9px; margin-left: 4px; opacity: 0.9; }
|
||
.inc-table tbody tr:nth-child(even) { background: rgba(238, 241, 246, 0.5); }
|
||
.inc-table tbody tr:hover { background: var(--sky-light); }
|
||
.inc-table td {
|
||
font-family: "IBM Plex Sans", sans-serif;
|
||
font-size: 12px;
|
||
padding: 8px 13px;
|
||
border-right: 1px solid var(--rule-faint);
|
||
vertical-align: top;
|
||
}
|
||
.inc-table td.mono { font-family: "IBM Plex Mono", monospace; font-size: 11px; }
|
||
.number-link {
|
||
font-family: "IBM Plex Mono", monospace;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
color: var(--sky);
|
||
text-decoration: none;
|
||
}
|
||
.number-link:hover { color: var(--navy-mid); text-decoration: underline; }
|
||
td.overdue { color: var(--alert) !important; font-weight: 600; }
|
||
|
||
/* Status badges */
|
||
.status-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 2px 8px;
|
||
border-radius: 20px;
|
||
font-family: "IBM Plex Sans", sans-serif;
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
}
|
||
.status-badge::before {
|
||
content: "";
|
||
width: 5px;
|
||
height: 5px;
|
||
border-radius: 50%;
|
||
background: currentColor;
|
||
opacity: 0.8;
|
||
}
|
||
.status-badge.registered { background: #e3edf9; color: #1565c0; }
|
||
.status-badge.resolved { background: #e6f5ef; color: #0a7c4e; }
|
||
.status-badge.resumed { background: #fef3e2; color: #b45309; }
|
||
.status-badge.waiting { background: #f3e8fd; color: #7c3aed; }
|
||
.status-badge.closed { background: #e6f5ef; color: #065f46; }
|
||
.status-badge.chotvcom { background: #e0f2fe; color: #0369a1; }
|
||
.status-badge.inprogress { background: #fffbeb; color: #92400e; }
|
||
.status-badge.negotiation { background: #fdf2f8; color: #9d174d; }
|
||
.status-badge.negative { background: #fdecea; color: #c0392b; }
|
||
.status-badge.contractor { background: #e6faf6; color: #0d6b56; }
|
||
.status-badge.problem { background: #fff7ed; color: #c2410c; }
|
||
.status-badge.analiz { background: #f5f3ff; color: #5b21b6; }
|
||
.status-badge.chotvcom1 { background: #eff6ff; color: #1d4ed8; }
|
||
.status-badge.unknown { background: var(--bg-alt); color: var(--text-2); }
|
||
|
||
/* Employees */
|
||
.emp-panel {
|
||
background: #fff;
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r-lg);
|
||
box-shadow: var(--shadow-sm);
|
||
overflow: hidden;
|
||
}
|
||
.emp-block { border-bottom: 1px solid var(--rule-faint); }
|
||
.emp-block:last-child { border-bottom: none; }
|
||
.emp-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 12px 14px;
|
||
cursor: pointer;
|
||
border-left: 3px solid transparent;
|
||
transition: background 0.15s ease, border-color 0.15s ease;
|
||
}
|
||
.emp-header:hover { background: var(--bg); }
|
||
.emp-header.expanded {
|
||
background: var(--sky-light);
|
||
border-left-color: var(--sky);
|
||
border-bottom: 1px solid var(--rule);
|
||
}
|
||
.emp-chevron {
|
||
font-size: 10px;
|
||
color: var(--text-3);
|
||
transition: transform 0.2s ease, color 0.2s ease;
|
||
width: 14px;
|
||
text-align: center;
|
||
}
|
||
.emp-header.expanded .emp-chevron {
|
||
transform: rotate(90deg);
|
||
color: var(--sky);
|
||
}
|
||
.emp-name {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.emp-header.expanded .emp-name { color: var(--navy); }
|
||
.emp-stats {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
.emp-stat {
|
||
background: var(--bg);
|
||
border: 1px solid var(--rule);
|
||
border-radius: var(--r);
|
||
padding: 4px 12px;
|
||
text-align: center;
|
||
}
|
||
.emp-stat-val {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 18px;
|
||
font-weight: 800;
|
||
line-height: 1.1;
|
||
}
|
||
.emp-stat-lbl {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 8px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1.5px;
|
||
color: var(--text-3);
|
||
margin-top: 2px;
|
||
}
|
||
.s-total { color: var(--steel); }
|
||
.s-closed { color: var(--go); }
|
||
.s-prog { color: var(--warn); }
|
||
.s-overdue { color: var(--alert); }
|
||
|
||
.emp-table-wrap { display: none; padding: 0 14px 14px 38px; }
|
||
.emp-header.expanded + .emp-table-wrap { display: block; }
|
||
.emp-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 12px;
|
||
}
|
||
.emp-table thead { background: #f0f4fa; }
|
||
.emp-table th {
|
||
font-family: "Barlow Condensed", sans-serif;
|
||
font-size: 10px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 1px;
|
||
color: var(--text-2);
|
||
text-align: left;
|
||
padding: 8px 10px;
|
||
border-bottom: 1px solid var(--rule-faint);
|
||
}
|
||
.emp-table td {
|
||
padding: 8px 10px;
|
||
border-bottom: 1px solid var(--rule-faint);
|
||
background: var(--bg);
|
||
font-family: "IBM Plex Sans", sans-serif;
|
||
}
|
||
.emp-table td.mono { font-family: "IBM Plex Mono", monospace; font-size: 11px; }
|
||
.emp-table tbody tr:hover td { background: var(--sky-light); }
|
||
|
||
@media (max-width: 1100px) {
|
||
.charts-row { grid-template-columns: 1fr; }
|
||
.kpi-strip { grid-template-columns: repeat(2, 1fr); }
|
||
}
|
||
@media (max-width: 640px) {
|
||
.kpi-strip { grid-template-columns: 1fr; }
|
||
.topbar { flex-wrap: wrap; height: auto; }
|
||
.topbar-seg:last-child { margin-left: 0; }
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="page">
|
||
<header class="topbar">
|
||
<div class="topbar-brand">
|
||
<div class="brand-hex" aria-hidden="true"></div>
|
||
<div>
|
||
<div class="brand-text-main">ServiceDesk Monitor</div>
|
||
<div class="brand-text-sub">OMC · Ситуационный центр</div>
|
||
</div>
|
||
</div>
|
||
<div class="topbar-seg">
|
||
<span class="seg-label">Статус</span>
|
||
<span class="seg-value"><span class="status-pip pip-green" aria-hidden="true"></span> АКТИВЕН</span>
|
||
</div>
|
||
<div class="topbar-seg">
|
||
<span class="seg-label">Записей</span>
|
||
<span class="seg-value" id="statusbar-count">—</span>
|
||
</div>
|
||
<div class="topbar-seg">
|
||
<span class="seg-label">Контрольная дата</span>
|
||
<span class="seg-value">
|
||
<input type="date" id="control-date" />
|
||
<button type="button" class="btn-today" id="btn-today">Сегодня</button>
|
||
</span>
|
||
</div>
|
||
<div class="topbar-seg">
|
||
<span class="seg-label">Дата системы</span>
|
||
<span class="seg-value" id="current-date-display">—</span>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="section-head">
|
||
<span class="section-head-label">Подсистема</span>
|
||
<span class="section-head-title">Мониторинг инцидентов</span>
|
||
<span class="section-head-rule"></span>
|
||
<span class="section-head-info">Формат загрузки: CSV · JSON</span>
|
||
</div>
|
||
|
||
<main class="main">
|
||
<div class="upload-panel">
|
||
<span class="lbl">Источник данных</span>
|
||
<button type="button" class="btn-upload" id="btn-upload">↑ Загрузить файл</button>
|
||
<span class="upload-hint" id="upload-hint">Файл не выбран — CSV или JSON с колонками экспорта ServiceDesk</span>
|
||
<input type="file" id="file-input" accept=".csv,.json" hidden />
|
||
</div>
|
||
|
||
<div id="success" class="msg msg-ok"></div>
|
||
<div id="loading" class="msg msg-load"></div>
|
||
<div id="error" class="msg msg-err"></div>
|
||
|
||
<div id="dashboard-content">
|
||
<div class="kpi-strip">
|
||
<div class="kpi-card total">
|
||
<div class="kpi-accent"></div>
|
||
<div class="kpi-body">
|
||
<div class="kpi-row">
|
||
<div class="kpi-left">
|
||
<div class="kpi-num" id="kpi-total">—</div>
|
||
<div class="kpi-label">Всего в выборке</div>
|
||
</div>
|
||
<div class="kpi-icon">∑</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="kpi-card closed">
|
||
<div class="kpi-accent"></div>
|
||
<div class="kpi-body">
|
||
<div class="kpi-row">
|
||
<div class="kpi-left">
|
||
<div class="kpi-num" id="kpi-closed">—</div>
|
||
<div class="kpi-label">Закрыто</div>
|
||
</div>
|
||
<div class="kpi-icon">✓</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="kpi-card progress">
|
||
<div class="kpi-accent"></div>
|
||
<div class="kpi-body">
|
||
<div class="kpi-row">
|
||
<div class="kpi-left">
|
||
<div class="kpi-num" id="kpi-progress">—</div>
|
||
<div class="kpi-label">В работе</div>
|
||
</div>
|
||
<div class="kpi-icon">⟳</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="kpi-card overdue">
|
||
<div class="kpi-accent"></div>
|
||
<div class="kpi-body">
|
||
<div class="kpi-row">
|
||
<div class="kpi-left">
|
||
<div class="kpi-num" id="kpi-overdue">—</div>
|
||
<div class="kpi-label">Просрочено</div>
|
||
<div class="kpi-sub" id="kpi-overdue-sub"></div>
|
||
</div>
|
||
<div class="kpi-icon">!</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filters-bar">
|
||
<span class="lbl">Фильтры</span>
|
||
<div class="search-wrap">
|
||
<input type="search" id="search-input" placeholder="Поиск по №, услуге, инициатору…" autocomplete="off" />
|
||
</div>
|
||
<div class="filter-group" data-filter="status">
|
||
<button type="button" class="filter-select" id="filter-status-btn">Все статусы</button>
|
||
<div class="filter-options" id="filter-status-opts"></div>
|
||
<div class="selected-filters-row" id="filter-status-tags"></div>
|
||
</div>
|
||
<div class="filter-group" data-filter="team">
|
||
<button type="button" class="filter-select" id="filter-team-btn">Все команды</button>
|
||
<div class="filter-options" id="filter-team-opts"></div>
|
||
<div class="selected-filters-row" id="filter-team-tags"></div>
|
||
</div>
|
||
<div class="filter-group" data-filter="employee">
|
||
<button type="button" class="filter-select" id="filter-emp-btn">Все сотрудники</button>
|
||
<div class="filter-options" id="filter-emp-opts"></div>
|
||
<div class="selected-filters-row" id="filter-emp-tags"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tabs-bar">
|
||
<button type="button" class="tab active" data-tab="charts">Диаграммы и динамика</button>
|
||
<button type="button" class="tab" data-tab="table">Таблица инцидентов</button>
|
||
<button type="button" class="tab" data-tab="employees">По сотрудникам</button>
|
||
</div>
|
||
|
||
<div id="charts-tab" class="tab-content active">
|
||
<div class="charts-row">
|
||
<div class="chart-panel">
|
||
<div class="chart-panel-head">
|
||
<span class="chart-panel-indicator"></span>
|
||
<span class="chart-panel-title">Статусы открытых</span>
|
||
</div>
|
||
<div class="chart-panel-body">
|
||
<div class="chart-canvas-wrap"><canvas id="chart-status"></canvas></div>
|
||
<div class="chart-legend-wrap" id="legend-status"></div>
|
||
</div>
|
||
</div>
|
||
<div class="chart-panel">
|
||
<div class="chart-panel-head">
|
||
<span class="chart-panel-indicator"></span>
|
||
<span class="chart-panel-title">Команды (топ-8)</span>
|
||
</div>
|
||
<div class="chart-panel-body">
|
||
<div class="chart-canvas-wrap"><canvas id="chart-team"></canvas></div>
|
||
<div class="chart-legend-wrap" id="legend-team"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="timeline-panel">
|
||
<div class="chart-panel-head">
|
||
<span class="chart-panel-indicator"></span>
|
||
<span class="chart-panel-title">Недельная динамика</span>
|
||
</div>
|
||
<div class="chart-panel-body">
|
||
<div class="line-legend">
|
||
<span><i style="background:#2c5282"></i> Всего</span>
|
||
<span><i style="background:#0a7c4e"></i> Закрыто</span>
|
||
<span><i style="background:#b45309"></i> В работе</span>
|
||
<span><i style="background:#c0392b;border-style:dashed"></i> Просрочено</span>
|
||
</div>
|
||
<div class="line-chart-wrap"><canvas id="chart-line"></canvas></div>
|
||
<div class="wt-head">Детализация по неделям</div>
|
||
<table class="weekly-table" id="weekly-stats-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Неделя</th>
|
||
<th>Дата отчёта</th>
|
||
<th>Всего</th>
|
||
<th>Закрыто</th>
|
||
<th>В работе</th>
|
||
<th>Просрочено</th>
|
||
<th>% Закрыто</th>
|
||
<th>% Просрочено</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="weekly-stats-body"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="table-tab" class="tab-content">
|
||
<div class="table-panel">
|
||
<div class="table-scroll">
|
||
<table class="inc-table" id="inc-table">
|
||
<thead>
|
||
<tr>
|
||
<th data-sort="number">№ <span class="sort-indicator"></span></th>
|
||
<th data-sort="status">Статус <span class="sort-indicator"></span></th>
|
||
<th data-sort="team">Команда <span class="sort-indicator"></span></th>
|
||
<th data-sort="employee">Исполнитель <span class="sort-indicator"></span></th>
|
||
<th data-sort="created">Дата создания <span class="sort-indicator"></span></th>
|
||
<th data-sort="deadline">Регл. срок <span class="sort-indicator"></span></th>
|
||
<th data-sort="solved">Дата решения <span class="sort-indicator"></span></th>
|
||
<th data-sort="closed">Кем решён <span class="sort-indicator"></span></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="inc-tbody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="employees-tab" class="tab-content">
|
||
<div class="emp-panel" id="emp-panel"></div>
|
||
</div>
|
||
</div>
|
||
</main>
|
||
</div>
|
||
|
||
<script>
|
||
(function () {
|
||
const COLORS = [
|
||
"#1a73c8", "#0a7c4e", "#c0392b", "#b45309", "#7c3aed",
|
||
"#0369a1", "#9d174d", "#0d6b56", "#5b21b6", "#92400e",
|
||
"#c2410c", "#1d4ed8", "#065f46"
|
||
];
|
||
|
||
const CLOSED = new Set(["resolved", "closed", "negative"]);
|
||
|
||
const FIELDS = {
|
||
number: "Название",
|
||
status: "Статус",
|
||
team: "Ответственный (команда)",
|
||
employee: "Ответственный (сотрудник)",
|
||
initiator: "Инициатор заявки",
|
||
service: "Услуга",
|
||
created: "Дата создания",
|
||
deadline: "Регламентное время решения запроса",
|
||
solved: "Дата решения",
|
||
closedBy: "Кем решен (сотрудник)",
|
||
uuid: "Уникальный идентификатор"
|
||
};
|
||
|
||
const SKEYS = {
|
||
number: FIELDS.number,
|
||
status: FIELDS.status,
|
||
team: FIELDS.team,
|
||
employee: FIELDS.employee,
|
||
created: FIELDS.created,
|
||
deadline: FIELDS.deadline,
|
||
solved: FIELDS.solved,
|
||
closed: FIELDS.closedBy
|
||
};
|
||
|
||
const STATUS_LABELS = {
|
||
registered: "Зарегистрирован",
|
||
resolved: "Решён",
|
||
resumed: "Возобновлён",
|
||
waiting: "Ожидание",
|
||
closed: "Закрыт",
|
||
chotvcom: "ЧО ТВ ком",
|
||
inprogress: "В работе",
|
||
negotiation: "Согласование",
|
||
negative: "Отрицательно",
|
||
contractor: "Подрядчик",
|
||
problem: "Проблема",
|
||
analiz: "Анализ",
|
||
chotvcom1: "ЧО ТВ ком (2)"
|
||
};
|
||
|
||
const WEEK_STATS = [
|
||
{ week: "2026-03-31", total: 142, closed: 48, work: 71, overdue: 23 },
|
||
{ week: "2026-03-24", total: 138, closed: 52, work: 68, overdue: 18 },
|
||
{ week: "2026-03-17", total: 131, closed: 44, work: 65, overdue: 22 },
|
||
{ week: "2026-03-10", total: 128, closed: 41, work: 62, overdue: 25 },
|
||
{ week: "2026-03-03", total: 124, closed: 39, work: 60, overdue: 25 },
|
||
{ week: "2026-02-24", total: 119, closed: 37, work: 58, overdue: 24 },
|
||
{ week: "2026-02-17", total: 115, closed: 35, work: 56, overdue: 24 },
|
||
{ week: "2026-02-10", total: 112, closed: 33, work: 55, overdue: 24 },
|
||
{ week: "2026-02-03", total: 108, closed: 31, work: 53, overdue: 24 },
|
||
{ week: "2026-01-27", total: 104, closed: 30, work: 51, overdue: 23 },
|
||
{ week: "2026-01-20", total: 99, closed: 28, work: 49, overdue: 22 },
|
||
{ week: "2026-01-13", total: 95, closed: 27, work: 47, overdue: 21 },
|
||
{ week: "2026-01-06", total: 91, closed: 25, work: 46, overdue: 20 },
|
||
{ week: "2025-12-30", total: 88, closed: 24, work: 45, overdue: 19 }
|
||
];
|
||
|
||
const RU_MONTHS = ["Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"];
|
||
|
||
const TT_STYLE = {
|
||
backgroundColor: "#1c2b3a",
|
||
borderColor: "#4a5f75",
|
||
borderWidth: 1,
|
||
titleFont: { family: "IBM Plex Mono", size: 11 },
|
||
bodyFont: { family: "IBM Plex Mono", size: 11 },
|
||
padding: 10,
|
||
cornerRadius: 4
|
||
};
|
||
|
||
let incidentsData = [];
|
||
let filteredData = [];
|
||
let selectedStatuses = [];
|
||
let selectedTeams = [];
|
||
let selectedEmployees = [];
|
||
let sortState = { key: "deadline", asc: true };
|
||
let controlDate = startOfDay(new Date());
|
||
let chartStatusInst = null;
|
||
let chartTeamInst = null;
|
||
let chartLineInst = null;
|
||
let filtersBound = false;
|
||
let sortBound = false;
|
||
let tabsBound = false;
|
||
let activeTab = "charts";
|
||
|
||
const el = (id) => document.getElementById(id);
|
||
|
||
function startOfDay(d) {
|
||
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
|
||
}
|
||
|
||
function pad(n) { return n < 10 ? "0" + n : String(n); }
|
||
|
||
function formatSystemDate() {
|
||
const d = new Date();
|
||
const dd = pad(d.getDate());
|
||
const mm = pad(d.getMonth() + 1);
|
||
const yy = d.getFullYear();
|
||
const hh = pad(d.getHours());
|
||
const mi = pad(d.getMinutes());
|
||
return dd + "." + mm + "." + yy + " " + hh + ":" + mi;
|
||
}
|
||
|
||
function parseDateDMY(str) {
|
||
if (!str || typeof str !== "string") return null;
|
||
const t = str.trim();
|
||
const m = t.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})(?:\s+(\d{1,2}):(\d{1,2}))?/);
|
||
if (!m) return null;
|
||
const day = parseInt(m[1], 10);
|
||
const month = parseInt(m[2], 10) - 1;
|
||
const year = parseInt(m[3], 10);
|
||
const hh = m[4] != null ? parseInt(m[4], 10) : 0;
|
||
const mm = m[5] != null ? parseInt(m[5], 10) : 0;
|
||
return new Date(year, month, day, hh, mm, 0, 0);
|
||
}
|
||
|
||
function normalizeRow(raw) {
|
||
const row = {};
|
||
Object.keys(FIELDS).forEach((k) => {
|
||
const col = FIELDS[k];
|
||
let v = raw[col];
|
||
if (v === undefined || v === null) v = "";
|
||
if (typeof v === "string") v = v.trim();
|
||
row[k] = v;
|
||
});
|
||
const n = parseInt(String(row.number).replace(/\s/g, ""), 10);
|
||
row.number = isNaN(n) ? 0 : n;
|
||
row.status = String(row.status || "").toLowerCase();
|
||
return row;
|
||
}
|
||
|
||
function isClosed(row) {
|
||
const bySolver = String(row.closedBy || "").trim() !== "";
|
||
return CLOSED.has(row.status) || bySolver;
|
||
}
|
||
|
||
function isOverdue(row) {
|
||
if (isClosed(row)) return false;
|
||
const dl = parseDateDMY(String(row.deadline || ""));
|
||
if (!dl || isNaN(dl.getTime())) return false;
|
||
return dl.getTime() < controlDate.getTime();
|
||
}
|
||
|
||
function statusBadgeClass(code) {
|
||
const c = String(code || "").toLowerCase();
|
||
const known = [
|
||
"registered", "resolved", "resumed", "waiting", "closed", "chotvcom",
|
||
"inprogress", "negotiation", "negative", "contractor", "problem", "analiz", "chotvcom1"
|
||
];
|
||
return known.indexOf(c) >= 0 ? c : "unknown";
|
||
}
|
||
|
||
function statusLabel(code) {
|
||
const c = String(code || "").toLowerCase();
|
||
return STATUS_LABELS[c] || code || "—";
|
||
}
|
||
|
||
function isoWeekNumber(d) {
|
||
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));
|
||
const dayNum = date.getUTCDay() || 7;
|
||
date.setUTCDate(date.getUTCDate() + 4 - dayNum);
|
||
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
|
||
return Math.ceil((((date - yearStart) / 86400000) + 1) / 7);
|
||
}
|
||
|
||
function weekAxisLabel(isoWeekStr) {
|
||
const d = new Date(isoWeekStr + "T12:00:00");
|
||
const w = isoWeekNumber(d);
|
||
const m = RU_MONTHS[d.getMonth()];
|
||
return "Н" + w + " " + m;
|
||
}
|
||
|
||
const centerTextPlugin = {
|
||
id: "centerText",
|
||
beforeDraw(chart) {
|
||
if (chart.config.type !== "doughnut") return;
|
||
if (chart.data.labels && chart.data.labels[0] === "—") return;
|
||
const ds = chart.data.datasets[0];
|
||
if (!ds || !ds.data) return;
|
||
const sum = ds.data.reduce(function (a, b) { return a + (Number(b) || 0); }, 0);
|
||
const meta = chart.getDatasetMeta(0);
|
||
if (!meta || !meta.data || !meta.data[0]) return;
|
||
const pt = meta.data[0];
|
||
const ctx = chart.ctx;
|
||
const { x, y } = pt.tooltipPosition();
|
||
ctx.save();
|
||
ctx.font = "700 22px 'Barlow Condensed', sans-serif";
|
||
ctx.fillStyle = "#1c2b3a";
|
||
ctx.textAlign = "center";
|
||
ctx.textBaseline = "middle";
|
||
ctx.fillText(String(sum), x, y);
|
||
ctx.restore();
|
||
}
|
||
};
|
||
|
||
if (typeof Chart !== "undefined") {
|
||
Chart.register(centerTextPlugin);
|
||
}
|
||
|
||
function destroyChart(inst) {
|
||
if (inst) {
|
||
inst.destroy();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function buildLegend(container, labels, values, colors) {
|
||
container.innerHTML = "";
|
||
for (let i = 0; i < labels.length; i++) {
|
||
const item = document.createElement("div");
|
||
item.className = "legend-item";
|
||
item.innerHTML =
|
||
'<span class="legend-dot" style="background:' + colors[i % colors.length] + '"></span>' +
|
||
'<span class="legend-lbl" title="' + escapeHtml(labels[i]) + '">' + escapeHtml(labels[i]) + "</span>" +
|
||
'<span class="legend-val">' + escapeHtml(String(values[i])) + "</span>";
|
||
container.appendChild(item);
|
||
}
|
||
}
|
||
|
||
function escapeHtml(s) {
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function getOpenRows(data) {
|
||
return data.filter(function (r) { return !isClosed(r); });
|
||
}
|
||
|
||
function renderCharts() {
|
||
const openRows = getOpenRows(filteredData);
|
||
|
||
/* Doughnut 1 — статусы открытых */
|
||
const stMap = {};
|
||
openRows.forEach(function (r) {
|
||
const k = r.status || "unknown";
|
||
stMap[k] = (stMap[k] || 0) + 1;
|
||
});
|
||
const stKeys = Object.keys(stMap).sort(function (a, b) { return stMap[b] - stMap[a]; });
|
||
const stVals = stKeys.map(function (k) { return stMap[k]; });
|
||
const stLabels = stKeys.map(function (k) { return statusLabel(k); });
|
||
const stColors = stKeys.map(function (_, i) { return COLORS[i % COLORS.length]; });
|
||
|
||
const ctxS = el("chart-status");
|
||
chartStatusInst = destroyChart(chartStatusInst);
|
||
buildLegend(el("legend-status"), stLabels, stVals, stColors);
|
||
if (stVals.length === 0) {
|
||
chartStatusInst = new Chart(ctxS, {
|
||
type: "doughnut",
|
||
data: { labels: ["—"], datasets: [{ data: [1], backgroundColor: ["#e0e5ed"], borderColor: "#fff", borderWidth: 2 }] },
|
||
options: {
|
||
cutout: "68%",
|
||
plugins: { legend: { display: false }, tooltip: { enabled: false } }
|
||
}
|
||
});
|
||
} else {
|
||
chartStatusInst = new Chart(ctxS, {
|
||
type: "doughnut",
|
||
data: {
|
||
labels: stLabels,
|
||
datasets: [{
|
||
data: stVals,
|
||
backgroundColor: stColors,
|
||
borderColor: "#ffffff",
|
||
borderWidth: 2,
|
||
hoverBorderWidth: 3
|
||
}]
|
||
},
|
||
options: {
|
||
cutout: "68%",
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
backgroundColor: TT_STYLE.backgroundColor,
|
||
borderColor: TT_STYLE.borderColor,
|
||
borderWidth: 1,
|
||
titleFont: TT_STYLE.titleFont,
|
||
bodyFont: TT_STYLE.bodyFont,
|
||
padding: TT_STYLE.padding,
|
||
cornerRadius: TT_STYLE.cornerRadius
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/* Doughnut 2 — команды топ-8 + Другие */
|
||
const tm = {};
|
||
openRows.forEach(function (r) {
|
||
const k = String(r.team || "").trim() || "—";
|
||
tm[k] = (tm[k] || 0) + 1;
|
||
});
|
||
const teamKeys = Object.keys(tm).sort(function (a, b) { return tm[b] - tm[a]; });
|
||
let top = teamKeys.slice(0, 8);
|
||
let otherSum = 0;
|
||
if (teamKeys.length > 8) {
|
||
teamKeys.slice(8).forEach(function (k) { otherSum += tm[k]; });
|
||
}
|
||
const tmLabels = top.map(function (k) { return k; });
|
||
const tmVals = top.map(function (k) { return tm[k]; });
|
||
if (otherSum > 0) {
|
||
tmLabels.push("Другие");
|
||
tmVals.push(otherSum);
|
||
}
|
||
const tmColors = tmLabels.map(function (_, i) { return COLORS[i % COLORS.length]; });
|
||
|
||
const ctxT = el("chart-team");
|
||
chartTeamInst = destroyChart(chartTeamInst);
|
||
buildLegend(el("legend-team"), tmLabels, tmVals, tmColors);
|
||
if (tmVals.length === 0) {
|
||
chartTeamInst = new Chart(ctxT, {
|
||
type: "doughnut",
|
||
data: { labels: ["—"], datasets: [{ data: [1], backgroundColor: ["#e0e5ed"], borderColor: "#fff", borderWidth: 2 }] },
|
||
options: {
|
||
cutout: "68%",
|
||
plugins: { legend: { display: false }, tooltip: { enabled: false } }
|
||
}
|
||
});
|
||
} else {
|
||
chartTeamInst = new Chart(ctxT, {
|
||
type: "doughnut",
|
||
data: {
|
||
labels: tmLabels,
|
||
datasets: [{
|
||
data: tmVals,
|
||
backgroundColor: tmColors,
|
||
borderColor: "#ffffff",
|
||
borderWidth: 2,
|
||
hoverBorderWidth: 3
|
||
}]
|
||
},
|
||
options: {
|
||
cutout: "68%",
|
||
plugins: {
|
||
legend: { display: false },
|
||
tooltip: {
|
||
backgroundColor: TT_STYLE.backgroundColor,
|
||
borderColor: TT_STYLE.borderColor,
|
||
borderWidth: 1,
|
||
titleFont: TT_STYLE.titleFont,
|
||
bodyFont: TT_STYLE.bodyFont,
|
||
padding: TT_STYLE.padding,
|
||
cornerRadius: TT_STYLE.cornerRadius
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/* Line chart — WEEK_STATS */
|
||
const labels = WEEK_STATS.map(function (w) { return weekAxisLabel(w.week); });
|
||
const ctxL = el("chart-line");
|
||
chartLineInst = destroyChart(chartLineInst);
|
||
chartLineInst = new Chart(ctxL, {
|
||
type: "line",
|
||
data: {
|
||
labels: labels,
|
||
datasets: [
|
||
{
|
||
label: "Всего",
|
||
data: WEEK_STATS.map(function (w) { return w.total; }),
|
||
borderColor: "#2c5282",
|
||
backgroundColor: "rgba(44, 82, 130, 0.08)",
|
||
fill: true,
|
||
tension: 0.35,
|
||
pointBorderColor: "#fff",
|
||
pointBorderWidth: 2
|
||
},
|
||
{
|
||
label: "Закрыто",
|
||
data: WEEK_STATS.map(function (w) { return w.closed; }),
|
||
borderColor: "#0a7c4e",
|
||
backgroundColor: "rgba(10, 124, 78, 0.06)",
|
||
fill: true,
|
||
tension: 0.35,
|
||
pointBorderColor: "#fff",
|
||
pointBorderWidth: 2
|
||
},
|
||
{
|
||
label: "В работе",
|
||
data: WEEK_STATS.map(function (w) { return w.work; }),
|
||
borderColor: "#b45309",
|
||
backgroundColor: "rgba(180, 83, 9, 0.06)",
|
||
fill: true,
|
||
tension: 0.35,
|
||
pointBorderColor: "#fff",
|
||
pointBorderWidth: 2
|
||
},
|
||
{
|
||
label: "Просрочено",
|
||
data: WEEK_STATS.map(function (w) { return w.overdue; }),
|
||
borderColor: "#c0392b",
|
||
backgroundColor: "rgba(192, 57, 43, 0.06)",
|
||
fill: true,
|
||
tension: 0.35,
|
||
borderDash: [4, 3],
|
||
pointBorderColor: "#fff",
|
||
pointBorderWidth: 2
|
||
}
|
||
]
|
||
},
|
||
options: {
|
||
responsive: true,
|
||
maintainAspectRatio: false,
|
||
interaction: { mode: "index", intersect: false },
|
||
plugins: {
|
||
legend: {
|
||
display: false
|
||
},
|
||
tooltip: {
|
||
backgroundColor: TT_STYLE.backgroundColor,
|
||
borderColor: TT_STYLE.borderColor,
|
||
borderWidth: 1,
|
||
titleFont: TT_STYLE.titleFont,
|
||
bodyFont: TT_STYLE.bodyFont,
|
||
padding: TT_STYLE.padding,
|
||
cornerRadius: TT_STYLE.cornerRadius
|
||
}
|
||
},
|
||
scales: {
|
||
x: {
|
||
grid: { color: "rgba(176, 187, 201, 0.3)" },
|
||
ticks: {
|
||
font: { family: "IBM Plex Sans", size: 11 },
|
||
color: "#7a8fa3",
|
||
maxRotation: 45,
|
||
minRotation: 0
|
||
}
|
||
},
|
||
y: {
|
||
grid: { color: "rgba(176, 187, 201, 0.3)" },
|
||
ticks: {
|
||
font: { family: "IBM Plex Sans", size: 11 },
|
||
color: "#7a8fa3"
|
||
}
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
renderWeeklyTable();
|
||
}
|
||
|
||
function renderWeeklyTable() {
|
||
const tbody = el("weekly-stats-body");
|
||
tbody.innerHTML = "";
|
||
const rev = WEEK_STATS.slice().reverse();
|
||
rev.forEach(function (w) {
|
||
const pctClosed = w.total ? Math.round((w.closed / w.total) * 1000) / 10 : 0;
|
||
const pctOver = w.total ? Math.round((w.overdue / w.total) * 1000) / 10 : 0;
|
||
const tr = document.createElement("tr");
|
||
tr.innerHTML =
|
||
"<td>" + escapeHtml(weekAxisLabel(w.week)) + "</td>" +
|
||
"<td>" + escapeHtml(w.week) + "</td>" +
|
||
'<td class="num">' + w.total + "</td>" +
|
||
'<td class="num">' + w.closed + "</td>" +
|
||
'<td class="num">' + w.work + "</td>" +
|
||
'<td class="num">' + w.overdue + "</td>" +
|
||
"<td>" + pctClosed + "%</td>" +
|
||
"<td>" + pctOver + "%</td>";
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
function cmpRows(a, b, key) {
|
||
var va, vb;
|
||
switch (key) {
|
||
case "number":
|
||
return a.number - b.number;
|
||
case "status":
|
||
va = a.status;
|
||
vb = b.status;
|
||
break;
|
||
case "team":
|
||
va = a.team;
|
||
vb = b.team;
|
||
break;
|
||
case "employee":
|
||
va = a.employee;
|
||
vb = b.employee;
|
||
break;
|
||
case "created":
|
||
va = a.created;
|
||
vb = b.created;
|
||
break;
|
||
case "deadline":
|
||
va = a.deadline;
|
||
vb = b.deadline;
|
||
break;
|
||
case "solved":
|
||
va = a.solved;
|
||
vb = b.solved;
|
||
break;
|
||
case "closed":
|
||
va = a.closedBy;
|
||
vb = b.closedBy;
|
||
break;
|
||
default:
|
||
return 0;
|
||
}
|
||
return String(va == null ? "" : va).localeCompare(String(vb == null ? "" : vb), "ru");
|
||
}
|
||
|
||
function sortData() {
|
||
const key = sortState.key;
|
||
const mul = sortState.asc ? 1 : -1;
|
||
filteredData.sort(function (a, b) {
|
||
return cmpRows(a, b, key) * mul;
|
||
});
|
||
}
|
||
|
||
function renderTable() {
|
||
const tbody = el("inc-tbody");
|
||
tbody.innerHTML = "";
|
||
const rows = filteredData.filter(function (r) { return !isClosed(r); });
|
||
rows.forEach(function (r) {
|
||
const od = isOverdue(r);
|
||
const tr = document.createElement("tr");
|
||
const uuid = String(r.uuid || "").trim();
|
||
const numCell = uuid
|
||
? '<a class="number-link" href="https://servicedesk.omc.ru/sd/operator/#uuid:' + encodeURIComponent(uuid) + '" target="_blank" rel="noopener">' + escapeHtml(String(r.number)) + "</a>"
|
||
: escapeHtml(String(r.number));
|
||
const stClass = statusBadgeClass(r.status);
|
||
tr.innerHTML =
|
||
"<td class=\"mono\">" + numCell + "</td>" +
|
||
'<td><span class="status-badge ' + stClass + '">' + escapeHtml(statusLabel(r.status)) + "</span></td>" +
|
||
"<td>" + escapeHtml(String(r.team || "—")) + "</td>" +
|
||
"<td>" + escapeHtml(String(r.employee || "—")) + "</td>" +
|
||
'<td class="mono">' + escapeHtml(String(r.created || "—")) + "</td>" +
|
||
'<td class="mono' + (od ? " overdue" : "") + '">' + escapeHtml(String(r.deadline || "—")) + "</td>" +
|
||
'<td class="mono">' + escapeHtml(String(r.solved || "—")) + "</td>" +
|
||
"<td>" + escapeHtml(String(r.closedBy || "—")) + "</td>";
|
||
tbody.appendChild(tr);
|
||
});
|
||
updateSortIndicators();
|
||
}
|
||
|
||
function updateSortIndicators() {
|
||
document.querySelectorAll(".inc-table th[data-sort]").forEach(function (th) {
|
||
const k = th.getAttribute("data-sort");
|
||
const ind = th.querySelector(".sort-indicator");
|
||
if (!ind) return;
|
||
if (sortState.key === k) {
|
||
ind.textContent = sortState.asc ? "▲" : "▼";
|
||
} else {
|
||
ind.textContent = "";
|
||
}
|
||
});
|
||
}
|
||
|
||
function countForEmployee(rows, emp) {
|
||
let total = 0, closed = 0, prog = 0, ovd = 0;
|
||
rows.forEach(function (r) {
|
||
if (String(r.employee || "").trim() !== emp) return;
|
||
total++;
|
||
if (isClosed(r)) closed++;
|
||
else {
|
||
prog++;
|
||
if (isOverdue(r)) ovd++;
|
||
}
|
||
});
|
||
return { total: total, closed: closed, prog: prog, overdue: ovd };
|
||
}
|
||
|
||
function renderEmployees() {
|
||
const panel = el("emp-panel");
|
||
const byEmp = {};
|
||
filteredData.forEach(function (r) {
|
||
const e = String(r.employee || "").trim() || "—";
|
||
if (!byEmp[e]) byEmp[e] = [];
|
||
byEmp[e].push(r);
|
||
});
|
||
const names = Object.keys(byEmp).sort(function (a, b) {
|
||
return byEmp[b].length - byEmp[a].length;
|
||
});
|
||
panel.innerHTML = "";
|
||
names.forEach(function (name) {
|
||
const rows = byEmp[name];
|
||
const st = countForEmployee(filteredData, name);
|
||
const block = document.createElement("div");
|
||
block.className = "emp-block";
|
||
const hdr = document.createElement("div");
|
||
hdr.className = "emp-header";
|
||
hdr.innerHTML =
|
||
'<span class="emp-chevron">▶</span>' +
|
||
'<span class="emp-name">' + escapeHtml(name) + "</span>" +
|
||
'<div class="emp-stats">' +
|
||
'<div class="emp-stat"><div class="emp-stat-val s-total">' + st.total + '</div><div class="emp-stat-lbl">Всего</div></div>' +
|
||
'<div class="emp-stat"><div class="emp-stat-val s-closed">' + st.closed + '</div><div class="emp-stat-lbl">Закрыто</div></div>' +
|
||
'<div class="emp-stat"><div class="emp-stat-val s-prog">' + st.prog + '</div><div class="emp-stat-lbl">В работе</div></div>' +
|
||
'<div class="emp-stat"><div class="emp-stat-val s-overdue">' + st.overdue + '</div><div class="emp-stat-lbl">Просрочено</div></div>' +
|
||
"</div>";
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "emp-table-wrap";
|
||
let tableHtml =
|
||
'<table class="emp-table"><thead><tr>' +
|
||
"<th>№</th><th>Статус</th><th>Команда</th><th>Дата создания</th><th>Регл. срок</th><th>Дата решения</th>" +
|
||
"</tr></thead><tbody>";
|
||
rows.forEach(function (r) {
|
||
const od = isOverdue(r);
|
||
tableHtml +=
|
||
"<tr>" +
|
||
'<td class="mono">' + escapeHtml(String(r.number)) + "</td>" +
|
||
'<td><span class="status-badge ' + statusBadgeClass(r.status) + '">' + escapeHtml(statusLabel(r.status)) + "</span></td>" +
|
||
"<td>" + escapeHtml(String(r.team || "—")) + "</td>" +
|
||
'<td class="mono">' + escapeHtml(String(r.created || "—")) + "</td>" +
|
||
'<td class="mono' + (od ? " overdue" : "") + '">' + escapeHtml(String(r.deadline || "—")) + "</td>" +
|
||
'<td class="mono">' + escapeHtml(String(r.solved || "—")) + "</td>" +
|
||
"</tr>";
|
||
});
|
||
tableHtml += "</tbody></table>";
|
||
wrap.innerHTML = tableHtml;
|
||
block.appendChild(hdr);
|
||
block.appendChild(wrap);
|
||
panel.appendChild(block);
|
||
});
|
||
|
||
panel.querySelectorAll(".emp-header").forEach(function (h) {
|
||
h.addEventListener("click", function () {
|
||
const was = h.classList.contains("expanded");
|
||
panel.querySelectorAll(".emp-header").forEach(function (x) { x.classList.remove("expanded"); });
|
||
if (!was) h.classList.add("expanded");
|
||
});
|
||
});
|
||
}
|
||
|
||
function updateCards() {
|
||
const n = filteredData.length;
|
||
let c = 0, p = 0, o = 0;
|
||
filteredData.forEach(function (r) {
|
||
if (isClosed(r)) c++;
|
||
else {
|
||
p++;
|
||
if (isOverdue(r)) o++;
|
||
}
|
||
});
|
||
el("kpi-total").textContent = n;
|
||
el("kpi-closed").textContent = c;
|
||
el("kpi-progress").textContent = p;
|
||
el("kpi-overdue").textContent = o;
|
||
el("kpi-overdue-sub").textContent = p > 0 ? "от открытых: " + Math.round((o / p) * 1000) / 10 + "%" : "";
|
||
}
|
||
|
||
function applyFilters() {
|
||
const q = (el("search-input").value || "").trim().toLowerCase();
|
||
filteredData = incidentsData.filter(function (r) {
|
||
if (q) {
|
||
const num = String(r.number);
|
||
const srv = String(r.service || "").toLowerCase();
|
||
const ini = String(r.initiator || "").toLowerCase();
|
||
if (!num.includes(q) && !srv.includes(q) && !ini.includes(q)) return false;
|
||
}
|
||
if (selectedStatuses.length) {
|
||
if (selectedStatuses.indexOf(r.status) < 0) return false;
|
||
}
|
||
if (selectedTeams.length) {
|
||
const t = String(r.team || "").trim() || "—";
|
||
if (selectedTeams.indexOf(t) < 0) return false;
|
||
}
|
||
if (selectedEmployees.length) {
|
||
const e = String(r.employee || "").trim() || "—";
|
||
if (selectedEmployees.indexOf(e) < 0) return false;
|
||
}
|
||
return true;
|
||
});
|
||
sortData();
|
||
el("statusbar-count").textContent = String(incidentsData.length);
|
||
updateCards();
|
||
renderCharts();
|
||
renderTable();
|
||
if (activeTab === "employees") renderEmployees();
|
||
}
|
||
|
||
function setupFilters() {
|
||
const statuses = {};
|
||
const teams = {};
|
||
const emps = {};
|
||
incidentsData.forEach(function (r) {
|
||
statuses[r.status || "unknown"] = true;
|
||
const t = String(r.team || "").trim() || "—";
|
||
teams[t] = true;
|
||
const e = String(r.employee || "").trim() || "—";
|
||
emps[e] = true;
|
||
});
|
||
|
||
function fillOpts(containerId, keys, type) {
|
||
const box = el(containerId);
|
||
box.innerHTML = "";
|
||
keys.sort(function (a, b) { return String(a).localeCompare(String(b), "ru"); });
|
||
keys.forEach(function (k) {
|
||
const id = "cb-" + type + "-" + hashStr(k);
|
||
const lab = document.createElement("label");
|
||
const inp = document.createElement("input");
|
||
inp.type = "checkbox";
|
||
inp.id = id;
|
||
inp.setAttribute("data-val", k);
|
||
lab.appendChild(inp);
|
||
lab.appendChild(document.createTextNode(" " + (type === "status" ? statusLabel(k) : k)));
|
||
box.appendChild(lab);
|
||
});
|
||
}
|
||
|
||
fillOpts("filter-status-opts", Object.keys(statuses), "status");
|
||
fillOpts("filter-team-opts", Object.keys(teams), "team");
|
||
fillOpts("filter-emp-opts", Object.keys(emps), "employee");
|
||
|
||
function updateTags() {
|
||
renderTagRow("filter-status-tags", selectedStatuses, "status");
|
||
renderTagRow("filter-team-tags", selectedTeams, "team");
|
||
renderTagRow("filter-emp-tags", selectedEmployees, "employee");
|
||
el("filter-status-btn").textContent = selectedStatuses.length ? "Выбрано: " + selectedStatuses.length : "Все статусы";
|
||
el("filter-team-btn").textContent = selectedTeams.length ? "Выбрано: " + selectedTeams.length : "Все команды";
|
||
el("filter-emp-btn").textContent = selectedEmployees.length ? "Выбрано: " + selectedEmployees.length : "Все сотрудники";
|
||
}
|
||
|
||
function renderTagRow(containerId, arr, type) {
|
||
const row = el(containerId);
|
||
row.innerHTML = "";
|
||
arr.forEach(function (v) {
|
||
const pill = document.createElement("span");
|
||
pill.className = "selected-filter";
|
||
pill.appendChild(document.createTextNode((type === "status" ? statusLabel(v) : v) + " "));
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.setAttribute("data-remove", type);
|
||
btn.setAttribute("data-val", v);
|
||
btn.textContent = "×";
|
||
pill.appendChild(btn);
|
||
row.appendChild(pill);
|
||
});
|
||
}
|
||
|
||
function syncCheckboxes() {
|
||
document.querySelectorAll("#filter-status-opts input").forEach(function (inp) {
|
||
inp.checked = selectedStatuses.indexOf(inp.getAttribute("data-val")) >= 0;
|
||
});
|
||
document.querySelectorAll("#filter-team-opts input").forEach(function (inp) {
|
||
inp.checked = selectedTeams.indexOf(inp.getAttribute("data-val")) >= 0;
|
||
});
|
||
document.querySelectorAll("#filter-emp-opts input").forEach(function (inp) {
|
||
inp.checked = selectedEmployees.indexOf(inp.getAttribute("data-val")) >= 0;
|
||
});
|
||
}
|
||
|
||
function hashStr(s) {
|
||
let h = 0;
|
||
const str = String(s);
|
||
for (let i = 0; i < str.length; i++) h = ((h << 5) - h) + str.charCodeAt(i) | 0;
|
||
return String(h);
|
||
}
|
||
|
||
if (!filtersBound) {
|
||
filtersBound = true;
|
||
[["filter-status-btn", "filter-status-opts", "status"], ["filter-team-btn", "filter-team-opts", "team"], ["filter-emp-btn", "filter-emp-opts", "employee"]].forEach(function (cfg) {
|
||
const btn = el(cfg[0]);
|
||
const opts = el(cfg[1]);
|
||
const typ = cfg[2];
|
||
btn.addEventListener("click", function (e) {
|
||
e.stopPropagation();
|
||
document.querySelectorAll(".filter-options").forEach(function (o) { if (o !== opts) o.classList.remove("show"); });
|
||
opts.classList.toggle("show");
|
||
});
|
||
opts.addEventListener("click", function (e) {
|
||
e.stopPropagation();
|
||
});
|
||
opts.addEventListener("change", function () {
|
||
const set = typ === "status" ? selectedStatuses : typ === "team" ? selectedTeams : selectedEmployees;
|
||
set.length = 0;
|
||
opts.querySelectorAll('input[type="checkbox"]:checked').forEach(function (inp) {
|
||
set.push(inp.getAttribute("data-val"));
|
||
});
|
||
updateTags();
|
||
applyFilters();
|
||
});
|
||
});
|
||
document.addEventListener("click", function () {
|
||
document.querySelectorAll(".filter-options").forEach(function (o) { o.classList.remove("show"); });
|
||
});
|
||
el("search-input").addEventListener("input", function () {
|
||
applyFilters();
|
||
});
|
||
document.querySelector(".filters-bar").addEventListener("click", function (e) {
|
||
const b = e.target.closest("button[data-remove]");
|
||
if (!b) return;
|
||
e.preventDefault();
|
||
const typ = b.getAttribute("data-remove");
|
||
const val = b.getAttribute("data-val");
|
||
if (typ === "status") selectedStatuses = selectedStatuses.filter(function (x) { return x !== val; });
|
||
if (typ === "team") selectedTeams = selectedTeams.filter(function (x) { return x !== val; });
|
||
if (typ === "employee") selectedEmployees = selectedEmployees.filter(function (x) { return x !== val; });
|
||
syncCheckboxes();
|
||
updateTags();
|
||
el("filter-status-btn").textContent = selectedStatuses.length ? "Выбрано: " + selectedStatuses.length : "Все статусы";
|
||
el("filter-team-btn").textContent = selectedTeams.length ? "Выбрано: " + selectedTeams.length : "Все команды";
|
||
el("filter-emp-btn").textContent = selectedEmployees.length ? "Выбрано: " + selectedEmployees.length : "Все сотрудники";
|
||
applyFilters();
|
||
});
|
||
}
|
||
|
||
selectedStatuses = [];
|
||
selectedTeams = [];
|
||
selectedEmployees = [];
|
||
updateTags();
|
||
syncCheckboxes();
|
||
}
|
||
|
||
function setupSort() {
|
||
if (sortBound) return;
|
||
sortBound = true;
|
||
document.querySelectorAll(".inc-table th[data-sort]").forEach(function (th) {
|
||
th.addEventListener("click", function () {
|
||
const k = th.getAttribute("data-sort");
|
||
if (sortState.key === k) sortState.asc = !sortState.asc;
|
||
else {
|
||
sortState.key = k;
|
||
sortState.asc = true;
|
||
}
|
||
applyFilters();
|
||
});
|
||
});
|
||
}
|
||
|
||
function setupTabs() {
|
||
if (tabsBound) return;
|
||
tabsBound = true;
|
||
document.querySelectorAll(".tab").forEach(function (tab) {
|
||
tab.addEventListener("click", function () {
|
||
const name = tab.getAttribute("data-tab");
|
||
activeTab = name;
|
||
document.querySelectorAll(".tab").forEach(function (t) { t.classList.toggle("active", t === tab); });
|
||
document.querySelectorAll(".tab-content").forEach(function (tc) { tc.classList.remove("active"); });
|
||
if (name === "charts") el("charts-tab").classList.add("active");
|
||
if (name === "table") el("table-tab").classList.add("active");
|
||
if (name === "employees") {
|
||
el("employees-tab").classList.add("active");
|
||
renderEmployees();
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function setControlInput() {
|
||
const inp = el("control-date");
|
||
const y = controlDate.getFullYear();
|
||
const m = pad(controlDate.getMonth() + 1);
|
||
const d = pad(controlDate.getDate());
|
||
inp.value = y + "-" + m + "-" + d;
|
||
}
|
||
|
||
function processData(rows) {
|
||
incidentsData = rows.map(normalizeRow);
|
||
filteredData = incidentsData.slice();
|
||
selectedStatuses = [];
|
||
selectedTeams = [];
|
||
selectedEmployees = [];
|
||
el("statusbar-count").textContent = String(incidentsData.length);
|
||
el("dashboard-content").classList.add("visible");
|
||
setupFilters();
|
||
sortState = { key: "deadline", asc: true };
|
||
setupSort();
|
||
setupTabs();
|
||
applyFilters();
|
||
}
|
||
|
||
function handleFile(file) {
|
||
if (!file) return;
|
||
el("upload-hint").textContent = file.name;
|
||
el("success").classList.remove("show");
|
||
el("error").classList.remove("show");
|
||
el("loading").classList.add("show");
|
||
el("loading").textContent = "Загрузка и разбор файла…";
|
||
|
||
const reader = new FileReader();
|
||
reader.onload = function (ev) {
|
||
try {
|
||
const text = String(ev.target.result || "");
|
||
const lower = file.name.toLowerCase();
|
||
let rows;
|
||
if (lower.endsWith(".json")) {
|
||
const data = JSON.parse(text);
|
||
if (!Array.isArray(data)) throw new Error("JSON должен быть массивом объектов");
|
||
rows = data;
|
||
} else {
|
||
const parsed = Papa.parse(text, { header: true, skipEmptyLines: true });
|
||
if (parsed.errors && parsed.errors.length) {
|
||
console.warn(parsed.errors);
|
||
}
|
||
rows = parsed.data || [];
|
||
}
|
||
if (!rows.length) throw new Error("Нет строк данных");
|
||
processData(rows);
|
||
el("loading").classList.remove("show");
|
||
el("success").classList.add("show");
|
||
el("success").textContent = "✓ Загружено записей: " + rows.length;
|
||
} catch (err) {
|
||
el("loading").classList.remove("show");
|
||
el("error").classList.add("show");
|
||
el("error").textContent = "✗ " + (err && err.message ? err.message : String(err));
|
||
}
|
||
};
|
||
reader.onerror = function () {
|
||
el("loading").classList.remove("show");
|
||
el("error").classList.add("show");
|
||
el("error").textContent = "✗ Не удалось прочитать файл";
|
||
};
|
||
reader.readAsText(file, "UTF-8");
|
||
}
|
||
|
||
el("btn-upload").addEventListener("click", function () {
|
||
el("file-input").click();
|
||
});
|
||
el("file-input").addEventListener("change", function () {
|
||
const f = el("file-input").files[0];
|
||
if (f) handleFile(f);
|
||
});
|
||
|
||
el("btn-today").addEventListener("click", function () {
|
||
controlDate = startOfDay(new Date());
|
||
setControlInput();
|
||
applyFilters();
|
||
});
|
||
|
||
el("control-date").addEventListener("change", function () {
|
||
const v = el("control-date").value;
|
||
if (!v) return;
|
||
const p = v.split("-");
|
||
controlDate = startOfDay(new Date(parseInt(p[0], 10), parseInt(p[1], 10) - 1, parseInt(p[2], 10)));
|
||
applyFilters();
|
||
});
|
||
|
||
function tickClock() {
|
||
el("current-date-display").textContent = formatSystemDate();
|
||
}
|
||
|
||
tickClock();
|
||
setInterval(tickClock, 30000);
|
||
|
||
controlDate = startOfDay(new Date());
|
||
setControlInput();
|
||
setupTabs();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|