1456 lines
64 KiB
HTML
1456 lines
64 KiB
HTML
<!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')">×</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')">×</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')">×</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>
|