uchet-siz/index.html

1853 lines
70 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.sheetjs.com/xlsx-0.20.1/package/dist/xlsx.full.min.js"></script>
<style>
:root {
--ink: #0F1218;
--cyan: #00E5FF;
--cyan-50: #E8FCFF;
--white: #FFFFFF;
--gray-500: #5B6573;
--gray-100: #F2F4F7;
--gray-200: #E4E7EC;
--gray-300: #D0D5DD;
--gray-700: #344054;
--red: #D92D20;
--red-50: #FEF3F2;
--orange: #F79009;
--orange-50: #FFFAEB;
--green: #12B76A;
--green-50: #ECFDF3;
--radius: 8px;
--radius-lg: 12px;
--shadow: 0 1px 3px rgba(16,24,40,.06), 0 1px 2px rgba(16,24,40,.08);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Inter, system-ui, sans-serif;
color: var(--ink);
background: var(--gray-100);
min-height: 100vh;
}
/* Header */
.header {
background: var(--ink);
color: var(--white);
padding: 16px 24px;
display: flex;
align-items: center;
gap: 16px;
flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 100;
}
.header h1 {
font-size: 18px;
font-weight: 700;
white-space: nowrap;
}
.header .subtitle {
font-size: 13px;
color: var(--gray-500);
white-space: nowrap;
}
.header-right {
margin-left: auto;
display: flex;
gap: 8px;
align-items: center;
}
.header .btn-sm {
font-size: 13px;
padding: 6px 14px;
border-radius: var(--radius);
font-weight: 600;
cursor: pointer;
border: none;
transition: opacity .15s;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 4px;
}
.header .btn-sm:hover { opacity: .85; }
/* Nav tabs */
.nav {
background: var(--white);
border-bottom: 1px solid var(--gray-200);
display: flex;
gap: 0;
overflow-x: auto;
padding: 0 24px;
position: sticky;
top: 54px;
z-index: 99;
}
.nav button {
background: none;
border: none;
border-bottom: 2px solid transparent;
padding: 12px 16px;
font-size: 14px;
font-weight: 500;
color: var(--gray-500);
cursor: pointer;
white-space: nowrap;
transition: all .15s;
}
.nav button:hover { color: var(--ink); }
.nav button.active {
color: var(--ink);
border-bottom-color: var(--cyan);
font-weight: 600;
}
/* Main */
.main {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
/* Panel */
.panel {
display: none;
}
.panel.active {
display: block;
}
/* Toolbar */
.toolbar {
display: flex;
gap: 8px;
margin-bottom: 16px;
flex-wrap: wrap;
align-items: center;
}
.toolbar input {
padding: 8px 12px;
border: 1px solid var(--gray-300);
border-radius: var(--radius);
font-size: 14px;
flex: 1;
min-width: 180px;
outline: none;
transition: border-color .15s;
}
.toolbar input:focus { border-color: var(--cyan); }
.toolbar select {
padding: 8px 12px;
border: 1px solid var(--gray-300);
border-radius: var(--radius);
font-size: 14px;
background: var(--white);
cursor: pointer;
outline: none;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 9px 18px;
border-radius: var(--radius);
font-size: 14px;
font-weight: 600;
cursor: pointer;
border: none;
transition: opacity .15s;
white-space: nowrap;
}
.btn:hover { opacity: .85; }
.btn-primary { background: var(--cyan); color: var(--ink); }
.btn-danger { background: var(--red); color: var(--white); }
.btn-outline { background: var(--white); color: var(--ink); border: 1px solid var(--gray-300); }
.btn-success { background: var(--green); color: var(--white); }
.btn-ink { background: var(--ink); color: var(--white); }
/* Tables */
.table-wrap {
overflow-x: auto;
background: var(--white);
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 10px 14px;
font-size: 13px;
border-bottom: 1px solid var(--gray-100);
white-space: nowrap;
}
th {
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: .3px;
color: var(--gray-500);
background: var(--gray-100);
position: sticky;
top: 0;
}
tr:last-child td { border-bottom: none; }
tr:hover td { background: var(--cyan-50); }
/* Badges */
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 100px;
font-size: 12px;
font-weight: 600;
}
.badge-green { background: var(--green-50); color: var(--green); }
.badge-orange { background: var(--orange-50); color: var(--orange); }
.badge-red { background: var(--red-50); color: var(--red); }
.badge-gray { background: var(--gray-100); color: var(--gray-500); }
/* Modal */
.modal-overlay {
display: none;
position: fixed;
inset: 0;
background: rgba(15,18,24,.5);
z-index: 200;
align-items: center;
justify-content: center;
}
.modal-overlay.show {
display: flex;
}
.modal {
background: var(--white);
border-radius: var(--radius-lg);
padding: 28px;
width: 90%;
max-width: 560px;
max-height: 85vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0,0,0,.2);
}
.modal h2 {
font-size: 18px;
margin-bottom: 20px;
}
.form-group {
margin-bottom: 14px;
}
.form-group label {
display: block;
font-size: 13px;
font-weight: 600;
margin-bottom: 4px;
color: var(--gray-700);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--gray-300);
border-radius: var(--radius);
font-size: 14px;
font-family: inherit;
outline: none;
transition: border-color .15s;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
border-color: var(--cyan);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.modal-actions {
display: flex;
gap: 8px;
justify-content: flex-end;
margin-top: 20px;
}
/* Stats cards */
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--white);
border-radius: var(--radius-lg);
padding: 20px;
box-shadow: var(--shadow);
}
.stat-card .stat-label {
font-size: 12px;
text-transform: uppercase;
letter-spacing: .5px;
color: var(--gray-500);
font-weight: 600;
}
.stat-card .stat-value {
font-size: 28px;
font-weight: 800;
margin-top: 4px;
line-height: 1.1;
}
.stat-card .stat-sub {
font-size: 12px;
color: var(--gray-500);
margin-top: 4px;
}
/* Alert list */
.alert-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.alert-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: var(--radius);
font-size: 13px;
}
.alert-item.red {
background: var(--red-50);
border: 1px solid rgba(217,45,32,.2);
}
.alert-item.orange {
background: var(--orange-50);
border: 1px solid rgba(247,144,9,.2);
}
/* Empty state */
.empty {
text-align: center;
padding: 48px 24px;
color: var(--gray-500);
}
.empty .empty-icon {
font-size: 48px;
margin-bottom: 12px;
opacity: .4;
}
/* Action icons */
.action-btn {
background: none;
border: none;
cursor: pointer;
padding: 4px;
border-radius: 4px;
font-size: 16px;
transition: background .15s;
line-height: 1;
}
.action-btn:hover { background: var(--gray-100); }
/* Norm hint in issuance modal */
.norm-hint {
margin: 16px 0 8px;
padding: 12px;
background: var(--cyan-50);
border-radius: var(--radius);
border: 1px solid rgba(0,229,255,.2);
}
.norm-hint-label {
font-size: 13px;
font-weight: 700;
margin-bottom: 8px;
color: var(--ink);
}
.norm-hint-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 0;
font-size: 13px;
border-bottom: 1px solid rgba(0,0,0,.05);
}
.norm-hint-item:last-child { border-bottom: none; }
.norm-hint-item-name { font-weight: 600; }
.norm-hint-item-wear { color: var(--gray-500); font-size: 12px; white-space: nowrap; }
.norm-hint-item .btn-xs {
padding: 3px 10px;
font-size: 11px;
font-weight: 600;
border-radius: 4px;
border: none;
cursor: pointer;
background: var(--cyan);
color: var(--ink);
white-space: nowrap;
}
.norm-hint-item .btn-xs:hover { opacity: .8; }
/* Norm issue modal — employee pick list */
.norm-issue-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 40vh;
overflow-y: auto;
}
.norm-issue-emp {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 14px;
background: var(--gray-100);
border-radius: var(--radius);
font-size: 13px;
}
.norm-issue-emp-info { min-width: 0; }
.norm-issue-emp-name { font-weight: 600; }
.norm-issue-emp-meta { font-size: 12px; color: var(--gray-500); }
/* Employee name link */
.emp-name-link {
color: var(--ink) !important;
font-weight: 600;
cursor: pointer;
border-bottom: 1px dashed var(--gray-300);
transition: all .15s;
}
.emp-name-link:hover {
color: var(--cyan) !important;
border-bottom-color: var(--cyan);
}
/* Responsive */
@media (max-width: 768px) {
.header { padding: 12px 16px; }
.header h1 { font-size: 15px; }
.header .subtitle { display: none; }
.nav { padding: 0 8px; top: 46px; }
.nav button { padding: 10px 10px; font-size: 12px; }
.main { padding: 16px; }
.form-row { grid-template-columns: 1fr; }
.stats { grid-template-columns: 1fr 1fr; }
}
</style>
</head>
<body>
<header class="header">
<div>
<h1>🛡️ Учет СИЗ</h1>
<div class="subtitle">АО «Казахтелеком»</div>
</div>
<div class="header-right">
<button class="btn btn-outline btn-sm" onclick="exportAllToExcel()">📥 Экспорт в Excel</button>
<button class="btn btn-primary btn-sm" onclick="resetData()">🔄 Сброс данных</button>
</div>
</header>
<nav class="nav">
<button class="active" data-panel="tab-employees">👤 Работники</button>
<button data-panel="tab-siz">🛡️ Справочник СИЗ</button>
<button data-panel="tab-warehouse">📦 Склад</button>
<button data-panel="tab-norms">📋 Нормы выдачи</button>
<button data-panel="tab-issuance">📤 Выдача</button>
<button data-panel="tab-control">⚠️ Контроль сроков</button>
<button data-panel="tab-reports">📊 Отчеты</button>
</nav>
<div class="main">
<!-- TAB: Работники -->
<div class="panel active" id="tab-employees">
<div class="toolbar">
<input type="text" id="empSearch" placeholder="🔍 Поиск по ФИО, должности, отделу..." oninput="renderEmployees()">
<button class="btn btn-primary" onclick="openEmployeeModal()">+ Добавить</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Таб. №</th>
<th>Филиал</th>
<th>Место дислокации</th>
<th>ФИО</th>
<th>Должность</th>
<th>Подразделение</th>
<th>Дата приема</th>
<th></th>
</tr>
</thead>
<tbody id="empTable"></tbody>
</table>
</div>
<div class="empty" id="empEmpty" style="display:none;">
<div class="empty-icon">👤</div>
<p>Нет работников. Нажмите «Добавить».</p>
</div>
</div>
<!-- TAB: Справочник СИЗ -->
<div class="panel" id="tab-siz">
<div class="toolbar">
<input type="text" id="sizSearch" placeholder="🔍 Поиск по названию, типу..." oninput="renderSiz()">
<select id="sizFilterType" onchange="renderSiz()">
<option value="">Все типы</option>
<option>Головы</option><option>Глаз</option><option>Органов дыхания</option>
<option>Рук</option><option>Ног</option><option>Спецодежда</option><option>Прочее</option>
</select>
<button class="btn btn-primary" onclick="openSizModal()">+ Добавить</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Наименование</th>
<th>Тип</th>
<th>Класс защиты</th>
<th>ГОСТ/ТУ</th>
<th>Срок носки (мес)</th>
<th>Ед. изм.</th>
<th></th>
</tr>
</thead>
<tbody id="sizTable"></tbody>
</table>
</div>
<div class="empty" id="sizEmpty" style="display:none;">
<div class="empty-icon">🛡️</div>
<p>Нет позиций СИЗ. Нажмите «Добавить».</p>
</div>
</div>
<!-- TAB: Склад -->
<div class="panel" id="tab-warehouse">
<div class="toolbar">
<input type="text" id="whSearch" placeholder="🔍 Поиск..." oninput="renderWarehouse()">
<button class="btn btn-primary" onclick="openWarehouseModal()">+ Приход</button>
<button class="btn btn-outline" onclick="openWarehouseModal('out')"> Расход (списание)</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>СИЗ</th>
<th>Кол-во</th>
<th>Дата</th>
<th>Операция</th>
<th>Партия</th>
<th>Поставщик</th>
<th></th>
</tr>
</thead>
<tbody id="whTable"></tbody>
</table>
</div>
<div class="empty" id="whEmpty" style="display:none;">
<div class="empty-icon">📦</div>
<p>Нет складских операций.</p>
</div>
</div>
<!-- TAB: Нормы выдачи -->
<div class="panel" id="tab-norms">
<div class="toolbar">
<input type="text" id="normSearch" placeholder="🔍 Поиск по должности..." oninput="renderNorms()">
<select id="normFilterZone" onchange="renderNorms()">
<option value="">Все пояса</option>
<option value="0">Общий</option>
<option value="1">Пояс 1</option>
<option value="2">Пояс 2</option>
<option value="3">Пояс 3</option>
</select>
<button class="btn btn-primary" onclick="openNormModal()">+ Добавить норму</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Должность</th>
<th>Клим. пояс</th>
<th>СИЗ</th>
<th>Срок носки</th>
<th>Кол-во</th>
<th>Примечание</th>
<th></th>
</tr>
</thead>
<tbody id="normTable"></tbody>
</table>
</div>
<div class="empty" id="normEmpty" style="display:none;">
<div class="empty-icon">📋</div>
<p>Нет норм выдачи. Добавьте нормы для должностей.</p>
</div>
</div>
<!-- TAB: Выдача СИЗ -->
<div class="panel" id="tab-issuance">
<div class="toolbar">
<input type="text" id="issSearch" placeholder="🔍 Поиск..." oninput="renderIssuances()">
<select id="issFilterStatus" onchange="renderIssuances()">
<option value="">Все статусы</option>
<option value="active">Действует</option>
<option value="expired">Просрочено</option>
<option value="returned">Возвращено</option>
</select>
<button class="btn btn-primary" onclick="openIssuanceModal()">+ Выдать СИЗ</button>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Работник</th>
<th>СИЗ</th>
<th>Кол-во</th>
<th>Дата выдачи</th>
<th>Годен до</th>
<th>Статус</th>
<th></th>
</tr>
</thead>
<tbody id="issTable"></tbody>
</table>
</div>
<div class="empty" id="issEmpty" style="display:none;">
<div class="empty-icon">📤</div>
<p>Нет записей о выдаче СИЗ.</p>
</div>
</div>
<!-- TAB: Контроль сроков -->
<div class="panel" id="tab-control">
<div class="stats">
<div class="stat-card">
<div class="stat-label">Просрочено</div>
<div class="stat-value" style="color:var(--red);" id="stExpired">0</div>
<div class="stat-sub">требуют замены</div>
</div>
<div class="stat-card">
<div class="stat-label">Истекает (30 дн)</div>
<div class="stat-value" style="color:var(--orange);" id="stExpiring">0</div>
<div class="stat-sub">скоро замена</div>
</div>
<div class="stat-card">
<div class="stat-label">Действует</div>
<div class="stat-value" style="color:var(--green);" id="stActive">0</div>
<div class="stat-sub">в норме</div>
</div>
</div>
<div class="alert-list" id="alertList"></div>
</div>
<!-- TAB: Отчеты -->
<div class="panel" id="tab-reports">
<div class="stats">
<div class="stat-card">
<div class="stat-label">Работников</div>
<div class="stat-value" id="repEmployees">0</div>
</div>
<div class="stat-card">
<div class="stat-label">Позиций СИЗ</div>
<div class="stat-value" id="repSiz">0</div>
</div>
<div class="stat-card">
<div class="stat-label">На складе</div>
<div class="stat-value" id="repStock">0</div>
<div class="stat-sub">единиц</div>
</div>
<div class="stat-card">
<div class="stat-label">Выдано</div>
<div class="stat-value" id="repIssued">0</div>
<div class="stat-sub">единиц</div>
</div>
<div class="stat-card">
<div class="stat-label">Просрочено</div>
<div class="stat-value" style="color:var(--red);" id="repExpired">0</div>
</div>
</div>
<div class="table-wrap" style="margin-top:20px;">
<table>
<thead>
<tr><th>Работник</th><th>Должность</th><th>Выдано позиций</th><th>Из них просрочено</th></tr>
</thead>
<tbody id="repTable"></tbody>
</table>
</div>
</div>
</div>
<!-- EMPLOYEE MODAL -->
<div class="modal-overlay" id="employeeModal">
<div class="modal">
<h2 id="empModalTitle">Добавить работника</h2>
<div class="form-row">
<div class="form-group">
<label>Табельный номер *</label>
<input type="text" id="empTabNum" placeholder="00001">
</div>
<div class="form-group">
<label>Дата приема</label>
<input type="date" id="empDateHired">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Филиал</label>
<select id="empBranch">
<option value="">— Выберите —</option>
<option>ДКБ</option>
<option>ДРБ</option>
<option>ДТК</option>
<option>ДЦБСФ</option>
<option>ОДС</option>
</select>
</div>
<div class="form-group">
<label>Место дислокации / Населенный пункт</label>
<select id="empLocation">
<option value="">— Выберите —</option>
<option>Акмолинская область</option>
<option>г. Астана</option>
<option>г. Шымкент</option>
<option>Жамбылская область</option>
<option>Карагандинская область</option>
<option>Туркестанская область</option>
</select>
</div>
</div>
<div class="form-group">
<label>ФИО *</label>
<input type="text" id="empName" placeholder="Иванов Иван Иванович">
</div>
<div class="form-row">
<div class="form-group">
<label>Должность</label>
<select id="empPosition">
<option value="">— Выберите —</option>
<option>водитель автомобиля</option>
<option>заведующий складом</option>
<option>инженер</option>
<option>кабельщик-спайщик</option>
<option>электромеханик</option>
<option>электромонтер</option>
</select>
</div>
<div class="form-group">
<label>Подразделение</label>
<input type="text" id="empDepartment" placeholder="Техническое подразделение">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Климатический пояс</label>
<select id="empClimateZone">
<option value="0">Общий</option>
<option value="1">Пояс 1 (особый)</option>
<option value="2">Пояс 2 (холодный)</option>
<option value="3">Пояс 3 (умеренный)</option>
</select>
</div>
</div>
<input type="hidden" id="empEditId">
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeModal('employeeModal')">Отмена</button>
<button class="btn btn-primary" onclick="saveEmployee()">Сохранить</button>
</div>
</div>
</div>
<!-- SIZ MODAL -->
<div class="modal-overlay" id="sizModal">
<div class="modal">
<h2 id="sizModalTitle">Добавить СИЗ</h2>
<div class="form-group">
<label>Наименование *</label>
<input type="text" id="sizName" placeholder="Каска защитная">
</div>
<div class="form-row">
<div class="form-group">
<label>Тип</label>
<select id="sizType">
<option>Головы</option><option>Глаз</option><option>Органов дыхания</option>
<option>Рук</option><option>Ног</option><option>Спецодежда</option><option>Прочее</option>
</select>
</div>
<div class="form-group">
<label>Класс защиты</label>
<input type="text" id="sizProtection" placeholder="1 класс">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>ГОСТ / ТУ</label>
<input type="text" id="sizStandard" placeholder="ГОСТ 12.4.128-83">
</div>
<div class="form-group">
<label>Срок носки (месяцев)</label>
<input type="number" id="sizWearMonths" value="12" min="1">
</div>
</div>
<div class="form-group">
<label>Единица измерения</label>
<input type="text" id="sizUnit" value="шт.">
</div>
<input type="hidden" id="sizEditId">
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeModal('sizModal')">Отмена</button>
<button class="btn btn-primary" onclick="saveSiz()">Сохранить</button>
</div>
</div>
</div>
<!-- WAREHOUSE MODAL -->
<div class="modal-overlay" id="warehouseModal">
<div class="modal">
<h2 id="whModalTitle">Приход на склад</h2>
<div class="form-group">
<label>СИЗ *</label>
<select id="whSizId"></select>
</div>
<div class="form-row">
<div class="form-group">
<label>Количество *</label>
<input type="number" id="whQuantity" value="1" min="1">
</div>
<div class="form-group">
<label>Дата</label>
<input type="date" id="whDate">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Номер партии</label>
<input type="text" id="whBatch" placeholder="Б-2026001">
</div>
<div class="form-group">
<label>Поставщик</label>
<input type="text" id="whSupplier" placeholder="ТОО Поставщик">
</div>
</div>
<input type="hidden" id="whEditId">
<input type="hidden" id="whOpType" value="in">
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeModal('warehouseModal')">Отмена</button>
<button class="btn btn-primary" onclick="saveWarehouse()">Сохранить</button>
</div>
</div>
</div>
<!-- NORM MODAL -->
<div class="modal-overlay" id="normModal">
<div class="modal">
<h2 id="normModalTitle">Добавить норму выдачи</h2>
<div class="form-group">
<label>Должность *</label>
<select id="normPosition">
<option value="">— Выберите —</option>
<option>водитель автомобиля</option>
<option>заведующий складом</option>
<option>инженер</option>
<option>кабельщик-спайщик</option>
<option>электромеханик</option>
<option>электромонтер</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>Климатический пояс</label>
<select id="normClimateZone">
<option value="0">Общий</option>
<option value="1">Пояс 1</option>
<option value="2">Пояс 2</option>
<option value="3">Пояс 3</option>
</select>
</div>
<div class="form-group">
<label>СИЗ *</label>
<select id="normSizId"></select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Срок носки</label>
<input type="text" id="normWearDisplay" placeholder="12 мес = 1 год" oninput="parseNormWear()">
<input type="hidden" id="normWearMonths" value="12">
</div>
<div class="form-group">
<label>Количество</label>
<input type="number" id="normQuantity" value="1" min="1">
</div>
</div>
<div class="form-group">
<label>Примечание</label>
<input type="text" id="normNote" placeholder="Летний / зимний вариант...">
</div>
<input type="hidden" id="normEditId">
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeModal('normModal')">Отмена</button>
<button class="btn btn-primary" onclick="saveNorm()">Сохранить</button>
</div>
</div>
</div>
<!-- NORM ISSUE MODAL (выдать сотруднику по норме) -->
<div class="modal-overlay" id="normIssueModal">
<div class="modal">
<h2 id="normIssueTitle">Выдать СИЗ</h2>
<div id="normIssueInfo" style="font-size:13px;color:var(--gray-500);margin-bottom:16px;"></div>
<div id="normIssueList" class="norm-issue-list"></div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeModal('normIssueModal')">Отмена</button>
</div>
</div>
</div>
<!-- ISSUANCE MODAL -->
<div class="modal-overlay" id="issuanceModal">
<div class="modal">
<h2 id="issModalTitle">Выдача СИЗ</h2>
<div class="form-group">
<label>Работник *</label>
<select id="issEmployeeId"></select>
</div>
<div class="form-group">
<label>СИЗ *</label>
<select id="issSizId"></select>
</div>
<div class="form-row">
<div class="form-group">
<label>Количество</label>
<input type="number" id="issQuantity" value="1" min="1">
</div>
<div class="form-group">
<label>Дата выдачи</label>
<input type="date" id="issDate">
</div>
</div>
<div class="form-group">
<label>Примечание</label>
<textarea id="issNotes" rows="2" placeholder="Размер, рост, доп. инфо..."></textarea>
</div>
<div class="norm-hint" id="issNormHint" style="display:none;">
<div class="norm-hint-label">📋 Нормы для должности "<span id="issNormPosition"></span>":</div>
<div class="norm-hint-list" id="issNormList"></div>
</div>
<input type="hidden" id="issEditId">
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeModal('issuanceModal')">Отмена</button>
<button class="btn btn-primary" onclick="saveIssuance()">Сохранить</button>
</div>
</div>
</div>
<!-- EMPLOYEE HISTORY MODAL -->
<div class="modal-overlay" id="empHistoryModal">
<div class="modal" style="max-width:700px;">
<h2 id="empHistoryTitle">Выданные СИЗ</h2>
<div id="empHistoryMeta" style="font-size:13px;color:var(--gray-500);margin-bottom:16px;"></div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>СИЗ</th>
<th>Кол-во</th>
<th>Дата выдачи</th>
<th>Годен до</th>
<th>Статус</th>
</tr>
</thead>
<tbody id="empHistoryTable"></tbody>
</table>
</div>
<div class="empty" id="empHistoryEmpty" style="display:none;">
<p>Нет выданных СИЗ.</p>
</div>
<div class="modal-actions">
<button class="btn btn-outline" onclick="closeModal('empHistoryModal')">Закрыть</button>
</div>
</div>
</div>
<script>
// ===================== DATA LAYER =====================
const DB = {
_load(k) { try { return JSON.parse(localStorage.getItem('siz_'+k)) || []; } catch { return []; } },
_save(k, v) { localStorage.setItem('siz_'+k, JSON.stringify(v)); },
get employees() { return this._load('employees'); },
set employees(v) { this._save('employees', v); },
get siz() { return this._load('siz'); },
set siz(v) { this._save('siz', v); },
get warehouse() { return this._load('warehouse'); },
set warehouse(v) { this._save('warehouse', v); },
get issuances() { return this._load('issuances'); },
set issuances(v) { this._save('issuances', v); },
get norms() { return this._load('norms'); },
set norms(v) { this._save('norms', v); },
};
function uid() { return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); }
function fmtDate(d) {
if (!d) return '';
const dt = new Date(d);
return dt.toLocaleDateString('ru-RU');
}
function todayStr() {
return new Date().toISOString().slice(0, 10);
}
function daysBetween(d1, d2) {
return Math.floor((new Date(d2) - new Date(d1)) / (1000 * 60 * 60 * 24));
}
// Seed demo data if empty
function seedDemo() {
if (DB.employees.length > 0) return;
DB.employees = [
{ id: uid(), tabNum: '00001', fullName: 'Иванов Иван Иванович', position: 'кабельщик-спайщик', department: 'Техническое подразделение', branch: 'ОДС', location: 'г. Астана', climateZone: '1', dateHired: '2020-03-15' },
{ id: uid(), tabNum: '00002', fullName: 'Петрова Анна Сергеевна', position: 'электромонтер', department: 'Энергоцех', branch: 'ДКБ', location: 'Акмолинская область', climateZone: '2', dateHired: '2021-06-01' },
{ id: uid(), tabNum: '00003', fullName: 'Сериков Асхат Нурланович', position: 'электромонтер', department: 'Энергоцех', branch: 'ДРБ', location: 'г. Шымкент', climateZone: '3', dateHired: '2019-11-10' },
{ id: uid(), tabNum: '00004', fullName: 'Ким Елена Викторовна', position: 'инженер', department: 'Отдел качества', branch: 'ДЦБСФ', location: 'Карагандинская область', climateZone: '0', dateHired: '2022-01-20' },
{ id: uid(), tabNum: '00005', fullName: 'Нургалиев Даурен Кайратович', position: 'электромеханик', department: 'Ремонтный цех', branch: 'ДТК', location: 'Туркестанская область', climateZone: '2', dateHired: '2018-08-05' },
{ id: uid(), tabNum: '00006', fullName: 'Ахметов Тимур Болатович', position: 'кабельщик-спайщик', department: 'Линейный цех', branch: 'ОДС', location: 'Жамбылская область', climateZone: '2', dateHired: '2023-02-01' },
];
DB.siz = [
{ id: uid(), name: 'Каска защитная', type: 'Головы', protection: '1 класс', standard: 'ГОСТ 12.4.128-83', wearMonths: 24, unit: 'шт.' },
{ id: uid(), name: 'Очки защитные', type: 'Глаз', protection: '2 класс', standard: 'ГОСТ 12.4.013-97', wearMonths: 12, unit: 'шт.' },
{ id: uid(), name: 'Перчатки х/б', type: 'Рук', protection: 'Ми', standard: 'ГОСТ 12.4.010-75', wearMonths: 1, unit: 'пар' },
{ id: uid(), name: 'Респиратор', type: 'Органов дыхания', protection: 'FFP2', standard: 'ГОСТ 12.4.294-2015', wearMonths: 1, unit: 'шт.' },
{ id: uid(), name: 'Ботинки кожаные', type: 'Ног', protection: 'Мун 200', standard: 'ГОСТ 12.4.137-2001', wearMonths: 12, unit: 'пар' },
{ id: uid(), name: 'Костюм х/б', type: 'Спецодежда', protection: 'Ми', standard: 'ГОСТ 12.4.280-2014', wearMonths: 12, unit: 'компл.' },
{ id: uid(), name: 'Пояс предохранительный', type: 'Прочее', protection: '1 класс', standard: 'ГОСТ 12.4.089-86', wearMonths: 36, unit: 'шт.' },
{ id: uid(), name: 'Костюм летний х/б', type: 'Спецодежда', protection: 'Ми', standard: 'ГОСТ 12.4.280-2014', wearMonths: 12, unit: 'компл.' },
{ id: uid(), name: 'Костюм зимний утепленный', type: 'Спецодежда', protection: 'Тн', standard: 'ГОСТ 12.4.236-2011', wearMonths: 36, unit: 'компл.' },
{ id: uid(), name: 'Рукавицы брезентовые', type: 'Рук', protection: 'Ми', standard: 'ГОСТ 12.4.010-75', wearMonths: 1, unit: 'пар' },
{ id: uid(), name: 'Диэлектрические перчатки', type: 'Рук', protection: 'Эн', standard: 'ГОСТ 12.4.307-2016', wearMonths: 12, unit: 'пар' },
{ id: uid(), name: 'Диэлектрические боты', type: 'Ног', protection: 'Эн', standard: 'ГОСТ 12.4.307-2016', wearMonths: 36, unit: 'пар' },
{ id: uid(), name: 'Щиток сварочный', type: 'Глаз', protection: '3 класс', standard: 'ГОСТ 12.4.254-2013', wearMonths: 24, unit: 'шт.' },
];
DB.norms = [
{ id: uid(), position: 'кабельщик-спайщик', climateZone: '0', sizId: DB.siz[0].id, wearMonths: 24, quantity: 1, note: '' },
{ id: uid(), position: 'кабельщик-спайщик', climateZone: '0', sizId: DB.siz[2].id, wearMonths: 1, quantity: 12, note: '' },
{ id: uid(), position: 'кабельщик-спайщик', climateZone: '0', sizId: DB.siz[4].id, wearMonths: 12, quantity: 1, note: '' },
{ id: uid(), position: 'кабельщик-спайщик', climateZone: '0', sizId: DB.siz[7].id, wearMonths: 12, quantity: 1, note: 'Летний' },
{ id: uid(), position: 'кабельщик-спайщик', climateZone: '0', sizId: DB.siz[8].id, wearMonths: 36, quantity: 1, note: 'Зимний' },
{ id: uid(), position: 'электромонтер', climateZone: '0', sizId: DB.siz[0].id, wearMonths: 24, quantity: 1, note: '' },
{ id: uid(), position: 'электромонтер', climateZone: '0', sizId: DB.siz[1].id, wearMonths: 12, quantity: 1, note: '' },
{ id: uid(), position: 'электромонтер', climateZone: '0', sizId: DB.siz[4].id, wearMonths: 12, quantity: 1, note: '' },
{ id: uid(), position: 'электромонтер', climateZone: '0', sizId: DB.siz[10].id, wearMonths: 12, quantity: 1, note: '' },
{ id: uid(), position: 'электромонтер', climateZone: '0', sizId: DB.siz[11].id, wearMonths: 36, quantity: 1, note: '' },
{ id: uid(), position: 'электромонтер', climateZone: '1', sizId: DB.siz[8].id, wearMonths: 36, quantity: 1, note: 'Зимний, пояс 1: 3 года' },
{ id: uid(), position: 'электромонтер', climateZone: '2', sizId: DB.siz[8].id, wearMonths: 30, quantity: 1, note: 'Зимний, пояс 2: 2,5 года' },
{ id: uid(), position: 'электромонтер', climateZone: '3', sizId: DB.siz[8].id, wearMonths: 24, quantity: 1, note: 'Зимний, пояс 3: 2 года' },
{ id: uid(), position: 'инженер', climateZone: '0', sizId: DB.siz[0].id, wearMonths: 24, quantity: 1, note: '' },
{ id: uid(), position: 'инженер', climateZone: '0', sizId: DB.siz[1].id, wearMonths: 12, quantity: 1, note: '' },
{ id: uid(), position: 'электромеханик', climateZone: '0', sizId: DB.siz[0].id, wearMonths: 24, quantity: 1, note: '' },
{ id: uid(), position: 'электромеханик', climateZone: '0', sizId: DB.siz[4].id, wearMonths: 12, quantity: 1, note: '' },
{ id: uid(), position: 'электромеханик', climateZone: '0', sizId: DB.siz[10].id, wearMonths: 12, quantity: 1, note: '' },
];
DB.warehouse = [
{ id: uid(), sizId: DB.siz[0].id, quantity: 50, date: '2025-06-01', batch: 'Б-2025001', supplier: 'ТОО Спецзащита', opType: 'in' },
{ id: uid(), sizId: DB.siz[1].id, quantity: 100, date: '2025-06-15', batch: 'Б-2025002', supplier: 'ТОО Оптика KZ', opType: 'in' },
{ id: uid(), sizId: DB.siz[2].id, quantity: 500, date: '2025-07-01', batch: 'Б-2025003', supplier: 'ТОО Промтекстиль', opType: 'in' },
{ id: uid(), sizId: DB.siz[4].id, quantity: 80, date: '2025-05-20', batch: 'Б-2025004', supplier: 'ТОО Спецобувь', opType: 'in' },
{ id: uid(), sizId: DB.siz[5].id, quantity: 120, date: '2025-04-10', batch: 'Б-2025005', supplier: 'ТОО Спецзащита', opType: 'in' },
];
const emp1 = DB.employees[0].id, emp2 = DB.employees[1].id, emp3 = DB.employees[2].id;
DB.issuances = [
{ id: uid(), employeeId: emp1, sizId: DB.siz[0].id, quantity: 1, dateIssued: '2025-06-10', dateExpire: '2027-06-10', status: 'active', notes: '' },
{ id: uid(), employeeId: emp1, sizId: DB.siz[2].id, quantity: 5, dateIssued: '2025-12-01', dateExpire: '2026-01-01', status: 'expired', notes: 'Размер L' },
{ id: uid(), employeeId: emp2, sizId: DB.siz[0].id, quantity: 1, dateIssued: '2025-07-15', dateExpire: '2027-07-15', status: 'active', notes: '' },
{ id: uid(), employeeId: emp2, sizId: DB.siz[4].id, quantity: 1, dateIssued: '2025-03-01', dateExpire: '2026-03-01', status: 'expired', notes: 'Размер 42' },
{ id: uid(), employeeId: emp3, sizId: DB.siz[1].id, quantity: 1, dateIssued: '2025-08-20', dateExpire: '2026-03-20', status: 'active', notes: '' },
{ id: uid(), employeeId: emp3, sizId: DB.siz[3].id, quantity: 20, dateIssued: '2025-12-01', dateExpire: '2026-01-01', status: 'expired', notes: '' },
{ id: uid(), employeeId: emp4, sizId: DB.siz[5].id, quantity: 1, dateIssued: '2025-09-01', dateExpire: '2026-09-01', status: 'active', notes: 'Размер 52-54' },
];
}
// ===================== NAVIGATION =====================
document.querySelectorAll('.nav button').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.nav button').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
document.getElementById(btn.dataset.panel).classList.add('active');
if (btn.dataset.panel === 'tab-reports') renderReports();
if (btn.dataset.panel === 'tab-control') renderControl();
if (btn.dataset.panel === 'tab-norms') renderNorms();
});
});
// ===================== MODALS =====================
function openModal(id) { document.getElementById(id).classList.add('show'); }
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
document.querySelectorAll('.modal-overlay').forEach(ov => {
ov.addEventListener('click', (e) => { if (e.target === ov) ov.classList.remove('show'); });
});
// ===================== EMPLOYEES =====================
function openEmployeeModal(emp) {
document.getElementById('empEditId').value = emp ? emp.id : '';
document.getElementById('empModalTitle').textContent = emp ? 'Редактировать работника' : 'Добавить работника';
document.getElementById('empTabNum').value = emp ? emp.tabNum : '';
document.getElementById('empName').value = emp ? emp.fullName : '';
document.getElementById('empPosition').value = emp ? emp.position : '';
document.getElementById('empDepartment').value = emp ? emp.department : '';
document.getElementById('empDateHired').value = emp ? emp.dateHired : '';
document.getElementById('empBranch').value = emp ? (emp.branch || '') : '';
document.getElementById('empLocation').value = emp ? (emp.location || '') : '';
document.getElementById('empClimateZone').value = emp ? (emp.climateZone || '0') : '0';
openModal('employeeModal');
}
function saveEmployee() {
const name = document.getElementById('empName').value.trim();
const tabNum = document.getElementById('empTabNum').value.trim();
if (!name || !tabNum) return alert('Заполните ФИО и табельный номер');
const data = {
id: document.getElementById('empEditId').value || uid(),
tabNum: tabNum,
fullName: name,
position: document.getElementById('empPosition').value.trim(),
department: document.getElementById('empDepartment').value.trim(),
branch: document.getElementById('empBranch').value.trim(),
location: document.getElementById('empLocation').value.trim(),
climateZone: document.getElementById('empClimateZone').value || '0',
dateHired: document.getElementById('empDateHired').value,
};
let list = DB.employees;
const idx = list.findIndex(e => e.id === data.id);
if (idx >= 0) list[idx] = data; else list.push(data);
DB.employees = list;
closeModal('employeeModal');
renderEmployees();
}
function deleteEmployee(id) {
if (!confirm('Удалить работника? Связанные выдачи СИЗ также будут удалены.')) return;
DB.employees = DB.employees.filter(e => e.id !== id);
DB.issuances = DB.issuances.filter(i => i.employeeId !== id);
renderEmployees();
renderIssuances();
}
function showEmpHistory(empId) {
const emp = DB.employees.find(e => e.id === empId);
if (!emp) return;
document.getElementById('empHistoryTitle').textContent = 'Выданные СИЗ';
document.getElementById('empHistoryMeta').innerHTML = `<strong>${emp.fullName}</strong> — ${emp.position}${emp.department}${emp.branch || ''}`;
const issued = DB.issuances.filter(i => i.employeeId === empId);
document.getElementById('empHistoryTable').innerHTML = issued.length
? issued.map(i => {
const siz = DB.siz.find(s => s.id === i.sizId);
const st = getIssStatus(i);
return `<tr>
<td><strong>${getSizName(i.sizId)}</strong></td>
<td>${i.quantity} ${siz ? siz.unit : ''}</td>
<td>${fmtDate(i.dateIssued)}</td>
<td>${fmtDate(i.dateExpire)}</td>
<td>${statusBadge(st)}</td>
</tr>`;
}).join('')
: '';
document.getElementById('empHistoryEmpty').style.display = issued.length ? 'none' : 'block';
openModal('empHistoryModal');
}
function renderEmployees() {
const q = (document.getElementById('empSearch').value || '').toLowerCase();
let list = DB.employees;
if (q) list = list.filter(e =>
(e.fullName || '').toLowerCase().includes(q) ||
(e.position || '').toLowerCase().includes(q) ||
(e.department || '').toLowerCase().includes(q) ||
(e.tabNum || '').toLowerCase().includes(q) ||
(e.branch || '').toLowerCase().includes(q) ||
(e.location || '').toLowerCase().includes(q)
);
const tbody = document.getElementById('empTable');
tbody.innerHTML = list.map(e => `<tr>
<td>${e.tabNum}</td>
<td>${e.branch || '—'}</td>
<td>${e.location || '—'}</td>
<td><strong>${e.fullName}</strong></td>
<td>${e.position}</td>
<td>${e.department}</td>
<td>${fmtDate(e.dateHired)}</td>
<td>
<button class="action-btn" title="Редактировать" onclick="openEmployeeModal(DB.employees.find(x=>x.id==='${e.id}'))">✏️</button>
<button class="action-btn" title="Удалить" onclick="deleteEmployee('${e.id}')">🗑️</button>
</td>
</tr>`).join('');
document.getElementById('empEmpty').style.display = list.length ? 'none' : 'block';
}
// ===================== SIZ CATALOG =====================
function openSizModal(siz) {
document.getElementById('sizEditId').value = siz ? siz.id : '';
document.getElementById('sizModalTitle').textContent = siz ? 'Редактировать СИЗ' : 'Добавить СИЗ';
document.getElementById('sizName').value = siz ? siz.name : '';
document.getElementById('sizType').value = siz ? siz.type : 'Головы';
document.getElementById('sizProtection').value = siz ? siz.protection : '';
document.getElementById('sizStandard').value = siz ? siz.standard : '';
document.getElementById('sizWearMonths').value = siz ? siz.wearMonths : '12';
document.getElementById('sizUnit').value = siz ? siz.unit : 'шт.';
openModal('sizModal');
}
function saveSiz() {
const name = document.getElementById('sizName').value.trim();
if (!name) return alert('Введите наименование');
const data = {
id: document.getElementById('sizEditId').value || uid(),
name,
type: document.getElementById('sizType').value,
protection: document.getElementById('sizProtection').value.trim(),
standard: document.getElementById('sizStandard').value.trim(),
wearMonths: parseInt(document.getElementById('sizWearMonths').value) || 12,
unit: document.getElementById('sizUnit').value.trim() || 'шт.',
};
let list = DB.siz;
const idx = list.findIndex(e => e.id === data.id);
if (idx >= 0) list[idx] = data; else list.push(data);
DB.siz = list;
closeModal('sizModal');
renderSiz();
}
function deleteSiz(id) {
if (!confirm('Удалить позицию СИЗ? Связанные складские записи и выдачи также будут удалены.')) return;
DB.siz = DB.siz.filter(e => e.id !== id);
DB.warehouse = DB.warehouse.filter(w => w.sizId !== id);
DB.issuances = DB.issuances.filter(i => i.sizId !== id);
renderSiz();
}
function renderSiz() {
const q = (document.getElementById('sizSearch').value || '').toLowerCase();
const ft = document.getElementById('sizFilterType').value;
let list = DB.siz;
if (q) list = list.filter(e => e.name.toLowerCase().includes(q) || e.type.toLowerCase().includes(q));
if (ft) list = list.filter(e => e.type === ft);
document.getElementById('sizTable').innerHTML = list.map(e => `<tr>
<td><strong>${e.name}</strong></td>
<td>${e.type}</td>
<td>${e.protection}</td>
<td>${e.standard}</td>
<td>${e.wearMonths}</td>
<td>${e.unit}</td>
<td>
<button class="action-btn" title="Редактировать" onclick="openSizModal(DB.siz.find(x=>x.id==='${e.id}'))">✏️</button>
<button class="action-btn" title="Удалить" onclick="deleteSiz('${e.id}')">🗑️</button>
</td>
</tr>`).join('');
document.getElementById('sizEmpty').style.display = list.length ? 'none' : 'block';
}
// ===================== WAREHOUSE =====================
function fillSizSelect(selectId, emptyOption) {
const sel = document.getElementById(selectId);
sel.innerHTML = (emptyOption ? `<option value="">${emptyOption}</option>` : '') +
DB.siz.map(s => `<option value="${s.id}">${s.name} (${s.unit})</option>`).join('');
}
function openWarehouseModal(opType) {
opType = opType || 'in';
document.getElementById('whEditId').value = '';
document.getElementById('whOpType').value = opType;
document.getElementById('whModalTitle').textContent = opType === 'in' ? 'Приход на склад' : 'Расход (списание)';
document.getElementById('whQuantity').value = '1';
document.getElementById('whDate').value = todayStr();
document.getElementById('whBatch').value = '';
document.getElementById('whSupplier').value = '';
fillSizSelect('whSizId', '— Выберите СИЗ —');
openModal('warehouseModal');
}
function saveWarehouse() {
const sizId = document.getElementById('whSizId').value;
if (!sizId) return alert('Выберите СИЗ');
const qty = parseInt(document.getElementById('whQuantity').value) || 0;
if (qty <= 0) return alert('Количество должно быть больше 0');
const opType = document.getElementById('whOpType').value;
const data = {
id: document.getElementById('whEditId').value || uid(),
sizId,
quantity: opType === 'in' ? qty : -qty,
date: document.getElementById('whDate').value || todayStr(),
batch: document.getElementById('whBatch').value.trim(),
supplier: document.getElementById('whSupplier').value.trim(),
opType,
};
let list = DB.warehouse;
const idx = list.findIndex(e => e.id === data.id);
if (idx >= 0) list[idx] = data; else list.push(data);
DB.warehouse = list;
closeModal('warehouseModal');
renderWarehouse();
}
function deleteWarehouse(id) {
if (!confirm('Удалить складскую запись?')) return;
DB.warehouse = DB.warehouse.filter(w => w.id !== id);
renderWarehouse();
}
function getSizName(id) {
const s = DB.siz.find(x => x.id === id);
return s ? s.name : '—';
}
function getStockBalance(sizId) {
return DB.warehouse.filter(w => w.sizId === sizId).reduce((sum, w) => sum + w.quantity, 0);
}
function renderWarehouse() {
const q = (document.getElementById('whSearch').value || '').toLowerCase();
let list = DB.warehouse.slice().reverse();
if (q) list = list.filter(w =>
getSizName(w.sizId).toLowerCase().includes(q) ||
(w.batch || '').toLowerCase().includes(q) ||
(w.supplier || '').toLowerCase().includes(q)
);
document.getElementById('whTable').innerHTML = list.map(w => {
const sizInfo = DB.siz.find(s => s.id === w.sizId);
return `<tr>
<td><strong>${getSizName(w.sizId)}</strong></td>
<td style="color:${w.quantity >= 0 ? 'var(--green)' : 'var(--red)'}; font-weight:700;">${w.quantity > 0 ? '+' : ''}${w.quantity}</td>
<td>${fmtDate(w.date)}</td>
<td>${w.opType === 'in' ? '📥 Приход' : '📤 Расход'}</td>
<td>${w.batch || '—'}</td>
<td>${w.supplier || '—'}</td>
<td>
<button class="action-btn" title="Удалить" onclick="deleteWarehouse('${w.id}')">🗑️</button>
</td>
</tr>`;
}).join('');
document.getElementById('whEmpty').style.display = list.length ? 'none' : 'block';
}
// ===================== NORMS =====================
function fillNormSizSelect() {
const sel = document.getElementById('normSizId');
sel.innerHTML = '<option value="">— Выберите СИЗ —</option>' +
DB.siz.map(s => `<option value="${s.id}">${s.name} (${s.wearMonths} мес)</option>`).join('');
}
function openNormModal(norm) {
document.getElementById('normEditId').value = norm ? norm.id : '';
document.getElementById('normModalTitle').textContent = norm ? 'Редактировать норму' : 'Добавить норму выдачи';
document.getElementById('normPosition').value = norm ? norm.position : '';
document.getElementById('normClimateZone').value = norm ? norm.climateZone : '0';
document.getElementById('normWearMonths').value = norm ? norm.wearMonths : '12';
document.getElementById('normWearDisplay').value = norm ? monthsToText(norm.wearMonths) : '12 мес = 1 год';
document.getElementById('normQuantity').value = norm ? norm.quantity : '1';
document.getElementById('normNote').value = norm ? norm.note : '';
fillNormSizSelect();
if (norm) document.getElementById('normSizId').value = norm.sizId;
openModal('normModal');
}
function monthsToText(m) {
if (m <= 0) return '1 мес';
if (m < 12) return m + ' мес';
const y = Math.floor(m / 12);
const r = m % 12;
let s = y + ' год';
if (y > 1 && y < 5) s = y + ' года';
if (y >= 5) s = y + ' лет';
if (r > 0) s += ' ' + r + ' мес';
return s + ' (' + m + ' мес)';
}
function parseNormWear() {
const val = document.getElementById('normWearDisplay').value.trim().toLowerCase();
let months = 12;
const yearMatch = val.match(/(\d+)\s*(год|года|лет)/);
const monthMatch = val.match(/(\d+)\s*мес/);
if (yearMatch) months += parseInt(yearMatch[1]) * 12;
else if (monthMatch) months = parseInt(monthMatch[1]);
document.getElementById('normWearMonths').value = months;
}
function saveNorm() {
const pos = document.getElementById('normPosition').value.trim();
const sizId = document.getElementById('normSizId').value;
if (!pos || !sizId) return alert('Заполните должность и выберите СИЗ');
parseNormWear();
const data = {
id: document.getElementById('normEditId').value || uid(),
position: pos,
climateZone: document.getElementById('normClimateZone').value || '0',
sizId,
wearMonths: parseInt(document.getElementById('normWearMonths').value) || 12,
quantity: parseInt(document.getElementById('normQuantity').value) || 1,
note: document.getElementById('normNote').value.trim(),
};
let list = DB.norms;
const idx = list.findIndex(e => e.id === data.id);
if (idx >= 0) list[idx] = data; else list.push(data);
DB.norms = list;
closeModal('normModal');
renderNorms();
}
function deleteNorm(id) {
if (!confirm('Удалить норму выдачи?')) return;
DB.norms = DB.norms.filter(n => n.id !== id);
renderNorms();
}
function climateZoneLabel(z) {
const m = { '0': 'Общий', '1': 'Пояс 1', '2': 'Пояс 2', '3': 'Пояс 3' };
return m[z] || 'Общий';
}
function renderNorms() {
const q = (document.getElementById('normSearch').value || '').toLowerCase();
const fz = document.getElementById('normFilterZone').value;
let list = DB.norms;
if (q) list = list.filter(n => n.position.toLowerCase().includes(q) || getSizName(n.sizId).toLowerCase().includes(q));
if (fz !== '') list = list.filter(n => n.climateZone === fz);
document.getElementById('normTable').innerHTML = list.map(n => `<tr>
<td><strong>${n.position}</strong></td>
<td>${climateZoneLabel(n.climateZone)}</td>
<td>${getSizName(n.sizId)}</td>
<td>${monthsToText(n.wearMonths)}</td>
<td>${n.quantity} ${(DB.siz.find(s=>s.id===n.sizId)||{}).unit||'шт.'}</td>
<td>${n.note || '—'}</td>
<td>
<button class="btn btn-success" style="padding:4px 10px;font-size:12px;margin-right:4px;" onclick="normPickEmployee('${n.id}')" title="Выдать">📤 Выдать</button>
<button class="action-btn" title="Редактировать" onclick="openNormModal(DB.norms.find(x=>x.id==='${n.id}'))">✏️</button>
<button class="action-btn" title="Удалить" onclick="deleteNorm('${n.id}')">🗑️</button>
</td>
</tr>`).join('');
document.getElementById('normEmpty').style.display = list.length ? 'none' : 'block';
}
function normPickEmployee(normId) {
const norm = DB.norms.find(n => n.id === normId);
if (!norm) return;
const siz = DB.siz.find(s => s.id === norm.sizId);
const sizName = siz ? siz.name : '—';
const unit = siz ? siz.unit : 'шт.';
const zone = norm.climateZone || '0';
document.getElementById('normIssueTitle').textContent = 'Выдать СИЗ';
document.getElementById('normIssueInfo').innerHTML = `<strong>${sizName}</strong> × ${norm.quantity} ${unit}${monthsToText(norm.wearMonths)}<br>Должность: ${norm.position}, ${climateZoneLabel(zone)}`;
let employees = DB.employees;
if (zone !== '0') {
employees = employees.filter(e => e.position === norm.position && (e.climateZone || '0') === zone);
if (employees.length === 0) employees = DB.employees.filter(e => e.position === norm.position && (e.climateZone || '0') === '0');
if (employees.length === 0) employees = DB.employees.filter(e => e.position === norm.position);
} else {
employees = DB.employees.filter(e => e.position === norm.position);
}
document.getElementById('normIssueList').innerHTML = employees.length
? employees.map(e => `<div class="norm-issue-emp">
<div class="norm-issue-emp-info">
<div class="norm-issue-emp-name">${e.fullName}</div>
<div class="norm-issue-emp-meta">Таб. № ${e.tabNum}${e.department}${climateZoneLabel(e.climateZone||'0')}</div>
</div>
<button class="btn btn-xs" style="background:var(--cyan);color:var(--ink);padding:6px 14px;border:none;border-radius:6px;cursor:pointer;font-weight:600;" onclick="normDoIssue('${norm.id}','${e.id}')">Выдать</button>
</div>`).join('')
: `<div class="empty"><p>Нет работников с должностью «${norm.position}»${zone!=='0'?' в поясе '+climateZoneLabel(zone):''}.</p></div>`;
openModal('normIssueModal');
}
function normDoIssue(normId, empId) {
const norm = DB.norms.find(n => n.id === normId);
if (!norm) return;
const dateIssued = todayStr();
const expireDate = new Date(dateIssued);
expireDate.setMonth(expireDate.getMonth() + norm.wearMonths);
const dateExpire = expireDate.toISOString().slice(0, 10);
let status = dateExpire < todayStr() ? 'expired' : 'active';
DB.issuances = [...DB.issuances, {
id: uid(),
employeeId: empId,
sizId: norm.sizId,
quantity: norm.quantity,
dateIssued,
dateExpire,
status,
notes: norm.note || '',
}];
closeModal('normIssueModal');
renderIssuances();
}
// ===================== ISSUANCE — SHOW NORMS =====================
function showNormsForEmployee() {
const empId = document.getElementById('issEmployeeId').value;
const hint = document.getElementById('issNormHint');
const listEl = document.getElementById('issNormList');
const posEl = document.getElementById('issNormPosition');
if (!empId) { hint.style.display = 'none'; return; }
const emp = DB.employees.find(e => e.id === empId);
if (!emp) { hint.style.display = 'none'; return; }
posEl.textContent = emp.position;
const empZone = emp.climateZone || '0';
let matched = DB.norms.filter(n => n.position === emp.position && n.climateZone === empZone);
if (matched.length === 0) matched = DB.norms.filter(n => n.position === emp.position && n.climateZone === '0');
if (matched.length === 0) { hint.style.display = 'none'; return; }
listEl.innerHTML = matched.map(n => {
const siz = DB.siz.find(s => s.id === n.sizId);
const sizName = siz ? siz.name : '—';
const unit = siz ? siz.unit : 'шт.';
return `<div class="norm-hint-item">
<div>
<span class="norm-hint-item-name">${sizName}</span>
<span class="norm-hint-item-wear">— ${monthsToText(n.wearMonths)}${n.note ? ' • ' + n.note : ''}</span>
</div>
<button class="btn-xs" onclick="quickIssue('${n.sizId}',${n.wearMonths},${n.quantity},'${emp.id}',document.getElementById('issDate').value||todayStr())">Выдать</button>
</div>`;
}).join('');
hint.style.display = 'block';
}
function quickIssue(sizId, wearMonths, quantity, empId, dateIssued) {
const expireDate = new Date(dateIssued);
expireDate.setMonth(expireDate.getMonth() + wearMonths);
const dateExpire = expireDate.toISOString().slice(0, 10);
const today = todayStr();
let status = dateExpire < today ? 'expired' : 'active';
const data = {
id: uid(),
employeeId: empId,
sizId,
quantity,
dateIssued,
dateExpire,
status,
notes: '',
};
DB.issuances = [...DB.issuances, data];
closeModal('issuanceModal');
renderIssuances();
}
// ===== hook employee select change =====
const _origFillEmployeeSelect = fillEmployeeSelect;
fillEmployeeSelect = function() {
const sel = document.getElementById('issEmployeeId');
const v = sel.value;
sel.innerHTML = '<option value="">— Выберите —</option>' +
DB.employees.map(e => `<option value="${e.id}">${e.fullName} (${e.tabNum})</option>`).join('');
if (v) sel.value = v;
};
document.getElementById('issEmployeeId').addEventListener('change', showNormsForEmployee);
function fillEmployeeSelect() {
const sel = document.getElementById('issEmployeeId');
sel.innerHTML = '<option value="">— Выберите —</option>' +
DB.employees.map(e => `<option value="${e.id}">${e.fullName} (${e.tabNum})</option>`).join('');
}
function openIssuanceModal(iss) {
document.getElementById('issEditId').value = iss ? iss.id : '';
document.getElementById('issModalTitle').textContent = iss ? 'Редактировать выдачу' : 'Выдача СИЗ';
document.getElementById('issQuantity').value = iss ? iss.quantity : '1';
document.getElementById('issDate').value = iss ? iss.dateIssued : todayStr();
document.getElementById('issNotes').value = iss ? iss.notes : '';
document.getElementById('issNormHint').style.display = 'none';
fillEmployeeSelect();
fillSizSelect('issSizId', '— Выберите СИЗ —');
if (iss) {
document.getElementById('issEmployeeId').value = iss.employeeId;
document.getElementById('issSizId').value = iss.sizId;
showNormsForEmployee();
}
openModal('issuanceModal');
}
function saveIssuance() {
const empId = document.getElementById('issEmployeeId').value;
const sizId = document.getElementById('issSizId').value;
if (!empId || !sizId) return alert('Выберите работника и СИЗ');
const siz = DB.siz.find(s => s.id === sizId);
const wearMonths = siz ? siz.wearMonths : 12;
const dateIssued = document.getElementById('issDate').value || todayStr();
const expireDate = new Date(dateIssued);
expireDate.setMonth(expireDate.getMonth() + wearMonths);
const dateExpire = expireDate.toISOString().slice(0, 10);
const today = todayStr();
let status = 'active';
if (dateExpire < today) status = 'expired';
const data = {
id: document.getElementById('issEditId').value || uid(),
employeeId: empId,
sizId,
quantity: parseInt(document.getElementById('issQuantity').value) || 1,
dateIssued,
dateExpire,
status,
notes: document.getElementById('issNotes').value.trim(),
};
let list = DB.issuances;
const idx = list.findIndex(e => e.id === data.id);
if (idx >= 0) list[idx] = data; else list.push(data);
DB.issuances = list;
closeModal('issuanceModal');
renderIssuances();
}
function deleteIssuance(id) {
if (!confirm('Удалить запись о выдаче?')) return;
DB.issuances = DB.issuances.filter(i => i.id !== id);
renderIssuances();
}
function getEmpName(id) {
const e = DB.employees.find(x => x.id === id);
return e ? e.fullName : '—';
}
function getIssStatus(iss) {
if (iss.status === 'returned') return 'returned';
const today = todayStr();
if (iss.dateExpire < today) {
const list = DB.issuances;
const idx = list.findIndex(i => i.id === iss.id);
if (idx >= 0 && list[idx].status !== 'expired') { list[idx].status = 'expired'; DB.issuances = list; }
return 'expired';
}
const thirtyDays = new Date(today);
thirtyDays.setDate(thirtyDays.getDate() + 30);
if (iss.dateExpire <= thirtyDays.toISOString().slice(0, 10)) return 'expiring';
return 'active';
}
function statusBadge(status) {
if (status === 'expired') return '<span class="badge badge-red">Просрочено</span>';
if (status === 'expiring') return '<span class="badge badge-orange">Истекает</span>';
if (status === 'returned') return '<span class="badge badge-gray">Возвращено</span>';
return '<span class="badge badge-green">Действует</span>';
}
function returnIssuance(id) {
let list = DB.issuances;
const idx = list.findIndex(i => i.id === id);
if (idx >= 0) { list[idx].status = 'returned'; DB.issuances = list; }
renderIssuances();
}
function renderIssuances() {
const q = (document.getElementById('issSearch').value || '').toLowerCase();
const fs = document.getElementById('issFilterStatus').value;
let list = DB.issuances.slice().reverse();
list.forEach(iss => { iss._status = getIssStatus(iss); });
if (q) list = list.filter(i =>
getEmpName(i.employeeId).toLowerCase().includes(q) ||
getSizName(i.sizId).toLowerCase().includes(q) ||
(i.notes || '').toLowerCase().includes(q)
);
if (fs) list = list.filter(i => i._status === fs);
document.getElementById('issTable').innerHTML = list.map(i => {
const siz = DB.siz.find(s => s.id === i.sizId);
return `<tr>
<td><strong>${getEmpName(i.employeeId)}</strong></td>
<td>${getSizName(i.sizId)}</td>
<td>${i.quantity} ${siz ? siz.unit : ''}</td>
<td>${fmtDate(i.dateIssued)}</td>
<td>${fmtDate(i.dateExpire)}</td>
<td>${statusBadge(i._status)}</td>
<td>
<button class="action-btn" title="Редактировать" onclick="openIssuanceModal(DB.issuances.find(x=>x.id==='${i.id}'))">✏️</button>
${i._status !== 'returned' ? `<button class="action-btn" title="Возврат" onclick="returnIssuance('${i.id}')">↩️</button>` : ''}
<button class="action-btn" title="Удалить" onclick="deleteIssuance('${i.id}')">🗑️</button>
</td>
</tr>`;
}).join('');
document.getElementById('issEmpty').style.display = list.length ? 'none' : 'block';
}
// ===================== CONTROL =====================
function renderControl() {
let expired = 0, expiring = 0, active = 0;
const alerts = [];
DB.issuances.forEach(i => {
const st = getIssStatus(i);
if (st === 'expired') { expired++; }
else if (st === 'expiring') { expiring++; }
else if (st === 'active') { active++; }
if (st === 'expired' || st === 'expiring') {
alerts.push({
employee: getEmpName(i.employeeId),
siz: getSizName(i.sizId),
expire: i.dateExpire,
status: st,
days: daysBetween(todayStr(), i.dateExpire),
});
}
});
document.getElementById('stExpired').textContent = expired;
document.getElementById('stExpiring').textContent = expiring;
document.getElementById('stActive').textContent = active;
alerts.sort((a, b) => a.days - b.days);
document.getElementById('alertList').innerHTML = alerts.map(a =>
`<div class="alert-item ${a.status === 'expired' ? 'red' : 'orange'}">
<span style="font-size:20px;">${a.status === 'expired' ? '🔴' : '🟡'}</span>
<div>
<strong>${a.employee}</strong> — ${a.siz}<br>
<small>Срок истёк: ${fmtDate(a.expire)} (${a.days >= 0 ? 'просрочено на ' + a.days + ' дн.' : 'осталось ' + Math.abs(a.days) + ' дн.'})</small>
</div>
</div>`
).join('') || '<div class="empty"><p>✅ Все сроки в норме.</p></div>';
}
// ===================== REPORTS =====================
function renderReports() {
const employees = DB.employees;
const sizList = DB.siz;
const warehouse = DB.warehouse;
const issuances = DB.issuances;
document.getElementById('repEmployees').textContent = employees.length;
document.getElementById('repSiz').textContent = sizList.length;
let totalStock = 0, totalIssued = 0, totalExpired = 0;
sizList.forEach(s => {
totalStock += getStockBalance(s.id);
});
issuances.forEach(i => {
if (i.status !== 'returned') {
totalIssued += i.quantity;
if (getIssStatus(i) === 'expired') totalExpired += i.quantity;
} else {
totalIssued += i.quantity;
}
});
document.getElementById('repStock').textContent = totalStock;
document.getElementById('repIssued').textContent = totalIssued;
document.getElementById('repExpired').textContent = totalExpired;
document.getElementById('repTable').innerHTML = employees.map(e => {
const empIss = issuances.filter(i => i.employeeId === e.id);
const issuedCount = empIss.filter(i => i.status !== 'returned').length;
const expiredCount = empIss.filter(i => getIssStatus(i) === 'expired').length;
return `<tr>
<td><span class="emp-name-link" onclick="showEmpHistory('${e.id}')">${e.fullName}</span></td>
<td>${e.position}</td>
<td>${issuedCount}</td>
<td>${expiredCount ? '<span class="badge badge-red">' + expiredCount + '</span>' : '<span class="badge badge-green">0</span>'}</td>
</tr>`;
}).join('');
}
// ===================== EXCEL EXPORT =====================
function exportAllToExcel() {
const wb = XLSX.utils.book_new();
const emps = DB.employees.map(e => ({
'Таб. №': e.tabNum,
'Филиал': e.branch,
'Место дислокации': e.location,
'ФИО': e.fullName,
'Должность': e.position,
'Подразделение': e.department,
'Дата приема': fmtDate(e.dateHired),
'Клим. пояс': climateZoneLabel(e.climateZone||'0'),
}));
const ws1 = XLSX.utils.json_to_sheet(emps);
XLSX.utils.book_append_sheet(wb, ws1, 'Работники');
const siz = DB.siz.map(s => ({
'Наименование': s.name,
'Тип': s.type,
'Класс защиты': s.protection,
'ГОСТ/ТУ': s.standard,
'Срок носки (мес)': s.wearMonths,
'Ед. изм.': s.unit,
'Остаток на складе': getStockBalance(s.id),
}));
const ws2 = XLSX.utils.json_to_sheet(siz);
XLSX.utils.book_append_sheet(wb, ws2, 'Справочник СИЗ');
const wh = DB.warehouse.map(w => ({
'СИЗ': getSizName(w.sizId),
'Количество': w.quantity,
'Дата': fmtDate(w.date),
'Операция': w.opType === 'in' ? 'Приход' : 'Расход',
'Партия': w.batch,
'Поставщик': w.supplier,
}));
const ws3 = XLSX.utils.json_to_sheet(wh);
XLSX.utils.book_append_sheet(wb, ws3, 'Склад');
const iss = DB.issuances.map(i => {
const st = getIssStatus(i);
const siz = DB.siz.find(s => s.id === i.sizId);
return {
'Работник': getEmpName(i.employeeId),
'СИЗ': getSizName(i.sizId),
'Количество': i.quantity + ' ' + (siz ? siz.unit : ''),
'Дата выдачи': fmtDate(i.dateIssued),
'Годен до': fmtDate(i.dateExpire),
'Статус': st === 'expired' ? 'Просрочено' : st === 'expiring' ? 'Истекает' : st === 'returned' ? 'Возвращено' : 'Действует',
'Примечание': i.notes,
};
});
const ws4 = XLSX.utils.json_to_sheet(iss);
XLSX.utils.book_append_sheet(wb, ws4, 'Выдача СИЗ');
const norms = DB.norms.map(n => ({
'Должность': n.position,
'Климатический пояс': climateZoneLabel(n.climateZone),
'СИЗ': getSizName(n.sizId),
'Срок носки': monthsToText(n.wearMonths),
'Количество': n.quantity,
'Примечание': n.note,
}));
const ws5 = XLSX.utils.json_to_sheet(norms);
XLSX.utils.book_append_sheet(wb, ws5, 'Нормы выдачи');
XLSX.writeFile(wb, 'Учет_СИЗ_' + new Date().toISOString().slice(0, 10) + '.xlsx');
}
// ===================== RESET =====================
function resetData() {
if (!confirm('Сбросить все данные к демо-версии? Текущие данные будут потеряны.')) return;
localStorage.clear();
seedDemo();
location.reload();
}
// ===================== INIT =====================
seedDemo();
renderEmployees();
renderSiz();
renderWarehouse();
renderNorms();
renderIssuances();
renderControl();
renderReports();
</script>
</body>
</html>