ot-control/index.html

1456 lines
64 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>ОТ Контроль — Учёт внутренних контролей</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
<style>
:root {
--bg: #FAFBFC;
--surface: #FFFFFF;
--border: #E3E8EF;
--ink: #1A2332;
--text: #4A5568;
--text-muted: #8899AA;
--primary: #2563EB;
--primary-light: #EFF6FF;
--success: #10B981;
--success-light: #ECFDF5;
--warning: #F59E0B;
--warning-light: #FFFBEB;
--danger: #EF4444;
--danger-light: #FEF2F2;
--info: #6366F1;
--info-light: #EEF2FF;
--sidebar: #1E293B;
--sidebar-hover: #334155;
--sidebar-active: #2563EB;
--radius: 10px;
--radius-sm: 6px;
--shadow: 0 1px 3px rgba(0,0,0,.08), 0 1px 2px rgba(0,0,0,.06);
--shadow-lg: 0 4px 12px rgba(0,0,0,.1);
--transition: 0.2s ease;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
color: var(--ink);
background: var(--bg);
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: 240px;
background: var(--sidebar);
color: #fff;
display: flex;
flex-direction: column;
position: fixed;
top: 0; left: 0;
height: 100vh;
z-index: 100;
transition: transform var(--transition);
}
.sidebar-logo {
padding: 20px 20px 16px;
font-size: 18px;
font-weight: 700;
letter-spacing: -0.3px;
border-bottom: 1px solid rgba(255,255,255,.08);
display: flex;
align-items: center;
gap: 10px;
}
.sidebar-logo .shield {
width: 32px; height: 32px;
background: var(--primary);
border-radius: var(--radius-sm);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.sidebar-nav {
flex: 1;
padding: 12px 10px;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 14px;
color: #94A3B8;
transition: all var(--transition);
white-space: nowrap;
border: none;
background: none;
width: 100%;
text-align: left;
}
.nav-item:hover { background: var(--sidebar-hover); color: #fff; }
.nav-item.active { background: var(--sidebar-active); color: #fff; }
.nav-icon { font-size: 18px; width: 20px; text-align: center; flex-shrink: 0; }
.sidebar-footer {
padding: 12px 14px;
border-top: 1px solid rgba(255,255,255,.08);
}
.role-select {
width: 100%;
padding: 8px 10px;
background: rgba(255,255,255,.1);
border: 1px solid rgba(255,255,255,.15);
color: #fff;
border-radius: var(--radius-sm);
font-size: 13px;
cursor: pointer;
}
.role-select option { background: var(--sidebar); color: #fff; }
/* Main content */
.main {
flex: 1;
margin-left: 240px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Header */
.header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 14px 24px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 50;
}
.header-title { font-size: 16px; font-weight: 600; }
.header-actions { display: flex; align-items: center; gap: 12px; }
/* Page content */
.page-content {
flex: 1;
padding: 24px;
}
.page { display: none; }
.page.active { display: block; }
/* Stats cards */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow);
}
.stat-card .label { font-size: 13px; color: var(--text-muted); margin-bottom: 6px; }
.stat-card .value { font-size: 32px; font-weight: 700; letter-spacing: -0.5px; }
.stat-card .trend { font-size: 12px; margin-top: 4px; }
.stat-card.accent-blue { border-left: 4px solid var(--primary); }
.stat-card.accent-green { border-left: 4px solid var(--success); }
.stat-card.accent-red { border-left: 4px solid var(--danger); }
.stat-card.accent-yellow { border-left: 4px solid var(--warning); }
.stat-card.accent-purple { border-left: 4px solid var(--info); }
/* Rating bars */
.rating-list { display: flex; flex-direction: column; gap: 10px; }
.rating-item { display: flex; align-items: center; gap: 12px; }
.rating-name { width: 160px; font-size: 13px; font-weight: 500; flex-shrink: 0; }
.rating-bar-bg {
flex: 1; height: 24px; background: var(--border);
border-radius: 12px; overflow: hidden;
}
.rating-bar-fill {
height: 100%; border-radius: 12px;
font-size: 11px; color: #fff;
display: flex; align-items: center; justify-content: flex-end;
padding-right: 10px; font-weight: 600;
transition: width 0.5s ease;
}
/* Panel / card */
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
margin-bottom: 20px;
}
.panel-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.panel-title { font-size: 15px; font-weight: 600; }
.panel-body { padding: 20px; }
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius-sm);
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all var(--transition);
white-space: nowrap;
}
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover { background: #1D4ED8; }
.btn-success { background: var(--success); color: #fff; }
.btn-success:hover { background: #059669; }
.btn-danger { background: var(--danger); color: #fff; }
.btn-danger:hover { background: #DC2626; }
.btn-outline {
background: transparent;
border: 1.5px solid var(--border);
color: var(--ink);
}
.btn-outline:hover { background: var(--bg); border-color: #CBD5E1; }
.btn-sm { padding: 5px 10px; font-size: 12px; }
.btn-icon { width: 32px; height: 32px; padding: 0; justify-content: center; }
/* Tables */
.table-wrap { overflow-x: auto; }
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
thead th {
text-align: left;
padding: 10px 12px;
background: var(--bg);
border-bottom: 2px solid var(--border);
font-weight: 600;
white-space: nowrap;
font-size: 12px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.3px;
}
tbody td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
tbody tr:hover { background: #F8FAFC; }
tbody tr:last-child td { border-bottom: none; }
/* Badge */
.badge {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.badge-success { background: var(--success-light); color: var(--success); }
.badge-warning { background: var(--warning-light); color: var(--warning); }
.badge-danger { background: var(--danger-light); color: var(--danger); }
.badge-info { background: var(--info-light); color: var(--info); }
.badge-default { background: #F1F5F9; color: #64748B; }
/* Form */
.form-group { margin-bottom: 14px; }
.form-label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 5px;
color: var(--ink);
}
.form-input, .form-select, .form-textarea {
width: 100%;
padding: 9px 12px;
border: 1.5px solid var(--border);
border-radius: var(--radius-sm);
font-size: 14px;
font-family: inherit;
background: var(--surface);
transition: border var(--transition);
}
.form-input:focus, .form-select:focus, .form-textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(37,99,235,.1);
}
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
.form-row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; }
/* Modal */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(15,23,42,.5);
z-index: 1000;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--surface);
border-radius: var(--radius);
box-shadow: var(--shadow-lg);
width: 100%;
max-width: 640px;
max-height: 90vh;
overflow-y: auto;
animation: slideUp 0.2s ease;
}
@keyframes slideUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
.modal-header {
padding: 18px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-close { background: none; border: none; font-size: 22px; cursor: pointer; color: var(--text-muted); line-height: 1; }
.modal-body { padding: 24px; }
.modal-footer { padding: 16px 24px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 10px; }
/* Chart containers */
.chart-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.chart-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px;
}
.chart-box h4 { font-size: 14px; font-weight: 600; margin-bottom: 12px; }
.chart-box canvas { max-height: 280px; }
/* Empty state */
.empty-state {
text-align: center;
padding: 40px 20px;
color: var(--text-muted);
}
.empty-state .icon { font-size: 42px; margin-bottom: 10px; }
.empty-state p { font-size: 14px; }
/* Photo preview */
.photo-preview {
max-width: 120px;
max-height: 80px;
border-radius: var(--radius-sm);
object-fit: cover;
border: 1px solid var(--border);
}
.photo-link { color: var(--primary); text-decoration: underline; cursor: pointer; font-size: 13px; }
/* Mobile hamburger */
.menu-toggle {
display: none;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: var(--ink);
padding: 4px;
}
/* Responsive */
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
}
.sidebar.open { transform: translateX(0); }
.main { margin-left: 0; }
.menu-toggle { display: block; }
.form-row, .form-row-3 { grid-template-columns: 1fr; }
.chart-grid { grid-template-columns: 1fr; }
.stats-grid { grid-template-columns: 1fr 1fr; }
.header { padding: 10px 16px; }
.page-content { padding: 16px; }
}
/* Print */
@media print {
.sidebar, .header, .btn, .modal-overlay { display: none !important; }
.main { margin-left: 0 !important; }
.page { display: block !important; }
.page-content { padding: 0; }
}
/* Toast */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
background: var(--ink);
color: #fff;
padding: 12px 20px;
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
z-index: 9999;
animation: slideUp 0.3s ease;
box-shadow: var(--shadow-lg);
}
.toast.success { border-left: 4px solid var(--success); }
.toast.error { border-left: 4px solid var(--danger); }
/* Search / filter bar */
.filter-bar {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 16px;
}
.filter-bar .form-input, .filter-bar .form-select { width: auto; min-width: 160px; }
/* Overdue highlight */
tr.overdue { background: #FEF2F2 !important; }
tr.overdue td { color: var(--danger); }
</style>
</head>
<body>
<!-- Sidebar -->
<aside class="sidebar" id="sidebar">
<div class="sidebar-logo">
<div class="shield">🛡</div>
<span>ОТ Контроль</span>
</div>
<nav class="sidebar-nav">
<button class="nav-item active" data-page="dashboard">
<span class="nav-icon">📊</span> Дашборд
</button>
<button class="nav-item" data-page="plan">
<span class="nav-icon">📋</span> План проверок
</button>
<button class="nav-item" data-page="registry">
<span class="nav-icon">📁</span> Реестр проверок
</button>
<button class="nav-item" data-page="violations">
<span class="nav-icon"></span> Замечания и нарушения
</button>
<button class="nav-item" data-page="remediation">
<span class="nav-icon"></span> Контроль устранения
</button>
<button class="nav-item" data-page="analytics">
<span class="nav-icon">📈</span> Аналитика
</button>
</nav>
<div class="sidebar-footer">
<select class="role-select" id="roleSelect">
<option value="admin">Администратор</option>
<option value="regional">Руководитель региона</option>
<option value="inspector">Проверяющий</option>
</select>
</div>
</aside>
<!-- Main -->
<div class="main">
<header class="header">
<div style="display:flex;align-items:center;gap:12px;">
<button class="menu-toggle" onclick="toggleSidebar()"></button>
<span class="header-title" id="pageTitle">Дашборд</span>
</div>
<div class="header-actions">
<button class="btn btn-outline btn-sm" onclick="exportExcel()">📥 Excel</button>
<button class="btn btn-outline btn-sm" onclick="window.print()">🖨 Печать</button>
</div>
</header>
<div class="page-content">
<!-- DASHBOARD -->
<div class="page active" id="page-dashboard">
<div class="stats-grid" id="statsGrid"></div>
<div class="panel">
<div class="panel-header"><span class="panel-title">Рейтинг подразделений по нарушениям</span></div>
<div class="panel-body"><div class="rating-list" id="ratingList"></div></div>
</div>
<div class="panel">
<div class="panel-header"><span class="panel-title">Последние проверки</span></div>
<div class="panel-body"><div class="table-wrap"><table id="recentChecks"></table></div></div>
</div>
</div>
<!-- AUDIT PLAN -->
<div class="page" id="page-plan">
<div class="panel">
<div class="panel-header">
<span class="panel-title">План проверок</span>
<button class="btn btn-primary btn-sm" onclick="openPlanModal()">+ Добавить</button>
</div>
<div class="panel-body">
<div class="table-wrap"><table id="planTable"></table></div>
</div>
</div>
</div>
<!-- AUDIT REGISTRY -->
<div class="page" id="page-registry">
<div class="panel">
<div class="panel-header">
<span class="panel-title">Реестр проверок</span>
<button class="btn btn-primary btn-sm" onclick="openAuditModal()">+ Добавить проверку</button>
</div>
<div class="panel-body">
<div class="filter-bar">
<select class="form-select" id="filterRegion" onchange="renderRegistry()">
<option value="">Все регионы</option>
</select>
<select class="form-select" id="filterStatus" onchange="renderRegistry()">
<option value="">Все статусы</option>
<option value="completed">Завершено</option>
<option value="pending">В работе</option>
<option value="overdue">Просрочено</option>
</select>
<input type="date" class="form-input" id="filterDateFrom" onchange="renderRegistry()" placeholder="С даты">
<input type="date" class="form-input" id="filterDateTo" onchange="renderRegistry()" placeholder="По дату">
</div>
<div class="table-wrap"><table id="registryTable"></table></div>
</div>
</div>
</div>
<!-- VIOLATIONS -->
<div class="page" id="page-violations">
<div class="panel">
<div class="panel-header">
<span class="panel-title">Замечания и нарушения</span>
<button class="btn btn-primary btn-sm" onclick="openViolationModal()">+ Добавить нарушение</button>
</div>
<div class="panel-body">
<div class="filter-bar">
<select class="form-select" id="filterViolRegion" onchange="renderViolations()">
<option value="">Все регионы</option>
</select>
<select class="form-select" id="filterViolCategory" onchange="renderViolations()">
<option value="">Все категории</option>
</select>
</div>
<div class="table-wrap"><table id="violationsTable"></table></div>
</div>
</div>
</div>
<!-- REMEDIATION CONTROL -->
<div class="page" id="page-remediation">
<div class="panel">
<div class="panel-header">
<span class="panel-title">Контроль устранения нарушений</span>
</div>
<div class="panel-body">
<div class="table-wrap"><table id="remediationTable"></table></div>
</div>
</div>
</div>
<!-- ANALYTICS -->
<div class="page" id="page-analytics">
<div class="chart-grid">
<div class="chart-box"><h4>Проверки по месяцам</h4><canvas id="chartMonthly"></canvas></div>
<div class="chart-box"><h4>Нарушения по категориям</h4><canvas id="chartCategories"></canvas></div>
<div class="chart-box"><h4>Статусы проверок</h4><canvas id="chartStatuses"></canvas></div>
<div class="chart-box"><h4>Нарушения по регионам</h4><canvas id="chartRegions"></canvas></div>
</div>
</div>
</div>
</div>
<!-- MODAL: Audit Plan -->
<div class="modal-overlay" id="planModal">
<div class="modal">
<div class="modal-header">
<h3 id="planModalTitle">Добавить план проверки</h3>
<button class="modal-close" onclick="closeModal('planModal')">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" id="planEditId">
<div class="form-row">
<div class="form-group">
<label class="form-label">Регион</label>
<select class="form-select" id="planRegion"></select>
</div>
<div class="form-group">
<label class="form-label">Подразделение</label>
<input class="form-input" id="planDivision" placeholder="Название подразделения">
</div>
</div>
<div class="form-row-3">
<div class="form-group">
<label class="form-label">Дата проверки</label>
<input type="date" class="form-input" id="planDate">
</div>
<div class="form-group">
<label class="form-label">Вид контроля</label>
<select class="form-select" id="planControlType">
<option>Плановый</option>
<option>Внеплановый</option>
<option>Целевой</option>
<option>Повторный</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Проверяющий</label>
<input class="form-input" id="planInspector" placeholder="ФИО проверяющего">
</div>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeModal('planModal')">Отмена</button>
<button class="btn btn-primary" onclick="savePlan()">Сохранить</button>
</div>
</div>
</div>
<!-- MODAL: Audit Registry -->
<div class="modal-overlay" id="auditModal">
<div class="modal">
<div class="modal-header">
<h3 id="auditModalTitle">Добавить проверку</h3>
<button class="modal-close" onclick="closeModal('auditModal')">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" id="auditEditId">
<div class="form-row">
<div class="form-group">
<label class="form-label">Регион</label>
<select class="form-select" id="auditRegion"></select>
</div>
<div class="form-group">
<label class="form-label">Подразделение</label>
<input class="form-input" id="auditDivision" placeholder="Название подразделения">
</div>
</div>
<div class="form-row-3">
<div class="form-group">
<label class="form-label">Дата проверки</label>
<input type="date" class="form-input" id="auditDate">
</div>
<div class="form-group">
<label class="form-label">Вид контроля</label>
<select class="form-select" id="auditControlType"><option>Плановый</option><option>Внеплановый</option><option>Целевой</option><option>Повторный</option></select>
</div>
<div class="form-group">
<label class="form-label">Проверяющий</label>
<input class="form-input" id="auditInspector" placeholder="ФИО проверяющего">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Категория нарушения</label>
<select class="form-select" id="auditViolationCategory">
<option>Незначительное</option>
<option>Значительное</option>
<option>Критическое</option>
<option>Без нарушений</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Ответственный</label>
<input class="form-input" id="auditResponsible" placeholder="ФИО ответственного">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Срок устранения</label>
<input type="date" class="form-input" id="auditDeadline">
</div>
<div class="form-group">
<label class="form-label">Статус</label>
<select class="form-select" id="auditStatus"><option value="pending">В работе</option><option value="completed">Завершено</option><option value="overdue">Просрочено</option></select>
</div>
</div>
<div class="form-group">
<label class="form-label">Фото нарушений</label>
<input type="file" class="form-input" id="auditPhoto" accept="image/*" onchange="previewPhoto(this)">
<img class="photo-preview" id="photoPreview" style="display:none;margin-top:8px;">
<input type="hidden" id="auditPhotoData">
</div>
<div class="form-group">
<label class="form-label">Описание / комментарий</label>
<textarea class="form-textarea" id="auditComment" rows="3" placeholder="Описание проверки или нарушений..."></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeModal('auditModal')">Отмена</button>
<button class="btn btn-primary" onclick="saveAudit()">Сохранить</button>
</div>
</div>
</div>
<!-- MODAL: Violation -->
<div class="modal-overlay" id="violationModal">
<div class="modal">
<div class="modal-header">
<h3 id="violationModalTitle">Добавить нарушение</h3>
<button class="modal-close" onclick="closeModal('violationModal')">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" id="violEditId">
<div class="form-row">
<div class="form-group">
<label class="form-label">Регион</label>
<select class="form-select" id="violRegion"></select>
</div>
<div class="form-group">
<label class="form-label">Подразделение</label>
<input class="form-input" id="violDivision" placeholder="Название подразделения">
</div>
</div>
<div class="form-row-3">
<div class="form-group">
<label class="form-label">Дата нарушения</label>
<input type="date" class="form-input" id="violDate">
</div>
<div class="form-group">
<label class="form-label">Категория</label>
<select class="form-select" id="violCategory"><option>Незначительное</option><option>Значительное</option><option>Критическое</option></select>
</div>
<div class="form-group">
<label class="form-label">Проверяющий</label>
<input class="form-input" id="violInspector" placeholder="ФИО проверяющего">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Ответственный</label>
<input class="form-input" id="violResponsible" placeholder="ФИО ответственного">
</div>
<div class="form-group">
<label class="form-label">Срок устранения</label>
<input type="date" class="form-input" id="violDeadline">
</div>
</div>
<div class="form-group">
<label class="form-label">Описание нарушения</label>
<textarea class="form-textarea" id="violDescription" rows="3" placeholder="Опишите нарушение..."></textarea>
</div>
<div class="form-group">
<label class="form-label">Фото</label>
<input type="file" class="form-input" id="violPhoto" accept="image/*" onchange="previewViolPhoto(this)">
<img class="photo-preview" id="violPhotoPreview" style="display:none;margin-top:8px;">
<input type="hidden" id="violPhotoData">
</div>
<div class="form-group">
<label class="form-label">Статус устранения</label>
<select class="form-select" id="violStatus"><option value="pending">Не устранено</option><option value="in_progress">В процессе</option><option value="completed">Устранено</option><option value="overdue">Просрочено</option></select>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-outline" onclick="closeModal('violationModal')">Отмена</button>
<button class="btn btn-primary" onclick="saveViolation()">Сохранить</button>
</div>
</div>
</div>
<script>
// ──── DATA LAYER ────
const REGIONS = ['Астана', 'Алматы', 'Шымкент', 'Актобе', 'Караганда', 'Атырау', 'Павлодар', 'Костанай', 'Усть-Каменогорск', 'Актау'];
const CONTROL_TYPES = ['Плановый', 'Внеплановый', 'Целевой', 'Повторный'];
const VIOLATION_CATEGORIES = ['Незначительное', 'Значительное', 'Критическое'];
function getStore(key) { try { return JSON.parse(localStorage.getItem('ot_' + key)) || []; } catch { return []; } }
function setStore(key, val) { localStorage.setItem('ot_' + key, JSON.stringify(val)); }
function uid() { return Date.now().toString(36) + Math.random().toString(36).substr(2, 6); }
function getPlans() { return getStore('plans'); }
function savePlans(val) { setStore('plans', val); }
function getAudits() { return getStore('audits'); }
function saveAudits(val) { setStore('audits', val); }
function getViolations() { return getStore('violations'); }
function saveViolations(val) { setStore('violations', val); }
function getRole() { return localStorage.getItem('ot_role') || 'admin'; }
function setRole(v) { localStorage.setItem('ot_role', v); }
// ──── SEED DEMO DATA ────
function seedDemo() {
if (!localStorage.getItem('ot_seeded')) {
const now = new Date();
const fmt = d => d.toISOString().split('T')[0];
const day = (d, n) => { let x = new Date(d); x.setDate(x.getDate() + n); return fmt(x); };
savePlans([
{id:uid(), region:'Астана', division:'Цех №1', date:day(now,5), controlType:'Плановый', inspector:'Ахметов Б.К.'},
{id:uid(), region:'Алматы', division:'Склад ГСМ', date:day(now,10), controlType:'Целевой', inspector:'Сериков Д.А.'},
{id:uid(), region:'Шымкент', division:'Производственный участок', date:day(now,3), controlType:'Внеплановый', inspector:'Нурланов Е.С.'},
{id:uid(), region:'Актобе', division:'Ремонтный цех', date:day(now,7), controlType:'Повторный', inspector:'Тулегенов А.М.'},
{id:uid(), region:'Караганда', division:'Административный корпус', date:day(now,14), controlType:'Плановый', inspector:'Искаков Р.Т.'},
]);
saveAudits([
{id:uid(), region:'Астана', division:'Цех №1', date:day(now,-5), controlType:'Плановый', inspector:'Ахметов Б.К.', violationCategory:'Значительное', responsible:'Касымов А.С.', deadline:day(now,10), status:'pending', photo:'', comment:'Нарушение правил работы на высоте'},
{id:uid(), region:'Алматы', division:'Склад ГСМ', date:day(now,-3), controlType:'Целевой', inspector:'Сериков Д.А.', violationCategory:'Критическое', responsible:'Муратов Е.А.', deadline:day(now,-1), status:'overdue', photo:'', comment:'Отсутствует заземление оборудования'},
{id:uid(), region:'Шымкент', division:'Производственный участок', date:day(now,-7), controlType:'Плановый', inspector:'Нурланов Е.С.', violationCategory:'Незначительное', responsible:'Оспанов К.М.', deadline:day(now,-2), status:'overdue', photo:'', comment:'Отсутствует маркировка зон'},
{id:uid(), region:'Астана', division:'Цех №2', date:day(now,-10), controlType:'Повторный', inspector:'Ахметов Б.К.', violationCategory:'Без нарушений', responsible:'', deadline:'', status:'completed', photo:'', comment:'Все замечания устранены'},
{id:uid(), region:'Павлодар', division:'Электроцех', date:day(now,-4), controlType:'Внеплановый', inspector:'Каримов Ж.Б.', violationCategory:'Значительное', responsible:'Абдрахманов Д.С.', deadline:day(now,15), status:'pending', photo:'', comment:'Нарушение правил электробезопасности'},
{id:uid(), region:'Караганда', division:'Транспортный цех', date:day(now,-12), controlType:'Плановый', inspector:'Жумабеков А.К.', violationCategory:'Без нарушений', responsible:'', deadline:'', status:'completed', photo:'', comment:'Проверка пройдена'},
{id:uid(), region:'Атырау', division:'Буровая площадка', date:day(now,-2), controlType:'Целевой', inspector:'Сагиев Н.Т.', violationCategory:'Критическое', responsible:'Кенесов Б.А.', deadline:day(now,-3), status:'overdue', photo:'', comment:'Отсутствуют СИЗ у персонала'},
{id:uid(), region:'Костанай', division:'Строительный участок', date:day(now,-6), controlType:'Плановый', inspector:'Ермеков С.Д.', violationCategory:'Незначительное', responsible:'Дуйсенов А.К.', deadline:day(now,20), status:'pending', photo:'', comment:'Не ограждена строительная площадка'},
]);
saveViolations([
{id:uid(), region:'Алматы', division:'Склад ГСМ', date:day(now,-3), category:'Критическое', inspector:'Сериков Д.А.', responsible:'Муратов Е.А.', deadline:day(now,-1), description:'Отсутствует заземление резервуаров', photo:'', status:'overdue'},
{id:uid(), region:'Астана', division:'Цех №1', date:day(now,-5), category:'Значительное', inspector:'Ахметов Б.К.', responsible:'Касымов А.С.', deadline:day(now,10), description:'Работа на высоте без страховки', photo:'', status:'pending'},
{id:uid(), region:'Атырау', division:'Буровая площадка', date:day(now,-2), category:'Критическое', inspector:'Сагиев Н.Т.', responsible:'Кенесов Б.А.', deadline:day(now,-3), description:'Отсутствие защитных касок', photo:'', status:'overdue'},
{id:uid(), region:'Шымкент', division:'Производственный участок', date:day(now,-7), category:'Незначительное', inspector:'Нурланов Е.С.', responsible:'Оспанов К.М.', deadline:day(now,4), description:'Стерта разметка проходов', photo:'', status:'in_progress'},
{id:uid(), region:'Павлодар', division:'Электроцех', date:day(now,-4), category:'Значительное', inspector:'Каримов Ж.Б.', responsible:'Абдрахманов Д.С.', deadline:day(now,15), description:'Провода без изоляции', photo:'', status:'pending'},
]);
localStorage.setItem('ot_seeded', '1');
}
}
// ──── NAVIGATION ────
function navigate(page) {
document.querySelectorAll('.page').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active'));
document.getElementById('page-' + page).classList.add('active');
document.querySelector('[data-page="' + page + '"]').classList.add('active');
const titles = {dashboard:'Дашборд', plan:'План проверок', registry:'Реестр проверок', violations:'Замечания и нарушения', remediation:'Контроль устранения', analytics:'Аналитика'};
document.getElementById('pageTitle').textContent = titles[page] || page;
if (window.innerWidth < 769) document.getElementById('sidebar').classList.remove('open');
if (page === 'dashboard') renderDashboard();
if (page === 'plan') renderPlan();
if (page === 'registry') renderRegistry();
if (page === 'violations') renderViolations();
if (page === 'remediation') renderRemediation();
if (page === 'analytics') renderAnalytics();
}
document.querySelectorAll('.nav-item').forEach(btn => {
btn.addEventListener('click', () => navigate(btn.dataset.page));
});
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
}
// ──── ROLE ────
document.getElementById('roleSelect').value = getRole();
document.getElementById('roleSelect').addEventListener('change', function() {
setRole(this.value);
showToast('Роль изменена: ' + this.options[this.selectedIndex].text, 'success');
});
// ──── POPULATE REGION SELECTS ────
function populateRegionSelects() {
document.querySelectorAll('select[id$=Region], select.filterRegion').forEach(sel => {
if (sel.options.length <= 1) {
REGIONS.forEach(r => { const o = document.createElement('option'); o.value = r; o.textContent = r; sel.appendChild(o); });
}
});
}
// ──── TOAST ────
function showToast(msg, type) {
const t = document.createElement('div');
t.className = 'toast ' + type;
t.textContent = msg;
document.body.appendChild(t);
setTimeout(() => t.remove(), 3000);
}
// ──── MODALS ────
function openModal(id) { document.getElementById(id).classList.add('open'); }
function closeModal(id) { document.getElementById(id).classList.remove('open'); }
document.querySelectorAll('.modal-overlay').forEach(ov => {
ov.addEventListener('click', function(e) { if (e.target === this) closeModal(this.id); });
});
// ──── PHOTO ────
function previewPhoto(input) {
const preview = document.getElementById('photoPreview');
const dataInput = document.getElementById('auditPhotoData');
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) { preview.src = e.target.result; preview.style.display = 'block'; dataInput.value = e.target.result; };
reader.readAsDataURL(input.files[0]);
}
}
function previewViolPhoto(input) {
const preview = document.getElementById('violPhotoPreview');
const dataInput = document.getElementById('violPhotoData');
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) { preview.src = e.target.result; preview.style.display = 'block'; dataInput.value = e.target.result; };
reader.readAsDataURL(input.files[0]);
}
}
// ═══════════════════════════════════════
// DASHBOARD
// ═══════════════════════════════════════
function renderDashboard() {
const audits = getAudits();
const violations = getViolations();
const plans = getPlans();
const total = audits.length;
const completed = audits.filter(a => a.status === 'completed').length;
const pending = audits.filter(a => a.status === 'pending').length;
const overdueAudits = audits.filter(a => {
if (a.status === 'overdue') return true;
if (a.status === 'pending' && a.deadline && new Date(a.deadline) < new Date()) return true;
return false;
}).length;
const totalViolations = violations.filter(v => v.status !== 'completed').length;
const overdueViolations = violations.filter(v => {
if (v.status === 'overdue') return true;
if ((v.status === 'pending' || v.status === 'in_progress') && v.deadline && new Date(v.deadline) < new Date()) return true;
return false;
}).length;
document.getElementById('statsGrid').innerHTML = `
<div class="stat-card accent-blue">
<div class="label">Всего проверок</div>
<div class="value">${total}</div>
<div class="trend" style="color:var(--text-muted)">Планов: ${plans.length}</div>
</div>
<div class="stat-card accent-green">
<div class="label">Завершено</div>
<div class="value">${completed}</div>
<div class="trend" style="color:var(--success)">${total ? Math.round(completed/total*100) : 0}% от всех</div>
</div>
<div class="stat-card accent-yellow">
<div class="label">В работе</div>
<div class="value">${pending}</div>
<div class="trend" style="color:var(--warning)">Требуют внимания</div>
</div>
<div class="stat-card accent-red">
<div class="label">Просрочено</div>
<div class="value">${overdueAudits}</div>
<div class="trend" style="color:var(--danger)">Проверок и нарушений: ${overdueAudits + overdueViolations}</div>
</div>
<div class="stat-card accent-purple">
<div class="label">Нарушений (активных)</div>
<div class="value">${totalViolations}</div>
<div class="trend" style="color:var(--danger)">Просрочено: ${overdueViolations}</div>
</div>
`;
// Rating
const divRatings = {};
audits.forEach(a => {
if (!divRatings[a.division]) divRatings[a.division] = {total:0, violations:0};
divRatings[a.division].total++;
if (a.violationCategory && a.violationCategory !== 'Без нарушений') divRatings[a.division].violations++;
});
const sorted = Object.entries(divRatings).sort((a,b) => {
const rateA = a[1].total ? a[1].violations / a[1].total : 0;
const rateB = b[1].total ? b[1].violations / b[1].total : 0;
return rateB - rateA;
});
const maxViolations = Math.max(1, ...sorted.map(s => s[1].violations));
const colors = ['#EF4444','#F59E0B','#6366F1','#10B981','#2563EB','#8B5CF6'];
document.getElementById('ratingList').innerHTML = sorted.slice(0, 8).map((s,i) => {
const pct = Math.round((s[1].violations / maxViolations) * 100);
return `<div class="rating-item">
<span class="rating-name">${s[0]}</span>
<div class="rating-bar-bg"><div class="rating-bar-fill" style="width:${pct}%;background:${colors[i%colors.length]}">${s[1].violations}</div></div>
</div>`;
}).join('') || '<div class="empty-state"><p>Нет данных для рейтинга</p></div>';
// Recent checks
const recent = [...audits].sort((a,b) => new Date(b.date) - new Date(a.date)).slice(0, 5);
document.getElementById('recentChecks').innerHTML = recent.length ? `
<thead><tr><th>Дата</th><th>Регион</th><th>Подразделение</th><th>Вид</th><th>Категория</th><th>Статус</th></tr></thead>
<tbody>${recent.map(a => `<tr>
<td>${a.date}</td><td>${a.region}</td><td>${a.division}</td><td>${a.controlType}</td>
<td>${a.violationCategory}</td>
<td>${statusBadge(a.status, a.deadline)}</td>
</tr>`).join('')}</tbody>
` : '<tbody><tr><td colspan="6"><div class="empty-state"><p>Нет проверок</p></div></td></tr></tbody>';
}
function statusBadge(status, deadline) {
const isOverdue = status !== 'completed' && deadline && new Date(deadline) < new Date();
if (status === 'completed') return '<span class="badge badge-success">Завершено</span>';
if (isOverdue) return '<span class="badge badge-danger">Просрочено</span>';
return '<span class="badge badge-warning">В работе</span>';
}
// ═══════════════════════════════════════
// AUDIT PLAN
// ═══════════════════════════════════════
function openPlanModal(id) {
document.getElementById('planEditId').value = '';
document.getElementById('planModalTitle').textContent = 'Добавить план проверки';
document.getElementById('planRegion').value = '';
document.getElementById('planDivision').value = '';
document.getElementById('planDate').value = '';
document.getElementById('planControlType').value = 'Плановый';
document.getElementById('planInspector').value = '';
openModal('planModal');
}
function savePlan() {
const plans = getPlans();
const editId = document.getElementById('planEditId').value;
const item = {
id: editId || uid(),
region: document.getElementById('planRegion').value,
division: document.getElementById('planDivision').value,
date: document.getElementById('planDate').value,
controlType: document.getElementById('planControlType').value,
inspector: document.getElementById('planInspector').value,
};
if (!item.region || !item.division || !item.date) return showToast('Заполните обязательные поля', 'error');
if (editId) {
const idx = plans.findIndex(p => p.id === editId);
if (idx >= 0) plans[idx] = item;
} else {
plans.push(item);
}
savePlans(plans);
closeModal('planModal');
renderPlan();
showToast(editId ? 'План обновлён' : 'План добавлен', 'success');
}
function deletePlan(id) {
if (!confirm('Удалить запись плана?')) return;
savePlans(getPlans().filter(p => p.id !== id));
renderPlan();
showToast('Запись удалена', 'success');
}
function renderPlan() {
const plans = getPlans();
document.getElementById('planTable').innerHTML = plans.length ? `
<thead><tr><th>Дата</th><th>Регион</th><th>Подразделение</th><th>Вид контроля</th><th>Проверяющий</th><th>Действия</th></tr></thead>
<tbody>${plans.sort((a,b) => new Date(a.date) - new Date(b.date)).map(p => `<tr>
<td>${p.date}</td><td>${p.region}</td><td>${p.division}</td><td>${p.controlType}</td><td>${p.inspector}</td>
<td>
<button class="btn btn-outline btn-sm" onclick="openPlanEdit('${p.id}')">✏</button>
<button class="btn btn-danger btn-sm" onclick="deletePlan('${p.id}')">🗑</button>
</td>
</tr>`).join('')}</tbody>
` : '<tbody><tr><td colspan="6"><div class="empty-state"><p>План проверок пуст</p></div></td></tr></tbody>';
}
function openPlanEdit(id) {
const plans = getPlans();
const item = plans.find(p => p.id === id);
if (!item) return;
document.getElementById('planEditId').value = item.id;
document.getElementById('planModalTitle').textContent = 'Редактировать план проверки';
document.getElementById('planRegion').value = item.region;
document.getElementById('planDivision').value = item.division;
document.getElementById('planDate').value = item.date;
document.getElementById('planControlType').value = item.controlType;
document.getElementById('planInspector').value = item.inspector;
openModal('planModal');
}
// ═══════════════════════════════════════
// AUDIT REGISTRY
// ═══════════════════════════════════════
function openAuditModal() {
document.getElementById('auditEditId').value = '';
document.getElementById('auditModalTitle').textContent = 'Добавить проверку';
['auditRegion','auditDivision','auditDate','auditControlType','auditInspector','auditViolationCategory','auditResponsible','auditDeadline','auditStatus','auditComment','auditPhotoData'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
document.getElementById('auditStatus').value = 'pending';
document.getElementById('photoPreview').style.display = 'none';
document.getElementById('auditPhoto').value = '';
openModal('auditModal');
}
function saveAudit() {
const audits = getAudits();
const editId = document.getElementById('auditEditId').value;
const item = {
id: editId || uid(),
region: document.getElementById('auditRegion').value,
division: document.getElementById('auditDivision').value,
date: document.getElementById('auditDate').value,
controlType: document.getElementById('auditControlType').value,
inspector: document.getElementById('auditInspector').value,
violationCategory: document.getElementById('auditViolationCategory').value,
responsible: document.getElementById('auditResponsible').value,
deadline: document.getElementById('auditDeadline').value,
status: document.getElementById('auditStatus').value,
photo: document.getElementById('auditPhotoData').value,
comment: document.getElementById('auditComment').value,
};
if (!item.region || !item.division || !item.date) return showToast('Заполните обязательные поля: регион, подразделение, дата', 'error');
if (editId) {
const idx = audits.findIndex(a => a.id === editId);
if (idx >= 0) audits[idx] = item;
} else {
audits.push(item);
}
saveAudits(audits);
closeModal('auditModal');
renderRegistry();
showToast(editId ? 'Проверка обновлена' : 'Проверка добавлена', 'success');
}
function deleteAudit(id) {
if (!confirm('Удалить запись проверки?')) return;
saveAudits(getAudits().filter(a => a.id !== id));
renderRegistry();
showToast('Запись удалена', 'success');
}
function openAuditEdit(id) {
const audits = getAudits();
const item = audits.find(a => a.id === id);
if (!item) return;
document.getElementById('auditEditId').value = item.id;
document.getElementById('auditModalTitle').textContent = 'Редактировать проверку';
document.getElementById('auditRegion').value = item.region;
document.getElementById('auditDivision').value = item.division;
document.getElementById('auditDate').value = item.date;
document.getElementById('auditControlType').value = item.controlType;
document.getElementById('auditInspector').value = item.inspector;
document.getElementById('auditViolationCategory').value = item.violationCategory;
document.getElementById('auditResponsible').value = item.responsible;
document.getElementById('auditDeadline').value = item.deadline;
document.getElementById('auditStatus').value = item.status;
document.getElementById('auditComment').value = item.comment || '';
document.getElementById('auditPhotoData').value = item.photo || '';
if (item.photo) {
document.getElementById('photoPreview').src = item.photo;
document.getElementById('photoPreview').style.display = 'block';
} else {
document.getElementById('photoPreview').style.display = 'none';
}
openModal('auditModal');
}
function renderRegistry() {
let audits = getAudits();
const filterRegion = document.getElementById('filterRegion').value;
const filterStatus = document.getElementById('filterStatus').value;
const filterFrom = document.getElementById('filterDateFrom').value;
const filterTo = document.getElementById('filterDateTo').value;
if (filterRegion) audits = audits.filter(a => a.region === filterRegion);
if (filterStatus) audits = audits.filter(a => {
if (filterStatus === 'overdue') {
return a.status === 'overdue' || (a.status === 'pending' && a.deadline && new Date(a.deadline) < new Date());
}
return a.status === filterStatus;
});
if (filterFrom) audits = audits.filter(a => a.date >= filterFrom);
if (filterTo) audits = audits.filter(a => a.date <= filterTo);
audits.sort((a,b) => new Date(b.date) - new Date(a.date));
document.getElementById('registryTable').innerHTML = audits.length ? `
<thead><tr><th>Дата</th><th>Регион</th><th>Подразделение</th><th>Вид</th><th>Проверяющий</th><th>Категория</th><th>Ответственный</th><th>Срок</th><th>Статус</th><th>Действия</th></tr></thead>
<tbody>${audits.map(a => {
const isOverdue = a.status !== 'completed' && a.deadline && new Date(a.deadline) < new Date();
return `<tr class="${isOverdue ? 'overdue' : ''}">
<td>${a.date}</td><td>${a.region}</td><td>${a.division}</td><td>${a.controlType}</td><td>${a.inspector}</td>
<td>${catBadge(a.violationCategory)}</td><td>${a.responsible || '—'}</td><td>${a.deadline || '—'}</td>
<td>${statusBadge(a.status, a.deadline)}</td>
<td>
${a.photo ? `<a class="photo-link" onclick="showPhoto('${a.id}')">📷</a> ` : ''}
<button class="btn btn-outline btn-sm" onclick="openAuditEdit('${a.id}')">✏</button>
<button class="btn btn-danger btn-sm" onclick="deleteAudit('${a.id}')">🗑</button>
</td>
</tr>`;
}).join('')}</tbody>
` : '<tbody><tr><td colspan="10"><div class="empty-state"><p>Нет проверок</p></div></td></tr></tbody>';
}
function catBadge(cat) {
if (!cat || cat === 'Без нарушений') return '<span class="badge badge-success">Без нарушений</span>';
if (cat === 'Критическое') return '<span class="badge badge-danger">Критическое</span>';
if (cat === 'Значительное') return '<span class="badge badge-warning">Значительное</span>';
return '<span class="badge badge-info">Незначительное</span>';
}
function showPhoto(id) {
const audit = getAudits().find(a => a.id === id);
if (audit && audit.photo) {
const w = window.open('', '_blank');
w.document.write(`<img src="${audit.photo}" style="max-width:100%;height:auto;">`);
}
}
// ═══════════════════════════════════════
// VIOLATIONS
// ═══════════════════════════════════════
function openViolationModal(id) {
document.getElementById('violEditId').value = '';
document.getElementById('violationModalTitle').textContent = 'Добавить нарушение';
['violRegion','violDivision','violDate','violCategory','violInspector','violResponsible','violDeadline','violDescription','violPhotoData'].forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
document.getElementById('violStatus').value = 'pending';
document.getElementById('violPhotoPreview').style.display = 'none';
document.getElementById('violPhoto').value = '';
openModal('violationModal');
}
function saveViolation() {
const violations = getViolations();
const editId = document.getElementById('violEditId').value;
const item = {
id: editId || uid(),
region: document.getElementById('violRegion').value,
division: document.getElementById('violDivision').value,
date: document.getElementById('violDate').value,
category: document.getElementById('violCategory').value,
inspector: document.getElementById('violInspector').value,
responsible: document.getElementById('violResponsible').value,
deadline: document.getElementById('violDeadline').value,
description: document.getElementById('violDescription').value,
photo: document.getElementById('violPhotoData').value,
status: document.getElementById('violStatus').value,
};
if (!item.region || !item.division || !item.date) return showToast('Заполните обязательные поля: регион, подразделение, дата', 'error');
if (editId) {
const idx = violations.findIndex(v => v.id === editId);
if (idx >= 0) violations[idx] = item;
} else {
violations.push(item);
}
saveViolations(violations);
closeModal('violationModal');
renderViolations();
showToast(editId ? 'Нарушение обновлено' : 'Нарушение добавлено', 'success');
}
function deleteViolation(id) {
if (!confirm('Удалить запись нарушения?')) return;
saveViolations(getViolations().filter(v => v.id !== id));
renderViolations();
showToast('Запись удалена', 'success');
}
function openViolationEdit(id) {
const violations = getViolations();
const item = violations.find(v => v.id === id);
if (!item) return;
document.getElementById('violEditId').value = item.id;
document.getElementById('violationModalTitle').textContent = 'Редактировать нарушение';
document.getElementById('violRegion').value = item.region;
document.getElementById('violDivision').value = item.division;
document.getElementById('violDate').value = item.date;
document.getElementById('violCategory').value = item.category;
document.getElementById('violInspector').value = item.inspector;
document.getElementById('violResponsible').value = item.responsible;
document.getElementById('violDeadline').value = item.deadline;
document.getElementById('violDescription').value = item.description || '';
document.getElementById('violStatus').value = item.status;
document.getElementById('violPhotoData').value = item.photo || '';
if (item.photo) {
document.getElementById('violPhotoPreview').src = item.photo;
document.getElementById('violPhotoPreview').style.display = 'block';
} else {
document.getElementById('violPhotoPreview').style.display = 'none';
}
openModal('violationModal');
}
function renderViolations() {
let violations = getViolations();
const filterRegion = document.getElementById('filterViolRegion').value;
const filterCategory = document.getElementById('filterViolCategory').value;
if (filterRegion) violations = violations.filter(v => v.region === filterRegion);
if (filterCategory) violations = violations.filter(v => v.category === filterCategory);
violations.sort((a,b) => new Date(b.date) - new Date(a.date));
document.getElementById('violationsTable').innerHTML = violations.length ? `
<thead><tr><th>Дата</th><th>Регион</th><th>Подразделение</th><th>Категория</th><th>Описание</th><th>Ответственный</th><th>Срок</th><th>Статус</th><th>Действия</th></tr></thead>
<tbody>${violations.map(v => {
const isOverdue = (v.status === 'pending' || v.status === 'in_progress') && v.deadline && new Date(v.deadline) < new Date();
return `<tr class="${isOverdue ? 'overdue' : ''}">
<td>${v.date}</td><td>${v.region}</td><td>${v.division}</td><td>${catBadge(v.category)}</td>
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${v.description||''}">${v.description || '—'}</td>
<td>${v.responsible || '—'}</td><td>${v.deadline || '—'}</td>
<td>${violStatusBadge(v.status, v.deadline)}</td>
<td>
<button class="btn btn-outline btn-sm" onclick="openViolationEdit('${v.id}')">✏</button>
<button class="btn btn-danger btn-sm" onclick="deleteViolation('${v.id}')">🗑</button>
</td>
</tr>`;
}).join('')}</tbody>
` : '<tbody><tr><td colspan="9"><div class="empty-state"><p>Нет нарушений</p></div></td></tr></tbody>';
}
function violStatusBadge(status, deadline) {
const isOverdue = (status === 'pending' || status === 'in_progress') && deadline && new Date(deadline) < new Date();
if (status === 'completed') return '<span class="badge badge-success">Устранено</span>';
if (status === 'overdue' || isOverdue) return '<span class="badge badge-danger">Просрочено</span>';
if (status === 'in_progress') return '<span class="badge badge-info">В процессе</span>';
return '<span class="badge badge-warning">Не устранено</span>';
}
// ═══════════════════════════════════════
// REMEDIATION CONTROL
// ═══════════════════════════════════════
function renderRemediation() {
const violations = getViolations().filter(v => v.status !== 'completed');
violations.sort((a,b) => {
const overdueA = a.status === 'overdue' || (a.deadline && new Date(a.deadline) < new Date()) ? 0 : 1;
const overdueB = b.status === 'overdue' || (b.deadline && new Date(b.deadline) < new Date()) ? 0 : 1;
if (overdueA !== overdueB) return overdueA - overdueB;
return new Date(a.deadline || 0) - new Date(b.deadline || 0);
});
document.getElementById('remediationTable').innerHTML = violations.length ? `
<thead><tr><th>Дата</th><th>Регион</th><th>Подразделение</th><th>Категория</th><th>Описание</th><th>Ответственный</th><th>Срок</th><th>Статус</th><th>Действие</th></tr></thead>
<tbody>${violations.map(v => `
<tr class="${v.status === 'overdue' || (v.deadline && new Date(v.deadline) < new Date()) ? 'overdue' : ''}">
<td>${v.date}</td><td>${v.region}</td><td>${v.division}</td><td>${catBadge(v.category)}</td>
<td>${v.description || '—'}</td><td>${v.responsible || '—'}</td><td>${v.deadline || '—'}</td>
<td>${violStatusBadge(v.status, v.deadline)}</td>
<td><button class="btn btn-success btn-sm" onclick="markResolved('${v.id}')">✅ Устранено</button></td>
</tr>
`).join('')}</tbody>
` : '<tbody><tr><td colspan="9"><div class="empty-state"><div class="icon">✅</div><p>Все нарушения устранены</p></div></td></tr></tbody>';
}
function markResolved(id) {
const violations = getViolations();
const idx = violations.findIndex(v => v.id === id);
if (idx >= 0) { violations[idx].status = 'completed'; saveViolations(violations); }
renderRemediation();
showToast('Нарушение отмечено как устранённое', 'success');
}
// ═══════════════════════════════════════
// ANALYTICS
// ═══════════════════════════════════════
let analyticsCharts = [];
function renderAnalytics() {
analyticsCharts.forEach(c => c.destroy());
analyticsCharts = [];
const audits = getAudits();
const violations = getViolations();
// Monthly audits
const months = {};
const now = new Date();
for (let i = 5; i >= 0; i--) {
const m = new Date(now.getFullYear(), now.getMonth() - i, 1);
const key = m.toLocaleString('ru', {month:'short'});
months[key] = 0;
}
audits.forEach(a => {
if (!a.date) return;
const d = new Date(a.date);
const key = d.toLocaleString('ru', {month:'short'});
if (months[key] !== undefined) months[key]++;
});
const c1 = document.getElementById('chartMonthly').getContext('2d');
analyticsCharts.push(new Chart(c1, {
type: 'bar', data: {labels: Object.keys(months), datasets:[{label:'Проверок', data:Object.values(months), backgroundColor:'#2563EB', borderRadius:6}]},
options: {responsive:true, plugins:{legend:{display:false}}}
}));
// Categories
const cats = {};
VIOLATION_CATEGORIES.forEach(c => cats[c] = 0);
violations.forEach(v => { if (cats[v.category] !== undefined) cats[v.category]++; });
const c2 = document.getElementById('chartCategories').getContext('2d');
analyticsCharts.push(new Chart(c2, {
type: 'doughnut', data: {labels:Object.keys(cats), datasets:[{data:Object.values(cats), backgroundColor:['#F59E0B','#EF4444','#DC2626']}]},
options: {responsive:true}
}));
// Statuses
const completed = audits.filter(a => a.status === 'completed').length;
const pending = audits.filter(a => a.status === 'pending').length;
const overdue = audits.filter(a => a.status === 'overdue' || (a.status === 'pending' && a.deadline && new Date(a.deadline) < new Date())).length;
const c3 = document.getElementById('chartStatuses').getContext('2d');
analyticsCharts.push(new Chart(c3, {
type: 'pie', data: {labels:['Завершено','В работе','Просрочено'], datasets:[{data:[completed,pending,overdue], backgroundColor:['#10B981','#F59E0B','#EF4444']}]},
options: {responsive:true}
}));
// Regions
const regionCounts = {};
REGIONS.forEach(r => regionCounts[r] = 0);
violations.forEach(v => { if (regionCounts[v.region] !== undefined) regionCounts[v.region]++; });
const c4 = document.getElementById('chartRegions').getContext('2d');
analyticsCharts.push(new Chart(c4, {
type: 'bar', data: {labels:Object.keys(regionCounts), datasets:[{label:'Нарушений', data:Object.values(regionCounts), backgroundColor:'#6366F1', borderRadius:6}]},
options: {indexAxis:'y', responsive:true, plugins:{legend:{display:false}}}
}));
}
// ═══════════════════════════════════════
// EXPORT
// ═══════════════════════════════════════
function exportExcel() {
const audits = getAudits();
const violations = getViolations();
let csv = 'Раздел;Регион;Подразделение;Дата;Вид контроля;Проверяющий;Категория;Ответственный;Срок;Статус;Описание\n';
audits.forEach(a => {
csv += `Проверка;${a.region};${a.division};${a.date};${a.controlType};${a.inspector};${a.violationCategory};${a.responsible||''};${a.deadline||''};${a.status};${(a.comment||'').replace(/"/g,'""')}\n`;
});
violations.forEach(v => {
csv += `Нарушение;${v.region};${v.division};${v.date};;${v.inspector};${v.category};${v.responsible||''};${v.deadline||''};${v.status};${(v.description||'').replace(/"/g,'""')}\n`;
});
const BOM = '\uFEFF';
const blob = new Blob([BOM + csv], {type:'text/csv;charset=utf-8'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'OT_Control_Export_' + new Date().toISOString().split('T')[0] + '.csv';
a.click();
URL.revokeObjectURL(url);
showToast('Экспорт в Excel (CSV) выполнен', 'success');
}
// ═══════════════════════════════════════
// INIT
// ═══════════════════════════════════════
seedDemo();
populateRegionSelects();
renderDashboard();
console.log('🛡 ОТ Контроль — приложение готово');
console.log('Данные хранятся в localStorage браузера');
console.log('Git: https://git.vibe42.kz/serik_gylymmedden/ot-control');
</script>
</body>
</html>