479 lines
19 KiB
HTML
479 lines
19 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru" data-theme="auto">
|
||
<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; }
|
||
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--kt-ai-space-6); margin-bottom: var(--kt-ai-space-9); }
|
||
.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-6); }
|
||
.stat-value { font-size: var(--kt-ai-text-3xl); font-weight: var(--kt-ai-weight-bold); color: var(--kt-ai-primary); }
|
||
.stat-label { color: var(--kt-ai-fg-muted); margin-top: var(--kt-ai-space-2); }
|
||
.tabs { display: flex; gap: var(--kt-ai-space-2); margin-bottom: var(--kt-ai-space-6); }
|
||
.tab { padding: var(--kt-ai-space-3) var(--kt-ai-space-5); border-radius: var(--kt-ai-radius-lg); cursor: pointer; border: 1px solid transparent; }
|
||
.tab.active { background: var(--kt-ai-primary); color: white; }
|
||
.tab:hover:not(.active) { background: var(--kt-ai-bg-soft); }
|
||
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: var(--kt-ai-space-4); }
|
||
.table-wrap { overflow-x: auto; }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th, td { padding: var(--kt-ai-space-3); text-align: left; border-bottom: 1px solid var(--kt-ai-divider); }
|
||
th { background: var(--kt-ai-bg-soft); font-weight: var(--kt-ai-weight-semibold); }
|
||
tr:hover { background: var(--kt-ai-bg-soft); }
|
||
.actions { display: flex; gap: var(--kt-ai-space-2); }
|
||
.search-row { display: flex; gap: var(--kt-ai-space-3); margin-bottom: var(--kt-ai-space-4); align-items: center; }
|
||
.search-input { flex: 1; }
|
||
.chart-placeholder { background: var(--kt-ai-bg-soft); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-6); text-align: center; color: var(--kt-ai-fg-muted); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="kt-ai-pagehead">
|
||
<div class="kt-ai-wrap kt-ai-pagehead-in">
|
||
<div style="display:flex;align-items:center;gap:var(--kt-ai-space-3)">
|
||
<img src="design-system/brand/kt-ai-logo.png" alt="Logo" height="24">
|
||
<strong>Аналитика топливных талонов</strong>
|
||
</div>
|
||
<span class="kt-ai-spacer"></span>
|
||
<a class="kt-ai-btn" data-variant="ghost" href="#" onclick="showTab('table');return false;">Талоны</a>
|
||
<a class="kt-ai-btn" data-variant="ghost" href="#" onclick="showTab('stats');return false;">Аналитика</a>
|
||
<a class="kt-ai-btn" data-variant="ghost" href="#" onclick="showTab('log');return false;">Журнал</a>
|
||
</div>
|
||
</header>
|
||
|
||
<section class="kt-ai-wrap" style="padding-top:var(--kt-ai-space-9)">
|
||
|
||
<div class="tabs">
|
||
<div class="tab active" id="tab-table" onclick="showTab('table')">Талоны</div>
|
||
<div class="tab" id="tab-stats" onclick="showTab('stats')">Аналитика</div>
|
||
<div class="tab" id="tab-log" onclick="showTab('log')">Журнал операций</div>
|
||
</div>
|
||
|
||
<div id="view-table">
|
||
<div class="kt-ai-card" style="margin-bottom:var(--kt-ai-space-6)">
|
||
<h2 style="margin-bottom:var(--kt-ai-space-4)">Добавить талон</h2>
|
||
<form id="fuelForm" onsubmit="submitForm(event)">
|
||
<input type="hidden" id="cardId">
|
||
<div class="form-grid">
|
||
<div class="kt-ai-field">
|
||
<label>Дата *</label>
|
||
<input type="date" class="kt-ai-input" id="date" required>
|
||
</div>
|
||
<div class="kt-ai-field">
|
||
<label>Водитель *</label>
|
||
<input type="text" class="kt-ai-input" id="driver" placeholder="ФИО водителя" required>
|
||
</div>
|
||
<div class="kt-ai-field">
|
||
<label>Автомобиль *</label>
|
||
<input type="text" class="kt-ai-input" id="vehicle" placeholder="Марка, госномер" required>
|
||
</div>
|
||
<div class="kt-ai-field">
|
||
<label>Литраж *</label>
|
||
<input type="number" step="0.01" class="kt-ai-input" id="liters" placeholder="0.00" required>
|
||
</div>
|
||
<div class="kt-ai-field">
|
||
<label>Сумма (₸) *</label>
|
||
<input type="number" step="0.01" class="kt-ai-input" id="amount" placeholder="0.00" required>
|
||
</div>
|
||
<div class="kt-ai-field">
|
||
<label>АЗС</label>
|
||
<input type="text" class="kt-ai-input" id="gasStation" placeholder="Название АЗС">
|
||
</div>
|
||
<div class="kt-ai-field">
|
||
<label>Пробег (км)</label>
|
||
<input type="number" class="kt-ai-input" id="mileage" placeholder="0">
|
||
</div>
|
||
<div class="kt-ai-field">
|
||
<label>Статус</label>
|
||
<select class="kt-ai-select" id="status">
|
||
<option value="использован">Использован</option>
|
||
<option value="в работе">В работе</option>
|
||
<option value="потерян">Потерян</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="kt-ai-field" style="margin-top:var(--kt-ai-space-4)">
|
||
<label>Примечание</label>
|
||
<textarea class="kt-ai-textarea" id="note" rows="2" placeholder="Комментарий"></textarea>
|
||
</div>
|
||
<div style="margin-top:var(--kt-ai-space-5);display:flex;gap:var(--kt-ai-space-3)">
|
||
<button type="submit" class="kt-ai-btn" data-variant="primary">Сохранить</button>
|
||
<button type="button" class="kt-ai-btn" data-variant="ghost" onclick="resetForm()">Очистить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="kt-ai-card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--kt-ai-space-4);flex-wrap:wrap;gap:var(--kt-ai-space-3)">
|
||
<h2>Все талоны</h2>
|
||
<div style="display:flex;gap:var(--kt-ai-space-2);flex-wrap:wrap">
|
||
<button class="kt-ai-btn" data-variant="ghost" onclick="exportToExcel()">Выгрузить в Excel</button>
|
||
<button class="kt-ai-btn" data-variant="ghost" onclick="loadFuelCards()">Обновить</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="search-row">
|
||
<input type="text" class="kt-ai-input search-input" id="searchInput" placeholder="Поиск по водителю, авто, АЗС..." oninput="filterTable()">
|
||
<select class="kt-ai-select" id="filterStatus" onchange="filterTable()">
|
||
<option value="">Все статусы</option>
|
||
<option value="использован">Использован</option>
|
||
<option value="в работе">В работе</option>
|
||
<option value="потерян">Потерян</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div class="table-wrap">
|
||
<table id="fuelTable">
|
||
<thead>
|
||
<tr>
|
||
<th>Дата</th>
|
||
<th>Водитель</th>
|
||
<th>Автомобиль</th>
|
||
<th>Литры</th>
|
||
<th>Сумма</th>
|
||
<th>АЗС</th>
|
||
<th>Пробег</th>
|
||
<th>Статус</th>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="fuelTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="view-stats" style="display:none">
|
||
<div class="stats-grid">
|
||
<div class="stat-card">
|
||
<div class="stat-value" id="statTotalCards">0</div>
|
||
<div class="stat-label">Всего талонов</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value" id="statTotalLiters">0</div>
|
||
<div class="stat-label">Общий литраж (л)</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value" id="statTotalAmount">0 ₸</div>
|
||
<div class="stat-label">Общая сумма</div>
|
||
</div>
|
||
<div class="stat-card">
|
||
<div class="stat-value" id="statAvgPrice">0 ₸/л</div>
|
||
<div class="stat-label">Средняя цена/л</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="kt-ai-card" style="margin-bottom:var(--kt-ai-space-6)">
|
||
<h3 style="margin-bottom:var(--kt-ai-space-4)">По водителям</h3>
|
||
<div class="table-wrap">
|
||
<table id="byDriverTable">
|
||
<thead>
|
||
<tr>
|
||
<th>Водитель</th>
|
||
<th>Кол-во талонов</th>
|
||
<th>Литры</th>
|
||
<th>Сумма (₸)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="kt-ai-card" style="margin-bottom:var(--kt-ai-space-6)">
|
||
<h3 style="margin-bottom:var(--kt-ai-space-4)">По автомобилям</h3>
|
||
<div class="table-wrap">
|
||
<table id="byVehicleTable">
|
||
<thead>
|
||
<tr>
|
||
<th>Автомобиль</th>
|
||
<th>Кол-во талонов</th>
|
||
<th>Литры</th>
|
||
<th>Сумма (₸)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="kt-ai-card">
|
||
<h3 style="margin-bottom:var(--kt-ai-space-4)">По АЗС</h3>
|
||
<div class="table-wrap">
|
||
<table id="byGasStationTable">
|
||
<thead>
|
||
<tr>
|
||
<th>АЗС</th>
|
||
<th>Кол-во талонов</th>
|
||
<th>Литры</th>
|
||
<th>Сумма (₸)</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="view-log" style="display:none">
|
||
<div class="kt-ai-card">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:var(--kt-ai-space-4)">
|
||
<h2>Журнал операций</h2>
|
||
<button class="kt-ai-btn" data-variant="ghost" onclick="loadLog()">Обновить</button>
|
||
</div>
|
||
<div class="table-wrap">
|
||
<table id="logTable">
|
||
<thead>
|
||
<tr>
|
||
<th>Время</th>
|
||
<th>Действие</th>
|
||
<th>Пользователь</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="logTableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</section>
|
||
|
||
<footer class="kt-ai-wrap kt-ai-pagefoot" style="margin-top:var(--kt-ai-space-12)">
|
||
<span>© 2026 Аналитика топливных талонов</span>
|
||
<span class="kt-ai-spacer"></span>
|
||
<span>Учёт и контроль расхода ГСМ</span>
|
||
</footer>
|
||
|
||
<script>
|
||
let allCards = [];
|
||
|
||
function showTab(name) {
|
||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||
document.getElementById('tab-' + name).classList.add('active');
|
||
document.getElementById('view-table').style.display = name === 'table' ? 'block' : 'none';
|
||
document.getElementById('view-stats').style.display = name === 'stats' ? 'block' : 'none';
|
||
document.getElementById('view-log').style.display = name === 'log' ? 'block' : 'none';
|
||
if (name === 'stats') loadStats();
|
||
if (name === 'log') loadLog();
|
||
}
|
||
|
||
document.getElementById('date').valueAsDate = new Date();
|
||
|
||
async function loadFuelCards() {
|
||
try {
|
||
const res = await fetch('api/fuel-cards');
|
||
allCards = await res.json();
|
||
renderTable(allCards);
|
||
} catch (err) {
|
||
console.error('Ошибка загрузки:', err);
|
||
}
|
||
}
|
||
|
||
function renderTable(cards) {
|
||
const tbody = document.getElementById('fuelTableBody');
|
||
tbody.innerHTML = '';
|
||
cards.forEach(card => {
|
||
const tr = document.createElement('tr');
|
||
const statusClass = card.status === 'потерян' ? 'data-variant="danger"' : '';
|
||
tr.innerHTML =
|
||
'<td>' + formatDate(card.date) + '</td>' +
|
||
'<td>' + escapeHtml(card.driver) + '</td>' +
|
||
'<td>' + escapeHtml(card.vehicle) + '</td>' +
|
||
'<td>' + parseFloat(card.liters).toFixed(2) + '</td>' +
|
||
'<td>' + parseFloat(card.amount).toFixed(2) + ' ₸</td>' +
|
||
'<td>' + escapeHtml(card.gasStation || '-') + '</td>' +
|
||
'<td>' + (card.mileage ? card.mileage : '-') + '</td>' +
|
||
'<td><span class="kt-ai-chip" ' + statusClass + '>' + escapeHtml(card.status) + '</span></td>' +
|
||
'<td class="actions">' +
|
||
'<button class="kt-ai-btn" data-variant="ghost" onclick="editCard(' + card.id + ')">✏️</button>' +
|
||
'<button class="kt-ai-btn" data-variant="ghost" onclick="deleteCard(' + card.id + ')">🗑️</button>' +
|
||
'</td>';
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
if (!dateStr) return '-';
|
||
const d = new Date(dateStr);
|
||
const day = String(d.getDate()).padStart(2, '0');
|
||
const month = String(d.getMonth() + 1).padStart(2, '0');
|
||
const year = d.getFullYear();
|
||
return day + '.' + month + '.' + year;
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
if (!text) return '';
|
||
return String(text).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|
||
|
||
function filterTable() {
|
||
const search = document.getElementById('searchInput').value.toLowerCase();
|
||
const status = document.getElementById('filterStatus').value;
|
||
const filtered = allCards.filter(card => {
|
||
const matchSearch = !search ||
|
||
(card.driver && card.driver.toLowerCase().includes(search)) ||
|
||
(card.vehicle && card.vehicle.toLowerCase().includes(search)) ||
|
||
(card.gasStation && card.gasStation.toLowerCase().includes(search));
|
||
const matchStatus = !status || card.status === status;
|
||
return matchSearch && matchStatus;
|
||
});
|
||
renderTable(filtered);
|
||
}
|
||
|
||
async function submitForm(e) {
|
||
e.preventDefault();
|
||
const id = document.getElementById('cardId').value;
|
||
const card = {
|
||
date: document.getElementById('date').value,
|
||
driver: document.getElementById('driver').value,
|
||
vehicle: document.getElementById('vehicle').value,
|
||
liters: document.getElementById('liters').value,
|
||
amount: document.getElementById('amount').value,
|
||
gasStation: document.getElementById('gasStation').value,
|
||
mileage: document.getElementById('mileage').value,
|
||
status: document.getElementById('status').value,
|
||
note: document.getElementById('note').value
|
||
};
|
||
|
||
try {
|
||
const url = id ? 'api/fuel-cards' : 'api/fuel-cards';
|
||
const method = id ? 'PUT' : 'POST';
|
||
if (id) card.id = parseInt(id);
|
||
|
||
const res = await fetch(url, {
|
||
method: method,
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(card)
|
||
});
|
||
|
||
if (res.ok) {
|
||
resetForm();
|
||
loadFuelCards();
|
||
alert(id ? 'Талон обновлён' : 'Талон добавлен');
|
||
} else {
|
||
alert('Ошибка сохранения');
|
||
}
|
||
} catch (err) {
|
||
console.error(err);
|
||
alert('Ошибка: ' + err.message);
|
||
}
|
||
}
|
||
|
||
function resetForm() {
|
||
document.getElementById('cardId').value = '';
|
||
document.getElementById('fuelForm').reset();
|
||
document.getElementById('date').valueAsDate = new Date();
|
||
}
|
||
|
||
async function editCard(id) {
|
||
const card = allCards.find(c => c.id === id);
|
||
if (!card) return;
|
||
document.getElementById('cardId').value = card.id;
|
||
document.getElementById('date').value = card.date;
|
||
document.getElementById('driver').value = card.driver;
|
||
document.getElementById('vehicle').value = card.vehicle;
|
||
document.getElementById('liters').value = card.liters;
|
||
document.getElementById('amount').value = card.amount;
|
||
document.getElementById('gasStation').value = card.gasStation || '';
|
||
document.getElementById('mileage').value = card.mileage || '';
|
||
document.getElementById('status').value = card.status;
|
||
document.getElementById('note').value = card.note || '';
|
||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||
}
|
||
|
||
async function deleteCard(id) {
|
||
if (!confirm('Удалить этот талон?')) return;
|
||
try {
|
||
const res = await fetch('api/fuel-cards/' + id, { method: 'DELETE' });
|
||
if (res.ok) {
|
||
loadFuelCards();
|
||
} else {
|
||
alert('Ошибка удаления');
|
||
}
|
||
} catch (err) {
|
||
alert('Ошибка: ' + err.message);
|
||
}
|
||
}
|
||
|
||
async function loadStats() {
|
||
try {
|
||
const res = await fetch('api/stats');
|
||
const stats = await res.json();
|
||
|
||
document.getElementById('statTotalCards').textContent = stats.totalCards;
|
||
document.getElementById('statTotalLiters').textContent = parseFloat(stats.totalLiters).toFixed(2);
|
||
document.getElementById('statTotalAmount').textContent = parseFloat(stats.totalAmount).toFixed(2) + ' ₸';
|
||
document.getElementById('statAvgPrice').textContent = parseFloat(stats.avgPricePerLiter).toFixed(2) + ' ₸/л';
|
||
|
||
renderStatsTable('byDriverTable', stats.byDriver);
|
||
renderStatsTable('byVehicleTable', stats.byVehicle);
|
||
renderStatsTable('byGasStationTable', stats.byGasStation);
|
||
} catch (err) {
|
||
console.error('Ошибка загрузки статистики:', err);
|
||
}
|
||
}
|
||
|
||
function renderStatsTable(tableId, data) {
|
||
const tbody = document.querySelector('#' + tableId + ' tbody');
|
||
tbody.innerHTML = '';
|
||
Object.entries(data).forEach(([name, vals]) => {
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML =
|
||
'<td>' + escapeHtml(name) + '</td>' +
|
||
'<td>' + vals.count + '</td>' +
|
||
'<td>' + parseFloat(vals.liters).toFixed(2) + '</td>' +
|
||
'<td>' + parseFloat(vals.amount).toFixed(2) + '</td>';
|
||
tbody.appendChild(tr);
|
||
});
|
||
}
|
||
|
||
async function loadLog() {
|
||
try {
|
||
const res = await fetch('api/log');
|
||
const logs = await res.json();
|
||
const tbody = document.getElementById('logTableBody');
|
||
tbody.innerHTML = '';
|
||
logs.forEach(log => {
|
||
const tr = document.createElement('tr');
|
||
const time = new Date(log.when).toLocaleString('ru-RU');
|
||
tr.innerHTML =
|
||
'<td>' + time + '</td>' +
|
||
'<td>' + escapeHtml(log.what) + '</td>' +
|
||
'<td>' + escapeHtml(log.who) + '</td>';
|
||
tbody.appendChild(tr);
|
||
});
|
||
} catch (err) {
|
||
console.error('Ошибка загрузки журнала:', err);
|
||
}
|
||
}
|
||
|
||
function exportToExcel() {
|
||
const head = ["Дата", "Водитель", "Автомобиль", "Литры", "Сумма", "АЗС", "Пробег", "Статус", "Примечание"];
|
||
const body = allCards.map(r => [
|
||
r.date,
|
||
r.driver,
|
||
r.vehicle,
|
||
r.liters,
|
||
r.amount,
|
||
r.gasStation || '',
|
||
r.mileage || '',
|
||
r.status,
|
||
r.note || ''
|
||
].map(v => '"' + String(v ?? "").replace(/"/g, '""') + '"').join(";"));
|
||
const csv = "\uFEFF" + [head.join(";"), ...body].join("\n");
|
||
const a = document.createElement("a");
|
||
a.href = URL.createObjectURL(new Blob([csv], { type: "text/csv" }));
|
||
a.download = "toplivnye-talony-" + new Date().toISOString().slice(0,10) + ".csv";
|
||
a.click();
|
||
}
|
||
|
||
loadFuelCards();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|