433 lines
19 KiB
HTML
433 lines
19 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>
|
||
.dashboard-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--kt-ai-space-6); margin-bottom: var(--kt-ai-space-8); }
|
||
.stat-card { background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-5); }
|
||
.stat-value { font-size: 32px; font-weight: var(--kt-ai-weight-bold); color: var(--kt-ai-fg); margin-bottom: var(--kt-ai-space-1); }
|
||
.stat-label { color: var(--kt-ai-fg-muted); font-size: var(--kt-ai-text-sm); }
|
||
.controls-bar { display: flex; gap: var(--kt-ai-space-4); align-items: center; flex-wrap: wrap; margin-bottom: var(--kt-ai-space-6); }
|
||
.search-input { flex: 1; min-width: 250px; }
|
||
.filters { display: flex; gap: var(--kt-ai-space-2); flex-wrap: wrap; }
|
||
.table-container { background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-xl); overflow: hidden; }
|
||
.data-table { width: 100%; border-collapse: collapse; }
|
||
.data-table th, .data-table td { padding: var(--kt-ai-space-4); text-align: left; border-bottom: 1px solid var(--kt-ai-divider); }
|
||
.data-table th { background: var(--kt-ai-bg-soft); font-weight: var(--kt-ai-weight-semibold); color: var(--kt-ai-fg-muted); font-size: var(--kt-ai-text-sm); }
|
||
.data-table tr:hover { background: var(--kt-ai-bg-soft); cursor: pointer; }
|
||
.data-table tr:last-child td { border-bottom: none; }
|
||
.status-chip { display: inline-block; padding: 2px 10px; border-radius: var(--kt-ai-radius-full); font-size: var(--kt-ai-text-xs); font-weight: var(--kt-ai-weight-medium); }
|
||
.status-na-soglasovanii { background: #fef3c7; color: #92400e; }
|
||
.status-soglasovano { background: #d1fae5; color: #065f46; }
|
||
.status-otkazano { background: #fee2e2; color: #991b1b; }
|
||
.status-v-poezdke { background: #dbeafe; color: #1e40af; }
|
||
.status-zaversheno { background: #e5e7eb; color: #374151; }
|
||
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 1000; }
|
||
.modal-overlay.active { display: flex; }
|
||
.modal { background: var(--kt-ai-bg); border-radius: var(--kt-ai-radius-2xl); padding: var(--kt-ai-space-8); width: 100%; max-width: 500px; max-height: 90vh; overflow-y: auto; }
|
||
.modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--kt-ai-space-6); }
|
||
.modal-title { font-size: var(--kt-ai-text-xl); font-weight: var(--kt-ai-weight-semibold); }
|
||
.modal-close { background: none; border: none; font-size: 24px; cursor: pointer; color: var(--kt-ai-fg-muted); }
|
||
.form-group { margin-bottom: var(--kt-ai-space-5); }
|
||
.form-label { display: block; margin-bottom: var(--kt-ai-space-2); font-weight: var(--kt-ai-weight-medium); color: var(--kt-ai-fg); }
|
||
.form-input, .form-select { width: 100%; padding: var(--kt-ai-space-3); 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: var(--kt-ai-text-base); }
|
||
.form-input:focus, .form-select:focus { outline: 2px solid var(--kt-ai-primary); border-color: transparent; }
|
||
.modal-actions { display: flex; gap: var(--kt-ai-space-3); justify-content: flex-end; margin-top: var(--kt-ai-space-7); }
|
||
.log-container { margin-top: var(--kt-ai-space-8); padding: var(--kt-ai-space-5); background: var(--kt-ai-bg-soft); border-radius: var(--kt-ai-radius-xl); }
|
||
.log-title { font-weight: var(--kt-ai-weight-semibold); margin-bottom: var(--kt-ai-space-3); }
|
||
.log-list { list-style: none; padding: 0; margin: 0; max-height: 200px; overflow-y: auto; }
|
||
.log-item { padding: var(--kt-ai-space-2) 0; border-bottom: 1px solid var(--kt-ai-divider); font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted); }
|
||
.page-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--kt-ai-space-8); flex-wrap: wrap; gap: var(--kt-ai-space-4); }
|
||
.page-title { font-size: var(--kt-ai-text-2xl); font-weight: var(--kt-ai-weight-bold); }
|
||
.empty-state { text-align: center; padding: var(--kt-ai-space-12); color: var(--kt-ai-fg-muted); }
|
||
.btn-icon { display: inline-flex; align-items: center; gap: 6px; }
|
||
</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" onclick="exportToExcel()">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
||
Выгрузить в Excel
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="kt-ai-wrap" style="padding-top: var(--kt-ai-space-8); padding-bottom: var(--kt-ai-space-8);">
|
||
|
||
<div class="page-header">
|
||
<h1 class="page-title">Дашборд командировок</h1>
|
||
<button class="kt-ai-btn" data-variant="primary" data-size="lg" onclick="openModal()">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||
Добавить командировку
|
||
</button>
|
||
</div>
|
||
|
||
<div class="dashboard-stats">
|
||
<div class="stat-card">
|
||
<div class="stat-value" id="stat-total">0</div>
|
||
<div class="stat-label">Всего командировок</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value" id="stat-na-soglasovanii">0</div>
|
||
<div class="stat-label">На согласовании</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value" id="stat-soglasovano">0</div>
|
||
<div class="stat-label">Согласовано</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value" id="stat-v-poezdke">0</div>
|
||
<div class="stat-label">В поездке</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="controls-bar">
|
||
<input type="text" class="kt-ai-input search-input" id="search-input" placeholder="Поиск по ФИО или городу...">
|
||
<div class="filters" id="status-filters">
|
||
<button class="kt-ai-btn" data-variant="primary" data-status="all">Все</button>
|
||
<button class="kt-ai-btn" data-variant="ghost" data-status="На согласовании">На согласовании</button>
|
||
<button class="kt-ai-btn" data-variant="ghost" data-status="Согласовано">Согласовано</button>
|
||
<button class="kt-ai-btn" data-variant="ghost" data-status="Отказано">Отказано</button>
|
||
<button class="kt-ai-btn" data-variant="ghost" data-status="В поездке">В поездке</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="table-container">
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Сотрудник</th>
|
||
<th>Куда</th>
|
||
<th>Даты</th>
|
||
<th>Бюджет</th>
|
||
<th>Статус</th>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="table-body">
|
||
</tbody>
|
||
</table>
|
||
<div id="empty-state" class="empty-state" style="display: none;">
|
||
<p>Нет записей</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="log-container">
|
||
<div class="log-title">Журнал изменений</div>
|
||
<ul class="log-list" id="log-list"></ul>
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<div class="modal-overlay" id="modal-overlay">
|
||
<div class="modal">
|
||
<div class="modal-header">
|
||
<h2 class="modal-title" id="modal-title">Добавить командировку</h2>
|
||
<button class="modal-close" onclick="closeModal()">×</button>
|
||
</div>
|
||
<form id="trip-form" onsubmit="saveTrip(event)">
|
||
<input type="hidden" id="trip-id">
|
||
<div class="form-group">
|
||
<label class="form-label" for="employee-name">ФИО сотрудника</label>
|
||
<input type="text" class="form-input" id="employee-name" required placeholder="Иванов А.А.">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="destination">Город назначения</label>
|
||
<input type="text" class="form-input" id="destination" required placeholder="Астана">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="date-start">Дата начала</label>
|
||
<input type="date" class="form-input" id="date-start" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="date-end">Дата окончания</label>
|
||
<input type="date" class="form-input" id="date-end" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="budget">Бюджет (тенге)</label>
|
||
<input type="number" class="form-input" id="budget" required placeholder="150000" min="0">
|
||
</div>
|
||
<div class="form-group">
|
||
<label class="form-label" for="status">Статус</label>
|
||
<select class="form-select" id="status" required>
|
||
<option value="На согласовании">На согласовании</option>
|
||
<option value="Согласовано">Согласовано</option>
|
||
<option value="Отказано">Отказано</option>
|
||
<option value="В поездке">В поездке</option>
|
||
<option value="Завершено">Завершено</option>
|
||
</select>
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button type="button" class="kt-ai-btn" data-variant="ghost" onclick="closeModal()">Отмена</button>
|
||
<button type="submit" class="kt-ai-btn" data-variant="primary">Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
const STORAGE_KEY = 'business-trips-journal';
|
||
const LOG_KEY = 'business-trips-log';
|
||
|
||
const DEMO_DATA = [
|
||
{ id: 1, employee: 'Иванов А.', destination: 'Астана', dateStart: '2023-10-10', dateEnd: '2023-10-15', budget: 150000, status: 'Согласовано' },
|
||
{ id: 2, employee: 'Петров Б.', destination: 'Алматы', dateStart: '2023-10-12', dateEnd: '2023-10-18', budget: 80000, status: 'На согласовании' },
|
||
{ id: 3, employee: 'Смирнов В.', destination: 'Москва', dateStart: '2023-11-01', dateEnd: '2023-11-10', budget: 450000, status: 'В поездке' },
|
||
{ id: 4, employee: 'Ахметов К.', destination: 'Астана', dateStart: '2023-11-05', dateEnd: '2023-11-12', budget: 120000, status: 'Отказано' },
|
||
{ id: 5, employee: 'Ли Д.', destination: 'Атырау', dateStart: '2023-11-20', dateEnd: '2023-11-25', budget: 90000, status: 'На согласовании' }
|
||
];
|
||
|
||
let trips = [];
|
||
let logs = [];
|
||
let currentFilter = 'all';
|
||
let searchQuery = '';
|
||
|
||
function loadData() {
|
||
const stored = localStorage.getItem(STORAGE_KEY);
|
||
if (stored) {
|
||
trips = JSON.parse(stored);
|
||
} else {
|
||
trips = [...DEMO_DATA];
|
||
saveData();
|
||
}
|
||
const storedLogs = localStorage.getItem(LOG_KEY);
|
||
logs = storedLogs ? JSON.parse(storedLogs) : [];
|
||
}
|
||
|
||
function saveData() {
|
||
localStorage.setItem(STORAGE_KEY, JSON.stringify(trips));
|
||
}
|
||
|
||
function saveLogs() {
|
||
localStorage.setItem(LOG_KEY, JSON.stringify(logs));
|
||
}
|
||
|
||
function addLog(action, details) {
|
||
const now = new Date();
|
||
logs.unshift({
|
||
timestamp: now.toLocaleString('ru-RU'),
|
||
action,
|
||
details
|
||
});
|
||
if (logs.length > 50) logs.pop();
|
||
saveLogs();
|
||
renderLog();
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
const [year, month, day] = dateStr.split('-');
|
||
return `${day}.${month}.${year}`;
|
||
}
|
||
|
||
function formatBudget(amount) {
|
||
return new Intl.NumberFormat('ru-RU').format(amount) + ' ₸';
|
||
}
|
||
|
||
function getStatusClass(status) {
|
||
const map = {
|
||
'На согласовании': 'status-na-soglasovanii',
|
||
'Согласовано': 'status-soglasovano',
|
||
'Отказано': 'status-otkazano',
|
||
'В поездке': 'status-v-poezdke',
|
||
'Завершено': 'status-zaversheno'
|
||
};
|
||
return map[status] || '';
|
||
}
|
||
|
||
function updateStats() {
|
||
const total = trips.length;
|
||
const naSoglasovanii = trips.filter(t => t.status === 'На согласовании').length;
|
||
const soglasovano = trips.filter(t => t.status === 'Согласовано').length;
|
||
const vPoezdke = trips.filter(t => t.status === 'В поездке').length;
|
||
|
||
document.getElementById('stat-total').textContent = total;
|
||
document.getElementById('stat-na-soglasovanii').textContent = naSoglasovanii;
|
||
document.getElementById('stat-soglasovano').textContent = soglasovano;
|
||
document.getElementById('stat-v-poezdke').textContent = vPoezdke;
|
||
}
|
||
|
||
function getFilteredTrips() {
|
||
return trips.filter(trip => {
|
||
const matchesStatus = currentFilter === 'all' || trip.status === currentFilter;
|
||
const matchesSearch = searchQuery === '' ||
|
||
trip.employee.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||
trip.destination.toLowerCase().includes(searchQuery.toLowerCase());
|
||
return matchesStatus && matchesSearch;
|
||
});
|
||
}
|
||
|
||
function renderTable() {
|
||
const tbody = document.getElementById('table-body');
|
||
const emptyState = document.getElementById('empty-state');
|
||
const filtered = getFilteredTrips();
|
||
|
||
if (filtered.length === 0) {
|
||
tbody.innerHTML = '';
|
||
emptyState.style.display = 'block';
|
||
return;
|
||
}
|
||
|
||
emptyState.style.display = 'none';
|
||
tbody.innerHTML = filtered.map(trip => `
|
||
<tr onclick="editTrip(${trip.id})">
|
||
<td>${trip.employee}</td>
|
||
<td>${trip.destination}</td>
|
||
<td>${formatDate(trip.dateStart)} — ${formatDate(trip.dateEnd)}</td>
|
||
<td>${formatBudget(trip.budget)}</td>
|
||
<td><span class="status-chip ${getStatusClass(trip.status)}">${trip.status}</span></td>
|
||
<td>
|
||
<button class="kt-ai-btn" data-variant="ghost" data-size="sm" onclick="event.stopPropagation(); deleteTrip(${trip.id})">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
`).join('');
|
||
}
|
||
|
||
function renderLog() {
|
||
const logList = document.getElementById('log-list');
|
||
logList.innerHTML = logs.map(log =>
|
||
`<li class="log-item"><strong>${log.timestamp}</strong> — ${log.action}: ${log.details}</li>`
|
||
).join('');
|
||
}
|
||
|
||
function openModal(tripId = null) {
|
||
const modal = document.getElementById('modal-overlay');
|
||
const title = document.getElementById('modal-title');
|
||
const form = document.getElementById('trip-form');
|
||
|
||
form.reset();
|
||
|
||
if (tripId) {
|
||
const trip = trips.find(t => t.id === tripId);
|
||
if (trip) {
|
||
title.textContent = 'Редактировать командировку';
|
||
document.getElementById('trip-id').value = trip.id;
|
||
document.getElementById('employee-name').value = trip.employee;
|
||
document.getElementById('destination').value = trip.destination;
|
||
document.getElementById('date-start').value = trip.dateStart;
|
||
document.getElementById('date-end').value = trip.dateEnd;
|
||
document.getElementById('budget').value = trip.budget;
|
||
document.getElementById('status').value = trip.status;
|
||
}
|
||
} else {
|
||
title.textContent = 'Добавить командировку';
|
||
document.getElementById('trip-id').value = '';
|
||
}
|
||
|
||
modal.classList.add('active');
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('modal-overlay').classList.remove('active');
|
||
}
|
||
|
||
function saveTrip(event) {
|
||
event.preventDefault();
|
||
|
||
const id = document.getElementById('trip-id').value;
|
||
const tripData = {
|
||
employee: document.getElementById('employee-name').value,
|
||
destination: document.getElementById('destination').value,
|
||
dateStart: document.getElementById('date-start').value,
|
||
dateEnd: document.getElementById('date-end').value,
|
||
budget: parseInt(document.getElementById('budget').value),
|
||
status: document.getElementById('status').value
|
||
};
|
||
|
||
if (id) {
|
||
const index = trips.findIndex(t => t.id == id);
|
||
if (index !== -1) {
|
||
const oldStatus = trips[index].status;
|
||
trips[index] = { ...tripData, id: parseInt(id) };
|
||
addLog('Изменение', `Статус "${tripData.employee}" изменён с "${oldStatus}" на "${tripData.status}"`);
|
||
}
|
||
} else {
|
||
const newId = Date.now();
|
||
trips.push({ ...tripData, id: newId });
|
||
addLog('Добавление', `Новая командировка: ${tripData.employee} → ${tripData.destination}`);
|
||
}
|
||
|
||
saveData();
|
||
updateStats();
|
||
renderTable();
|
||
closeModal();
|
||
}
|
||
|
||
function editTrip(id) {
|
||
openModal(id);
|
||
}
|
||
|
||
function deleteTrip(id) {
|
||
if (!confirm('Удалить запись?')) return;
|
||
|
||
const trip = trips.find(t => t.id === id);
|
||
if (trip) {
|
||
addLog('Удаление', `${trip.employee} → ${trip.destination} (${trip.status})`);
|
||
trips = trips.filter(t => t.id !== id);
|
||
saveData();
|
||
updateStats();
|
||
renderTable();
|
||
}
|
||
}
|
||
|
||
function exportToExcel() {
|
||
const filtered = getFilteredTrips();
|
||
const headers = ['Сотрудник', 'Куда', 'Дата начала', 'Дата окончания', 'Бюджет', 'Статус'];
|
||
const rows = filtered.map(t => [
|
||
t.employee,
|
||
t.destination,
|
||
formatDate(t.dateStart),
|
||
formatDate(t.dateEnd),
|
||
t.budget.toString().replace('.', ','),
|
||
t.status
|
||
]);
|
||
|
||
const csvContent = '\uFEFF' + [headers, ...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 = 'journal.csv';
|
||
link.click();
|
||
|
||
addLog('Выгрузка', `Экспорт ${filtered.length} записей в CSV`);
|
||
}
|
||
|
||
document.getElementById('search-input').addEventListener('input', (e) => {
|
||
searchQuery = e.target.value;
|
||
renderTable();
|
||
});
|
||
|
||
document.getElementById('status-filters').addEventListener('click', (e) => {
|
||
if (e.target.classList.contains('kt-ai-btn')) {
|
||
document.querySelectorAll('#status-filters .kt-ai-btn').forEach(btn => {
|
||
btn.setAttribute('data-variant', 'ghost');
|
||
});
|
||
e.target.setAttribute('data-variant', 'primary');
|
||
currentFilter = e.target.getAttribute('data-status');
|
||
renderTable();
|
||
}
|
||
});
|
||
|
||
loadData();
|
||
updateStats();
|
||
renderTable();
|
||
renderLog();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|