Files
omc-servicedesk-monitor/index.html
2026-04-06 09:37:33 +00:00

2082 lines
68 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
<script src="https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.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 · Excel · сохранение в PostgreSQL по ключу «Название»</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 или Excel (.xlsx, .xls) с теми же колонками, что в экспорте ServiceDesk</span>
<input type="file" id="file-input" accept=".csv,.json,.xlsx,.xls,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel" 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 tryLoadFromDb() {
el("error").classList.remove("show");
el("success").classList.remove("show");
el("loading").classList.add("show");
el("loading").textContent = "Загрузка данных из базы…";
fetch("/api/incidents")
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(function (j) {
el("loading").classList.remove("show");
const rows = j && Array.isArray(j.rows) ? j.rows : [];
processData(rows);
el("success").classList.add("show");
if (rows.length) {
el("success").textContent = "✓ Загружено из базы: " + rows.length + " записей";
} else {
el("success").textContent = "✓ База пуста — загрузите выгрузку через «Источник данных»";
}
})
.catch(function () {
el("loading").classList.remove("show");
el("error").classList.add("show");
el("error").textContent = "✗ Не удалось загрузить данные из базы (API недоступен или ошибка сети).";
});
}
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();
tryLoadFromDb();
})();
</script>
</body>
</html>