v2: Нормы выдачи СИЗ по должностям + климатические пояса + быстрый подбор при выдаче

This commit is contained in:
aliya_kairzhanova 2026-06-03 12:09:13 +00:00
parent 9b15463ba2
commit 6a6512db49

View File

@ -364,6 +364,45 @@ tr:hover td { background: var(--cyan-50); }
}
.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; }
/* Responsive */
@media (max-width: 768px) {
.header { padding: 12px 16px; }
@ -394,6 +433,7 @@ tr:hover td { background: var(--cyan-50); }
<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>
@ -490,6 +530,41 @@ tr:hover td { background: var(--cyan-50); }
</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">
@ -612,6 +687,17 @@ tr:hover td { background: var(--cyan-50); }
<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>
@ -700,6 +786,53 @@ tr:hover td { background: var(--cyan-50); }
</div>
</div>
<!-- NORM MODAL -->
<div class="modal-overlay" id="normModal">
<div class="modal">
<h2 id="normModalTitle">Добавить норму выдачи</h2>
<div class="form-group">
<label>Должность *</label>
<input type="text" id="normPosition" list="positionsList" placeholder="Кабельщик-спайщик" autocomplete="off">
<datalist id="positionsList"></datalist>
</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>
<!-- ISSUANCE MODAL -->
<div class="modal-overlay" id="issuanceModal">
<div class="modal">
@ -726,6 +859,10 @@ tr:hover td { background: var(--cyan-50); }
<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>
@ -747,6 +884,8 @@ const DB = {
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); }
@ -769,11 +908,12 @@ function daysBetween(d1, d2) {
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' },
{ id: uid(), tabNum: '00001', fullName: 'Иванов Иван Иванович', position: 'Кабельщик-спайщик', department: 'Линейный цех', climateZone: '1', dateHired: '2020-03-15' },
{ id: uid(), tabNum: '00002', fullName: 'Петрова Анна Сергеевна', position: 'Электромонтер', department: 'Энергоцех', climateZone: '2', dateHired: '2021-06-01' },
{ id: uid(), tabNum: '00003', fullName: 'Сериков Асхат Нурланович', position: 'Электромонтер', department: 'Энергоцех', climateZone: '3', dateHired: '2019-11-10' },
{ id: uid(), tabNum: '00004', fullName: 'Ким Елена Викторовна', position: 'Инженер', department: 'Технический отдел', climateZone: '0', dateHired: '2022-01-20' },
{ id: uid(), tabNum: '00005', fullName: 'Нургалиев Даурен Кайратович', position: 'Сварщик', department: 'Ремонтный цех', climateZone: '2', dateHired: '2018-08-05' },
{ id: uid(), tabNum: '00006', fullName: 'Ахметов Тимур Болатович', position: 'Кабельщик-спайщик', department: 'Линейный цех', climateZone: '2', dateHired: '2023-02-01' },
];
DB.siz = [
{ id: uid(), name: 'Каска защитная', type: 'Головы', protection: '1 класс', standard: 'ГОСТ 12.4.128-83', wearMonths: 24, unit: 'шт.' },
@ -783,6 +923,33 @@ function seedDemo() {
{ 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[12].id, wearMonths: 24, quantity: 1, note: '' },
{ id: uid(), position: 'Сварщик', climateZone: '0', sizId: DB.siz[9].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[0].id, wearMonths: 24, quantity: 1, note: '' },
{ id: uid(), position: 'Инженер', climateZone: '0', sizId: DB.siz[1].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' },
@ -812,6 +979,7 @@ document.querySelectorAll('.nav button').forEach(btn => {
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();
});
});
@ -832,6 +1000,7 @@ function openEmployeeModal(emp) {
document.getElementById('empPosition').value = emp ? emp.position : '';
document.getElementById('empDepartment').value = emp ? emp.department : '';
document.getElementById('empDateHired').value = emp ? emp.dateHired : '';
document.getElementById('empClimateZone').value = emp ? (emp.climateZone || '0') : '0';
openModal('employeeModal');
}
@ -845,6 +1014,7 @@ function saveEmployee() {
fullName: name,
position: document.getElementById('empPosition').value.trim(),
department: document.getElementById('empDepartment').value.trim(),
climateZone: document.getElementById('empClimateZone').value || '0',
dateHired: document.getElementById('empDateHired').value,
};
let list = DB.employees;
@ -1032,7 +1202,170 @@ function renderWarehouse() {
document.getElementById('whEmpty').style.display = list.length ? 'none' : 'block';
}
// ===================== ISSUANCES =====================
// ===================== 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 fillPositionsDatalist() {
const list = document.getElementById('positionsList');
const positions = [...new Set([...DB.norms.map(n => n.position), ...DB.employees.map(e => e.position)].filter(Boolean))];
list.innerHTML = positions.map(p => `<option value="${p}">`).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();
fillPositionsDatalist();
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="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';
}
// ===================== 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>' +
@ -1045,11 +1378,13 @@ function openIssuanceModal(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');
}
@ -1281,6 +1616,17 @@ function exportAllToExcel() {
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');
}
@ -1297,6 +1643,7 @@ seedDemo();
renderEmployees();
renderSiz();
renderWarehouse();
renderNorms();
renderIssuances();
renderControl();
renderReports();