488 lines
20 KiB
HTML
488 lines
20 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>
|
||
.kt-ai-btn { text-decoration: none; }
|
||
.table-container { overflow-x: auto; }
|
||
.status-chip { display: inline-block; padding: 4px 12px; border-radius: var(--kt-ai-radius-full); font-size: var(--kt-ai-text-sm); font-weight: var(--kt-ai-weight-medium); }
|
||
.status-na-rukah { background: var(--kt-ai-primary); color: #fff; }
|
||
.status-vozvrashen { background: var(--kt-ai-success, #10b981); color: #fff; }
|
||
.status-prosrochen { background: var(--kt-ai-danger); color: #fff; }
|
||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 1000; }
|
||
.modal-overlay.on { display: flex; }
|
||
.modal { background: var(--kt-ai-bg); border-radius: var(--kt-ai-radius-2xl); padding: var(--kt-ai-space-8); max-width: 520px; width: 90%; max-height: 90vh; overflow-y: auto; }
|
||
.modal-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--kt-ai-space-6); }
|
||
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: var(--kt-ai-fg-muted); }
|
||
.tab-content { display: none; }
|
||
.tab-content.on { display: block; }
|
||
.tabs { display: flex; gap: var(--kt-ai-space-2); margin-bottom: var(--kt-ai-space-6); }
|
||
.tab-btn { padding: var(--kt-ai-space-2) var(--kt-ai-space-4); border-radius: var(--kt-ai-radius-md); background: transparent; border: 1px solid transparent; cursor: pointer; color: var(--kt-ai-fg-muted); }
|
||
.tab-btn[aria-selected="true"] { background: var(--kt-ai-primary); color: #fff; }
|
||
.log-entry { padding: var(--kt-ai-space-3) 0; border-bottom: 1px solid var(--kt-ai-divider); font-size: var(--kt-ai-text-sm); }
|
||
.log-entry:last-child { border-bottom: none; }
|
||
.log-time { color: var(--kt-ai-fg-muted); font-size: var(--kt-ai-text-xs); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<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" id="themeToggle">🌓 Тема</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="kt-ai-wrap" style="padding-top: var(--kt-ai-space-8); padding-bottom: var(--kt-ai-space-12);">
|
||
|
||
<div class="tabs">
|
||
<button class="tab-btn" aria-selected="true" data-tab="records">Записи</button>
|
||
<button class="tab-btn" data-tab="log">Журнал изменений</button>
|
||
</div>
|
||
|
||
<div id="tab-records" class="tab-content on">
|
||
<section class="kt-ai-section">
|
||
<div class="kt-ai-section-head">
|
||
<h2>Учёт пропусков</h2>
|
||
<p>Ведите журнал выдачи и возврата пропусков сотрудников</p>
|
||
</div>
|
||
|
||
<div class="kt-ai-card" style="padding: var(--kt-ai-space-6); margin-bottom: var(--kt-ai-space-6);">
|
||
<div style="display: flex; gap: var(--kt-ai-space-4); flex-wrap: wrap; align-items: center;">
|
||
<div style="flex: 1; min-width: 200px;">
|
||
<div class="kt-ai-field">
|
||
<label>Поиск</label>
|
||
<input class="kt-ai-input" id="searchInput" placeholder="ФИО или номер пропуска">
|
||
</div>
|
||
</div>
|
||
<button class="kt-ai-btn" data-variant="primary" id="addBtn">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 8px;">
|
||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||
</svg>
|
||
Добавить пропуск
|
||
</button>
|
||
<button class="kt-ai-btn" data-variant="ghost" id="exportBtn">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="vertical-align: middle; margin-right: 8px;">
|
||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
|
||
<polyline points="7 10 12 15 17 10"></polyline>
|
||
<line x1="12" y1="15" x2="12" y2="3"></line>
|
||
</svg>
|
||
Выгрузить в Excel
|
||
</button>
|
||
</div>
|
||
|
||
<div style="display: flex; gap: var(--kt-ai-space-2); margin-top: var(--kt-ai-space-4); flex-wrap: wrap;" id="filterChips">
|
||
<button class="kt-ai-chip" data-filter="all" data-active="true">Все</button>
|
||
<button class="kt-ai-chip" data-filter="На руках">На руках</button>
|
||
<button class="kt-ai-chip" data-filter="Возвращен">Возвращен</button>
|
||
<button class="kt-ai-chip" data-filter="Просрочен">Просрочен</button>
|
||
</div>
|
||
|
||
<div style="display: flex; gap: var(--kt-ai-space-6); margin-top: var(--kt-ai-space-6); flex-wrap: wrap;">
|
||
<div style="text-align: center;">
|
||
<div style="font-size: var(--kt-ai-text-3xl); font-weight: var(--kt-ai-font-bold); color: var(--kt-ai-fg);" id="countTotal">0</div>
|
||
<div style="font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted);">Всего</div>
|
||
</div>
|
||
<div style="text-align: center;">
|
||
<div style="font-size: var(--kt-ai-text-3xl); font-weight: var(--kt-ai-font-bold); color: var(--kt-ai-primary);" id="countNaRukah">0</div>
|
||
<div style="font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted);">На руках</div>
|
||
</div>
|
||
<div style="text-align: center;">
|
||
<div style="font-size: var(--kt-ai-text-3xl); font-weight: var(--kt-ai-font-bold); color: var(--kt-ai-success, #10b981);" id="countVozvrashen">0</div>
|
||
<div style="font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted);">Возвращено</div>
|
||
</div>
|
||
<div style="text-align: center;">
|
||
<div style="font-size: var(--kt-ai-text-3xl); font-weight: var(--kt-ai-font-bold); color: var(--kt-ai-danger);" id="countProsrochen">0</div>
|
||
<div style="font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted);">Просрочено</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="kt-ai-card table-container">
|
||
<table style="width: 100%; border-collapse: collapse;">
|
||
<thead>
|
||
<tr style="border-bottom: 2px solid var(--kt-ai-divider);">
|
||
<th style="text-align: left; padding: var(--kt-ai-space-3); color: var(--kt-ai-fg-muted); font-weight: var(--kt-ai-weight-medium);">Сотрудник</th>
|
||
<th style="text-align: left; padding: var(--kt-ai-space-3); color: var(--kt-ai-fg-muted); font-weight: var(--kt-ai-weight-medium);">Номер пропуска</th>
|
||
<th style="text-align: left; padding: var(--kt-ai-space-3); color: var(--kt-ai-fg-muted); font-weight: var(--kt-ai-weight-medium);">Дата выдачи</th>
|
||
<th style="text-align: left; padding: var(--kt-ai-space-3); color: var(--kt-ai-fg-muted); font-weight: var(--kt-ai-weight-medium);">Дата возврата</th>
|
||
<th style="text-align: left; padding: var(--kt-ai-space-3); color: var(--kt-ai-fg-muted); font-weight: var(--kt-ai-weight-medium);">Статус</th>
|
||
<th style="text-align: right; padding: var(--kt-ai-space-3); color: var(--kt-ai-fg-muted); font-weight: var(--kt-ai-weight-medium);">Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="recordsTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
<div id="tab-log" class="tab-content">
|
||
<section class="kt-ai-section">
|
||
<div class="kt-ai-section-head">
|
||
<h2>Журнал изменений</h2>
|
||
<p>История всех действий с записями</p>
|
||
</div>
|
||
<div class="kt-ai-card" style="padding: var(--kt-ai-space-4);">
|
||
<div id="logContainer"></div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<div class="modal-overlay" id="modalOverlay">
|
||
<div class="modal">
|
||
<div class="modal-head">
|
||
<h3 id="modalTitle" style="margin: 0;">Добавить пропуск</h3>
|
||
<button class="modal-close" id="modalClose">×</button>
|
||
</div>
|
||
<form id="recordForm">
|
||
<input type="hidden" id="recordId">
|
||
<div class="kt-ai-field" style="margin-bottom: var(--kt-ai-space-4);">
|
||
<label for="fio">ФИО сотрудника</label>
|
||
<input class="kt-ai-input" id="fio" name="fio" required placeholder="Иванов Иван Иванович">
|
||
</div>
|
||
<div class="kt-ai-field" style="margin-bottom: var(--kt-ai-space-4);">
|
||
<label for="passNumber">Номер пропуска</label>
|
||
<input class="kt-ai-input" id="passNumber" name="passNumber" required placeholder="КТ-12345">
|
||
</div>
|
||
<div class="kt-ai-field" style="margin-bottom: var(--kt-ai-space-4);">
|
||
<label for="issueDate">Дата выдачи</label>
|
||
<input type="date" class="kt-ai-input" id="issueDate" name="issueDate" required>
|
||
</div>
|
||
<div class="kt-ai-field" style="margin-bottom: var(--kt-ai-space-4);">
|
||
<label for="returnDate">Дата возврата</label>
|
||
<input type="date" class="kt-ai-input" id="returnDate" name="returnDate">
|
||
</div>
|
||
<div class="kt-ai-field" style="margin-bottom: var(--kt-ai-space-6);">
|
||
<label for="status">Статус</label>
|
||
<select class="kt-ai-select" id="status" name="status" required>
|
||
<option value="На руках">На руках</option>
|
||
<option value="Возвращен">Возвращен</option>
|
||
<option value="Просрочен">Просрочен</option>
|
||
</select>
|
||
</div>
|
||
<div style="display: flex; gap: var(--kt-ai-space-3); justify-content: flex-end;">
|
||
<button type="button" class="kt-ai-btn" data-variant="ghost" id="cancelBtn">Отмена</button>
|
||
<button type="submit" class="kt-ai-btn" data-variant="primary">Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
// Демо-данные
|
||
const DEMO_DATA = [
|
||
{ id: 1, fio: 'Асетов Арман Бауржанович', passNumber: 'КТ-10234', issueDate: '2026-09-01', returnDate: '', status: 'На руках' },
|
||
{ id: 2, fio: 'Иванова Мария Петровна', passNumber: 'КТ-10567', issueDate: '2026-09-02', returnDate: '2026-09-03', status: 'Возвращен' },
|
||
{ id: 3, fio: 'Ким Дмитрий Сергеевич', passNumber: 'КТ-10891', issueDate: '2026-08-15', returnDate: '', status: 'Просрочен' },
|
||
{ id: 4, fio: 'Омарова Гульнара Талгатовна', passNumber: 'КТ-11023', issueDate: '2026-09-01', returnDate: '', status: 'На руках' },
|
||
{ id: 5, fio: 'Смирнов Алексей Владимирович', passNumber: 'КТ-11156', issueDate: '2026-09-03', returnDate: '', status: 'На руках' }
|
||
];
|
||
|
||
// Состояние приложения
|
||
let records = [];
|
||
let log = [];
|
||
let currentFilter = 'all';
|
||
let searchQuery = '';
|
||
|
||
// Инициализация
|
||
function init() {
|
||
const stored = localStorage.getItem('zhurnalRecords');
|
||
if (stored) {
|
||
records = JSON.parse(stored);
|
||
} else {
|
||
records = [...DEMO_DATA];
|
||
saveRecords();
|
||
}
|
||
|
||
const storedLog = localStorage.getItem('zhurnalLog');
|
||
if (storedLog) {
|
||
log = JSON.parse(storedLog);
|
||
}
|
||
|
||
render();
|
||
setupEventListeners();
|
||
}
|
||
|
||
function saveRecords() {
|
||
localStorage.setItem('zhurnalRecords', JSON.stringify(records));
|
||
}
|
||
|
||
function saveLog() {
|
||
localStorage.setItem('zhurnalLog', JSON.stringify(log));
|
||
}
|
||
|
||
function addToLog(action, details) {
|
||
log.unshift({
|
||
timestamp: new Date().toISOString(),
|
||
action: action,
|
||
details: details
|
||
});
|
||
saveLog();
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '—';
|
||
const d = new Date(dateStr);
|
||
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit' });
|
||
}
|
||
|
||
function getStatusClass(status) {
|
||
if (status === 'На руках') return 'status-na-rukah';
|
||
if (status === 'Возвращен') return 'status-vozvrashen';
|
||
if (status === 'Просрочен') return 'status-prosrochen';
|
||
return '';
|
||
}
|
||
|
||
function getFilteredRecords() {
|
||
let filtered = [...records];
|
||
|
||
if (currentFilter !== 'all') {
|
||
filtered = filtered.filter(r => r.status === currentFilter);
|
||
}
|
||
|
||
if (searchQuery) {
|
||
const q = searchQuery.toLowerCase();
|
||
filtered = filtered.filter(r =>
|
||
r.fio.toLowerCase().includes(q) ||
|
||
r.passNumber.toLowerCase().includes(q)
|
||
);
|
||
}
|
||
|
||
return filtered;
|
||
}
|
||
|
||
function updateCounts() {
|
||
const total = records.length;
|
||
const naRukah = records.filter(r => r.status === 'На руках').length;
|
||
const vozvrashen = records.filter(r => r.status === 'Возвращен').length;
|
||
const prosrochen = records.filter(r => r.status === 'Просрочен').length;
|
||
|
||
document.getElementById('countTotal').textContent = total;
|
||
document.getElementById('countNaRukah').textContent = naRukah;
|
||
document.getElementById('countVozvrashen').textContent = vozvrashen;
|
||
document.getElementById('countProsrochen').textContent = prosrochen;
|
||
}
|
||
|
||
function render() {
|
||
updateCounts();
|
||
renderTable();
|
||
renderLog();
|
||
}
|
||
|
||
function renderTable() {
|
||
const tbody = document.getElementById('recordsTableBody');
|
||
const filtered = getFilteredRecords();
|
||
|
||
if (filtered.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="6" style="padding: var(--kt-ai-space-6); text-align: center; color: var(--kt-ai-fg-muted);">Нет записей</td></tr>';
|
||
return;
|
||
}
|
||
|
||
tbody.innerHTML = filtered.map(r => `
|
||
<tr style="border-bottom: 1px solid var(--kt-ai-divider);">
|
||
<td style="padding: var(--kt-ai-space-3);">${escapeHtml(r.fio)}</td>
|
||
<td style="padding: var(--kt-ai-space-3);">${escapeHtml(r.passNumber)}</td>
|
||
<td style="padding: var(--kt-ai-space-3);">${formatDate(r.issueDate)}</td>
|
||
<td style="padding: var(--kt-ai-space-3);">${formatDate(r.returnDate)}</td>
|
||
<td style="padding: var(--kt-ai-space-3);"><span class="status-chip ${getStatusClass(r.status)}">${r.status}</span></td>
|
||
<td style="padding: var(--kt-ai-space-3); text-align: right;">
|
||
<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('');
|
||
}
|
||
|
||
function renderLog() {
|
||
const container = document.getElementById('logContainer');
|
||
if (log.length === 0) {
|
||
container.innerHTML = '<div style="color: var(--kt-ai-fg-muted); text-align: center; padding: var(--kt-ai-space-4);">История пуста</div>';
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = log.map(entry => {
|
||
const time = new Date(entry.timestamp).toLocaleString('ru-RU', {
|
||
day: '2-digit', month: '2-digit', year: '2-digit',
|
||
hour: '2-digit', minute: '2-digit'
|
||
});
|
||
return `
|
||
<div class="log-entry">
|
||
<div><strong>${entry.action}</strong>: ${escapeHtml(entry.details)}</div>
|
||
<div class="log-time">${time}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function openModal(recordId = null) {
|
||
const overlay = document.getElementById('modalOverlay');
|
||
const form = document.getElementById('recordForm');
|
||
const title = document.getElementById('modalTitle');
|
||
|
||
form.reset();
|
||
|
||
if (recordId) {
|
||
const record = records.find(r => r.id === recordId);
|
||
if (record) {
|
||
document.getElementById('recordId').value = record.id;
|
||
document.getElementById('fio').value = record.fio;
|
||
document.getElementById('passNumber').value = record.passNumber;
|
||
document.getElementById('issueDate').value = record.issueDate;
|
||
document.getElementById('returnDate').value = record.returnDate || '';
|
||
document.getElementById('status').value = record.status;
|
||
title.textContent = 'Редактировать пропуск';
|
||
}
|
||
} else {
|
||
document.getElementById('recordId').value = '';
|
||
title.textContent = 'Добавить пропуск';
|
||
}
|
||
|
||
overlay.classList.add('on');
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('modalOverlay').classList.remove('on');
|
||
}
|
||
|
||
function saveRecord(formData) {
|
||
const id = formData.get('recordId');
|
||
const record = {
|
||
fio: formData.get('fio'),
|
||
passNumber: formData.get('passNumber'),
|
||
issueDate: formData.get('issueDate'),
|
||
returnDate: formData.get('returnDate') || '',
|
||
status: formData.get('status')
|
||
};
|
||
|
||
if (id) {
|
||
const index = records.findIndex(r => r.id === parseInt(id));
|
||
if (index !== -1) {
|
||
const oldRecord = records[index];
|
||
records[index] = { ...record, id: parseInt(id) };
|
||
addToLog('Редактирование', `Пропуск ${oldRecord.passNumber} (${oldRecord.fio}) изменён`);
|
||
}
|
||
} else {
|
||
const newId = Date.now();
|
||
records.push({ ...record, id: newId });
|
||
addToLog('Добавление', `Добавлен пропуск ${record.passNumber} (${record.fio})`);
|
||
}
|
||
|
||
saveRecords();
|
||
render();
|
||
closeModal();
|
||
}
|
||
|
||
function deleteRecord(id) {
|
||
const record = records.find(r => r.id === id);
|
||
if (!record) return;
|
||
|
||
if (confirm(`Удалить запись о пропуске ${record.passNumber} (${record.fio})?`)) {
|
||
records = records.filter(r => r.id !== id);
|
||
addToLog('Удаление', `Удалён пропуск ${record.passNumber} (${record.fio})`);
|
||
saveRecords();
|
||
render();
|
||
}
|
||
}
|
||
|
||
function exportToExcel() {
|
||
const filtered = getFilteredRecords();
|
||
const headers = ['ФИО', 'Номер пропуска', 'Дата выдачи', 'Дата возврата', 'Статус'];
|
||
const rows = filtered.map(r => [
|
||
r.fio,
|
||
r.passNumber,
|
||
r.issueDate,
|
||
r.returnDate || '',
|
||
r.status
|
||
]);
|
||
|
||
const csvContent = '\uFEFF' + [
|
||
headers.join(';'),
|
||
...rows.map(row => row.map(cell => '"' + String(cell).replace(/"/g, '""') + '"').join(';'))
|
||
].join('\n');
|
||
|
||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||
const link = document.createElement('a');
|
||
link.href = URL.createObjectURL(blob);
|
||
link.download = 'zhurnal_propuskov.csv';
|
||
link.click();
|
||
|
||
addToLog('Экспорт', 'Выгрузка данных в Excel');
|
||
}
|
||
|
||
function setupEventListeners() {
|
||
document.getElementById('addBtn').addEventListener('click', () => openModal());
|
||
document.getElementById('modalClose').addEventListener('click', closeModal);
|
||
document.getElementById('cancelBtn').addEventListener('click', closeModal);
|
||
document.getElementById('exportBtn').addEventListener('click', exportToExcel);
|
||
|
||
document.getElementById('searchInput').addEventListener('input', (e) => {
|
||
searchQuery = e.target.value;
|
||
renderTable();
|
||
});
|
||
|
||
document.getElementById('filterChips').addEventListener('click', (e) => {
|
||
if (e.target.classList.contains('kt-ai-chip')) {
|
||
document.querySelectorAll('.kt-ai-chip').forEach(c => c.removeAttribute('data-active'));
|
||
e.target.setAttribute('data-active', 'true');
|
||
currentFilter = e.target.dataset.filter;
|
||
renderTable();
|
||
}
|
||
});
|
||
|
||
document.getElementById('recordForm').addEventListener('submit', (e) => {
|
||
e.preventDefault();
|
||
saveRecord(new FormData(e.target));
|
||
});
|
||
|
||
document.getElementById('modalOverlay').addEventListener('click', (e) => {
|
||
if (e.target === e.currentTarget) closeModal();
|
||
});
|
||
|
||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
document.querySelectorAll('.tab-btn').forEach(b => b.setAttribute('aria-selected', 'false'));
|
||
btn.setAttribute('aria-selected', 'true');
|
||
|
||
const tabName = btn.dataset.tab;
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('on'));
|
||
document.getElementById('tab-' + tabName).classList.add('on');
|
||
});
|
||
});
|
||
|
||
document.getElementById('themeToggle').addEventListener('click', () => {
|
||
const html = document.documentElement;
|
||
const current = html.getAttribute('data-theme');
|
||
html.setAttribute('data-theme', current === 'light' ? 'dark' : 'light');
|
||
});
|
||
}
|
||
|
||
window.editRecord = function(id) {
|
||
openModal(id);
|
||
};
|
||
|
||
window.deleteRecord = function(id) {
|
||
deleteRecord(id);
|
||
};
|
||
|
||
init();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|