435 lines
18 KiB
HTML
435 lines
18 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru" data-theme="light">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Журнал выдачи спецодежды</title>
|
||
<link rel="stylesheet" href="design-system/kt-ai-fonts.css">
|
||
<link rel="stylesheet" href="design-system/kt-ai-tokens.css">
|
||
<link rel="stylesheet" href="design-system/kt-ai-components.css">
|
||
<link rel="stylesheet" href="design-system/kt-ai-page.css">
|
||
<style>
|
||
a.kt-ai-btn { text-decoration: none; }
|
||
.status-chip { font-size: 13px; padding: 4px 10px; }
|
||
.table-wrap { overflow-x: auto; }
|
||
.kt-ai-table { width: 100%; border-collapse: collapse; font-size: 14px; }
|
||
.kt-ai-table th { text-align: left; padding: 12px 16px; font-weight: 600; color: var(--kt-ai-fg-muted); border-bottom: 2px solid var(--kt-ai-border); }
|
||
.kt-ai-table td { padding: 12px 16px; border-bottom: 1px solid var(--kt-ai-border); vertical-align: middle; }
|
||
.kt-ai-table tbody tr:hover { background: var(--kt-ai-bg-soft); }
|
||
.num-cell { text-align: right; font-variant-numeric: tabular-nums; }
|
||
.actions-cell { white-space: nowrap; }
|
||
.search-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 20px; }
|
||
.search-row input { flex: 1; min-width: 200px; }
|
||
.filters-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
|
||
.drawer-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: none; z-index: 100; }
|
||
.drawer-overlay.open { display: block; }
|
||
.drawer { position: fixed; right: -420px; top: 0; bottom: 0; width: 420px; background: var(--kt-ai-bg); padding: 24px; overflow-y: auto; transition: right 0.3s ease; z-index: 101; }
|
||
.drawer.open { right: 0; }
|
||
.drawer-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; }
|
||
.drawer-header h2 { margin: 0; }
|
||
.close-btn { background: none; border: none; font-size: 24px; cursor: pointer; color: var(--kt-ai-fg-muted); }
|
||
.form-group { margin-bottom: 16px; }
|
||
.form-group label { display: block; margin-bottom: 6px; font-weight: 500; font-size: 14px; color: var(--kt-ai-fg-muted); }
|
||
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 10px 12px; border: 1px solid var(--kt-ai-border); border-radius: var(--kt-ai-radius-lg); background: var(--kt-ai-bg); color: var(--kt-ai-fg); font-size: 14px; }
|
||
.form-group textarea { resize: vertical; min-height: 80px; }
|
||
.form-actions { display: flex; gap: 12px; margin-top: 24px; }
|
||
.empty-state { text-align: center; padding: 60px 20px; color: var(--kt-ai-fg-muted); }
|
||
.empty-state svg { width: 64px; height: 64px; margin-bottom: 16px; opacity: 0.5; }
|
||
</style>
|
||
</head>
|
||
<body class="kt-ai-app">
|
||
|
||
<header class="kt-ai-pagehead">
|
||
<div class="kt-ai-wrap kt-ai-pagehead-in">
|
||
<strong>📦 Журнал выдачи спецодежды</strong>
|
||
<span class="kt-ai-spacer"></span>
|
||
<button class="kt-ai-btn" data-variant="ghost" onclick="exportToCSV()">📥 Экспорт</button>
|
||
<button class="kt-ai-btn" data-variant="primary" onclick="openDrawer('issue')">➕ Выдать</button>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="kt-ai-hero" data-align="center">
|
||
<div class="kt-ai-wrap">
|
||
<h1>Учёт выдачи спецодежды</h1>
|
||
<p class="kt-ai-hero-sub">Контролируйте выдачу, отслеживайте возврат, ведите учёт по сотрудникам</p>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="kt-ai-section">
|
||
<div class="kt-ai-wrap" data-width="wide">
|
||
<div class="kt-ai-kpi-strip" style="margin-bottom: 24px;">
|
||
<div class="kt-ai-kpi">
|
||
<div class="value" id="kpi-total">0</div>
|
||
<div class="label">Всего записей</div>
|
||
</div>
|
||
<div class="kt-ai-kpi">
|
||
<div class="value" id="kpi-issued" style="color: var(--kt-ai-chart-blue)"></div>
|
||
<div class="label">Выдано</div>
|
||
</div>
|
||
<div class="kt-ai-kpi">
|
||
<div class="value" id="kpi-returned" style="color: var(--kt-ai-chart-green)"></div>
|
||
<div class="label">Возвращено</div>
|
||
</div>
|
||
<div class="kt-ai-kpi">
|
||
<div class="value" id="kpi-overdue" style="color: var(--kt-ai-chart-red)"></div>
|
||
<div class="label">Просрочено</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="search-row">
|
||
<input type="text" class="kt-ai-input" id="search-input" placeholder="🔍 Поиск по сотруднику или наименованию..." oninput="renderTable()">
|
||
</div>
|
||
|
||
<div class="filters-row" id="status-filters">
|
||
<button class="kt-ai-btn" data-variant="primary" data-size="sm" onclick="setFilter('all', this)">Все</button>
|
||
<button class="kt-ai-btn" data-variant="ghost" data-size="sm" onclick="setFilter('issued', this)">📤 Выдано</button>
|
||
<button class="kt-ai-btn" data-variant="ghost" data-size="sm" onclick="setFilter('returned', this)">✅ Возвращено</button>
|
||
<button class="kt-ai-btn" data-variant="ghost" data-size="sm" onclick="setFilter('overdue', this)">⚠️ Просрочено</button>
|
||
</div>
|
||
|
||
<div class="table-wrap">
|
||
<table class="kt-ai-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Дата выдачи</th>
|
||
<th>Сотрудник</th>
|
||
<th>Наименование</th>
|
||
<th class="num-cell">Кол-во</th>
|
||
<th>Дата возврата</th>
|
||
<th>Статус</th>
|
||
<th class="actions-cell">Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="table-body"></tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div id="empty-state" class="empty-state" style="display: none;">
|
||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||
<path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/>
|
||
</svg>
|
||
<p>Нет записей. Нажмите «Выдать», чтобы добавить первую запись.</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<footer class="kt-ai-wrap kt-ai-pagefoot">
|
||
<span>© 2026 Журнал выдачи спецодежды</span>
|
||
<span class="kt-ai-spacer"></span>
|
||
<span>Данные сохраняются локально</span>
|
||
</footer>
|
||
|
||
<div class="drawer-overlay" id="drawer-overlay" onclick="closeDrawer()"></div>
|
||
|
||
<div class="drawer" id="drawer">
|
||
<div class="drawer-header">
|
||
<h2 id="drawer-title">Выдать спецодежду</h2>
|
||
<button class="close-btn" onclick="closeDrawer()">×</button>
|
||
</div>
|
||
<form id="issue-form" onsubmit="handleSubmit(event)">
|
||
<input type="hidden" id="edit-id">
|
||
<div class="form-group">
|
||
<label for="employee">Сотрудник *</label>
|
||
<input type="text" id="employee" name="employee" required placeholder="ФИО сотрудника">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="item">Наименование *</label>
|
||
<input type="text" id="item" name="item" required placeholder="Каска защитная, перчатки, ботинки...">
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="quantity">Количество *</label>
|
||
<input type="number" id="quantity" name="quantity" min="1" value="1" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="issue-date">Дата выдачи *</label>
|
||
<input type="date" id="issue-date" name="issueDate" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="return-date">Дата возврата *</label>
|
||
<input type="date" id="return-date" name="returnDate" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="note">Примечание</label>
|
||
<textarea id="note" name="note" placeholder="Серийный номер, состояние..."></textarea>
|
||
</div>
|
||
<div class="form-actions">
|
||
<button type="submit" class="kt-ai-btn" data-variant="primary" id="save-btn">Сохранить</button>
|
||
<button type="button" class="kt-ai-btn" data-variant="ghost" onclick="closeDrawer()">Отмена</button>
|
||
</div>
|
||
</form>
|
||
<div id="return-actions" style="display: none; margin-top: 24px; padding-top: 24px; border-top: 1px solid var(--kt-ai-border);">
|
||
<p style="margin-bottom: 16px; color: var(--kt-ai-fg-muted);">Запись возвращена</p>
|
||
<button class="kt-ai-btn" data-variant="primary" onclick="reopenRecord()">Вернуть в работу</button>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
let records = [];
|
||
let currentFilter = 'all';
|
||
let currentEditId = null;
|
||
|
||
const DEMO_DATA = [
|
||
{ id: 1, employee: 'Иванов А.С.', item: 'Каска защитная', quantity: 1, issueDate: '2026-08-15', returnDate: '2026-09-15', status: 'issued', note: 'Серийный №12345' },
|
||
{ id: 2, employee: 'Петров В.К.', item: 'Перчатки рабочие', quantity: 5, issueDate: '2026-08-20', returnDate: '2026-09-01', status: 'returned', note: '' },
|
||
{ id: 3, employee: 'Сидорова М.И.', item: 'Ботинки рабочие', quantity: 1, issueDate: '2026-07-10', returnDate: '2026-08-10', status: 'overdue', note: 'Размер 39' },
|
||
{ id: 4, employee: 'Кузнецов П.Н.', item: 'Жилет сигнальный', quantity: 2, issueDate: '2026-08-25', returnDate: '2026-09-25', status: 'issued', note: 'Оранжевый, L' },
|
||
{ id: 5, employee: 'Иванов А.С.', item: 'Очки защитные', quantity: 1, issueDate: '2026-08-01', returnDate: '2026-08-31', status: 'returned', note: '' }
|
||
];
|
||
|
||
function init() {
|
||
const saved = localStorage.getItem('special-clothing-records');
|
||
records = saved ? JSON.parse(saved) : [...DEMO_DATA];
|
||
document.getElementById('issue-date').valueAsDate = new Date();
|
||
const nextMonth = new Date();
|
||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||
document.getElementById('return-date').valueAsDate = nextMonth;
|
||
renderTable();
|
||
updateKPI();
|
||
}
|
||
|
||
function saveRecords() {
|
||
localStorage.setItem('special-clothing-records', JSON.stringify(records));
|
||
renderTable();
|
||
updateKPI();
|
||
}
|
||
|
||
function getStatus(record) {
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
if (record.status === 'returned') return 'returned';
|
||
if (record.returnDate < today) return 'overdue';
|
||
return 'issued';
|
||
}
|
||
|
||
function getStatusBadge(status) {
|
||
const badges = {
|
||
issued: '<span class="kt-ai-chip status-chip" style="background: var(--kt-ai-chart-blue); color: white;">📤 Выдано</span>',
|
||
returned: '<span class="kt-ai-chip status-chip" style="background: var(--kt-ai-chart-green); color: white;">✅ Возвращено</span>',
|
||
overdue: '<span class="kt-ai-chip status-chip" style="background: var(--kt-ai-chart-red); color: white;">⚠️ Просрочено</span>'
|
||
};
|
||
return badges[status] || badges.issued;
|
||
}
|
||
|
||
function renderTable() {
|
||
const search = document.getElementById('search-input').value.toLowerCase();
|
||
const filtered = records.filter(r => {
|
||
const status = getStatus(r);
|
||
const matchesSearch = r.employee.toLowerCase().includes(search) || r.item.toLowerCase().includes(search);
|
||
const matchesFilter = currentFilter === 'all' || status === currentFilter;
|
||
return matchesSearch && matchesFilter;
|
||
});
|
||
|
||
const tbody = document.getElementById('table-body');
|
||
const emptyState = document.getElementById('empty-state');
|
||
const tableWrap = document.querySelector('.table-wrap');
|
||
|
||
if (filtered.length === 0) {
|
||
tbody.innerHTML = '';
|
||
emptyState.style.display = 'block';
|
||
tableWrap.style.display = 'none';
|
||
} else {
|
||
emptyState.style.display = 'none';
|
||
tableWrap.style.display = 'block';
|
||
tbody.innerHTML = filtered.map(r => {
|
||
const status = getStatus(r);
|
||
return `<tr>
|
||
<td>${formatDate(r.issueDate)}</td>
|
||
<td>${escapeHtml(r.employee)}</td>
|
||
<td>${escapeHtml(r.item)}</td>
|
||
<td class="num-cell">${r.quantity}</td>
|
||
<td>${formatDate(r.returnDate)}</td>
|
||
<td>${getStatusBadge(status)}</td>
|
||
<td class="actions-cell">
|
||
<button class="kt-ai-btn" data-variant="ghost" data-size="sm" onclick="editRecord(${r.id})">✏️</button>
|
||
<button class="kt-ai-btn" data-variant="danger" data-size="sm" onclick="deleteRecord(${r.id})">🗑️</button>
|
||
</td>
|
||
</tr>`;
|
||
}).join('');
|
||
}
|
||
updateKPI();
|
||
}
|
||
|
||
function updateKPI() {
|
||
const today = new Date().toISOString().slice(0, 10);
|
||
const total = records.length;
|
||
const issued = records.filter(r => getStatus(r) === 'issued').length;
|
||
const returned = records.filter(r => r.status === 'returned').length;
|
||
const overdue = records.filter(r => getStatus(r) === 'overdue').length;
|
||
|
||
document.getElementById('kpi-total').textContent = total;
|
||
document.getElementById('kpi-issued').textContent = issued;
|
||
document.getElementById('kpi-returned').textContent = returned;
|
||
document.getElementById('kpi-overdue').textContent = overdue;
|
||
}
|
||
|
||
function openDrawer(mode, id = null) {
|
||
const drawer = document.getElementById('drawer');
|
||
const overlay = document.getElementById('drawer-overlay');
|
||
const form = document.getElementById('issue-form');
|
||
const returnActions = document.getElementById('return-actions');
|
||
const title = document.getElementById('drawer-title');
|
||
|
||
currentEditId = id;
|
||
form.reset();
|
||
|
||
if (id !== null) {
|
||
const record = records.find(r => r.id === id);
|
||
if (record) {
|
||
document.getElementById('edit-id').value = record.id;
|
||
document.getElementById('employee').value = record.employee;
|
||
document.getElementById('item').value = record.item;
|
||
document.getElementById('quantity').value = record.quantity;
|
||
document.getElementById('issue-date').value = record.issueDate;
|
||
document.getElementById('return-date').value = record.returnDate;
|
||
document.getElementById('note').value = record.note || '';
|
||
|
||
if (record.status === 'returned') {
|
||
returnActions.style.display = 'block';
|
||
document.getElementById('save-btn').style.display = 'none';
|
||
title.textContent = 'Просмотр записи';
|
||
setFormDisabled(true);
|
||
} else {
|
||
returnActions.style.display = 'none';
|
||
document.getElementById('save-btn').style.display = 'inline-block';
|
||
title.textContent = 'Редактировать запись';
|
||
setFormDisabled(false);
|
||
}
|
||
}
|
||
} else {
|
||
document.getElementById('issue-date').valueAsDate = new Date();
|
||
const nextMonth = new Date();
|
||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||
document.getElementById('return-date').valueAsDate = nextMonth;
|
||
returnActions.style.display = 'none';
|
||
document.getElementById('save-btn').style.display = 'inline-block';
|
||
title.textContent = mode === 'issue' ? 'Выдать спецодежду' : 'Редактировать';
|
||
setFormDisabled(false);
|
||
}
|
||
|
||
drawer.classList.add('open');
|
||
overlay.classList.add('open');
|
||
}
|
||
|
||
function setFormDisabled(disabled) {
|
||
['employee', 'item', 'quantity', 'issue-date', 'return-date', 'note'].forEach(id => {
|
||
document.getElementById(id).disabled = disabled;
|
||
});
|
||
}
|
||
|
||
function closeDrawer() {
|
||
document.getElementById('drawer').classList.remove('open');
|
||
document.getElementById('drawer-overlay').classList.remove('open');
|
||
currentEditId = null;
|
||
}
|
||
|
||
function handleSubmit(e) {
|
||
e.preventDefault();
|
||
const id = document.getElementById('edit-id').value;
|
||
const record = {
|
||
employee: document.getElementById('employee').value.trim(),
|
||
item: document.getElementById('item').value.trim(),
|
||
quantity: parseInt(document.getElementById('quantity').value),
|
||
issueDate: document.getElementById('issue-date').value,
|
||
returnDate: document.getElementById('return-date').value,
|
||
note: document.getElementById('note').value.trim()
|
||
};
|
||
|
||
if (id) {
|
||
const index = records.findIndex(r => r.id === parseInt(id));
|
||
if (index !== -1) {
|
||
records[index] = { ...records[index], ...record };
|
||
}
|
||
} else {
|
||
records.push({
|
||
id: Date.now(),
|
||
...record,
|
||
status: 'issued'
|
||
});
|
||
}
|
||
|
||
saveRecords();
|
||
closeDrawer();
|
||
}
|
||
|
||
function editRecord(id) {
|
||
openDrawer('edit', id);
|
||
}
|
||
|
||
function deleteRecord(id) {
|
||
if (confirm('Удалить эту запись?')) {
|
||
records = records.filter(r => r.id !== id);
|
||
saveRecords();
|
||
}
|
||
}
|
||
|
||
function reopenRecord() {
|
||
if (currentEditId !== null) {
|
||
const index = records.findIndex(r => r.id === currentEditId);
|
||
if (index !== -1) {
|
||
records[index].status = 'issued';
|
||
saveRecords();
|
||
closeDrawer();
|
||
}
|
||
}
|
||
}
|
||
|
||
function returnRecord(id) {
|
||
if (confirm('Отметить как возвращённое?')) {
|
||
const index = records.findIndex(r => r.id === id);
|
||
if (index !== -1) {
|
||
records[index].status = 'returned';
|
||
saveRecords();
|
||
}
|
||
}
|
||
}
|
||
|
||
function setFilter(filter, btn) {
|
||
currentFilter = filter;
|
||
document.querySelectorAll('#status-filters .kt-ai-btn').forEach(b => {
|
||
b.setAttribute('data-variant', 'ghost');
|
||
});
|
||
btn.setAttribute('data-variant', 'primary');
|
||
renderTable();
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
const d = new Date(dateStr + 'T00:00:00');
|
||
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function exportToCSV() {
|
||
const headers = ['Дата выдачи', 'Сотрудник', 'Наименование', 'Кол-во', 'Дата возврата', 'Статус', 'Примечание'];
|
||
const rows = records.map(r => {
|
||
const status = getStatus(r);
|
||
const statusText = { issued: 'Выдано', returned: 'Возвращено', overdue: 'Просрочено' }[status];
|
||
return [
|
||
r.issueDate,
|
||
r.employee,
|
||
r.item,
|
||
r.quantity,
|
||
r.returnDate,
|
||
statusText,
|
||
r.note || ''
|
||
].map(v => '"' + String(v).replace(/"/g, '""') + '"').join(';');
|
||
});
|
||
const csv = '\uFEFF' + [headers.join(';'), ...rows].join('\n');
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'zhurnal_spetsodezhdy_' + new Date().toISOString().slice(0, 10) + '.csv';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
init();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|