uchet-siz/index.html

1306 lines
46 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); }
/* 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-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>
</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-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-group">
<label>ФИО *</label>
<input type="text" id="empName" placeholder="Иванов Иван Иванович">
</div>
<div class="form-row">
<div class="form-group">
<label>Должность</label>
<input type="text" id="empPosition" placeholder="Инженер">
</div>
<div class="form-group">
<label>Отдел</label>
<input type="text" id="empDepartment" placeholder="Технический отдел">
</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>
<!-- 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>
<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>
<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); },
};
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: 'Технический отдел', dateHired: '2020-03-15' },
{ id: uid(), tabNum: '00002', fullName: 'Петрова Анна Сергеевна', position: 'Монтажник', department: 'Линейный цех', dateHired: '2021-06-01' },
{ id: uid(), tabNum: '00003', fullName: 'Сериков Асхат Нурланович', position: 'Электрик', department: 'Энергоцех', dateHired: '2019-11-10' },
{ id: uid(), tabNum: '00004', fullName: 'Ким Елена Викторовна', position: 'Техник', department: 'Технический отдел', dateHired: '2022-01-20' },
{ id: uid(), tabNum: '00005', fullName: 'Нургалиев Даурен Кайратович', position: 'Сварщик', department: 'Ремонтный цех', dateHired: '2018-08-05' },
];
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: 'шт.' },
];
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();
});
});
// ===================== 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 : '';
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(),
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 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)
);
const tbody = document.getElementById('empTable');
tbody.innerHTML = list.map(e => `<tr>
<td>${e.tabNum}</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';
}
// ===================== ISSUANCES =====================
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 : '';
fillEmployeeSelect();
fillSizSelect('issSizId', '— Выберите СИЗ —');
if (iss) {
document.getElementById('issEmployeeId').value = iss.employeeId;
document.getElementById('issSizId').value = iss.sizId;
}
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><strong>${e.fullName}</strong></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.fullName,
'Должность': e.position,
'Отдел': e.department,
'Дата приема': fmtDate(e.dateHired),
}));
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, 'Выдача СИЗ');
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();
renderIssuances();
renderControl();
renderReports();
</script>
</body>
</html>