1147 lines
42 KiB
HTML
1147 lines
42 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>
|
||
<link rel="stylesheet" href="css/dashboard.css" />
|
||
|
||
</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>
|
||
<nav class="topbar-nav" aria-label="Разделы">
|
||
<a href="index.html" class="nav-link active">Мониторинг</a>
|
||
<a href="data.html" class="nav-link">Источник данных</a>
|
||
</nav>
|
||
<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">Данные из PostgreSQL · загрузка файла: раздел «Источник данных»</span>
|
||
</div>
|
||
|
||
<main class="main">
|
||
<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 tryLoadFromDb() {
|
||
fetch("/api/incidents")
|
||
.then(function (r) {
|
||
if (!r.ok) return null;
|
||
return r.json();
|
||
})
|
||
.then(function (j) {
|
||
if (!j || !j.rows || !j.rows.length) return;
|
||
processData(j.rows);
|
||
el("success").classList.add("show");
|
||
el("success").textContent = "✓ Загружено из базы: " + j.rows.length + " записей";
|
||
})
|
||
.catch(function () { /* 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>
|