napishi-programmu-ispolnenie/index.html

289 lines
13 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Исполнение бюджета закупок</title>
<style>
* { margin: 0; box-sizing: border-box; }
body { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; background: #f5f5f7; color: #1d1d1f; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
header { background: linear-gradient(135deg, #0071e3 0%, #005bb5 100%); color: white; padding: 30px 20px; margin-bottom: 30px; }
header h1 { font-size: 28px; margin-bottom: 8px; }
header p { opacity: 0.9; font-size: 14px; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 20px; margin-bottom: 30px; }
.stat-card { background: white; padding: 20px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.stat-card .value { font-size: 28px; font-weight: bold; color: #0071e3; }
.stat-card .label { font-size: 13px; color: #86868b; margin-top: 4px; }
.filters { background: white; padding: 20px; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); margin-bottom: 20px; }
.filters-row { display: flex; gap: 15px; flex-wrap: wrap; align-items: center; }
.filters input, .filters select { padding: 10px 14px; border: 1px solid #d2d2d7; border-radius: 8px; font-size: 14px; }
.filters input { flex: 1; min-width: 250px; }
.filters button { padding: 10px 20px; background: #0071e3; color: white; border: none; border-radius: 8px; cursor: pointer; font-size: 14px; }
.filters button:hover { background: #005bb5; }
.filters button.secondary { background: #86868b; }
.filters button.secondary:hover { background: #5e5e60; }
.table-container { background: white; border-radius: 12px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); overflow: hidden; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead { background: #f5f5f7; position: sticky; top: 0; }
th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #e5e5e5; }
th { font-weight: 600; color: #1d1d1f; white-space: nowrap; cursor: pointer; }
th:hover { background: #e5e5e5; }
tr:hover { background: #f5f5f7; }
.amount { text-align: right; font-family: "SF Mono", Monaco, monospace; }
.percent { text-align: right; font-family: "SF Mono", Monaco, monospace; }
.status-badge { display: inline-block; padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; }
.status-ok { background: #d4edda; color: #155724; }
.status-warn { background: #fff3cd; color: #856404; }
.status-error { background: #f8d7da; color: #721c24; }
.pagination { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; border-top: 1px solid #e5e5e5; }
.pagination-info { font-size: 13px; color: #86868b; }
.pagination-controls { display: flex; gap: 8px; }
.pagination-controls button { padding: 8px 16px; border: 1px solid #d2d2d7; background: white; border-radius: 6px; cursor: pointer; }
.pagination-controls button:disabled { opacity: 0.5; cursor: not-allowed; }
.pagination-controls button:hover:not(:disabled) { background: #f5f5f7; }
.export-btn { position: fixed; bottom: 30px; right: 30px; padding: 14px 24px; background: #34c759; color: white; border: none; border-radius: 12px; font-size: 14px; font-weight: 600; cursor: pointer; box-shadow: 0 4px 12px rgba(52,199,89,0.4); }
.export-btn:hover { background: #28a745; }
.loading { text-align: center; padding: 60px 20px; color: #86868b; }
</style>
</head>
<body>
<header>
<div class="container">
<h1>Исполнение бюджета закупок</h1>
<p>Мониторинг и анализ исполнения бюджета по статьям закупок</p>
</div>
</header>
<div class="container">
<div class="stats" id="stats"></div>
<div class="filters">
<div class="filters-row">
<input type="text" id="searchInput" placeholder="Поиск по названию услуги, подразделению, статье...">
<select id="statusFilter">
<option value="">Все статусы</option>
<option value="100">100% исполнение</option>
<option value="50-99">50-99% исполнение</option>
<option value="0-49">0-49% исполнение</option>
</select>
<button onclick="applyFilters()">Применить</button>
<button class="secondary" onclick="resetFilters()">Сбросить</button>
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th onclick="sort('articleCode')">Код статьи ↕</th>
<th onclick="sort('serviceName')">Наименование услуги ↕</th>
<th onclick="sort('department')">Подразделение ↕</th>
<th class="amount" onclick="sort('budgetSum')">Бюджет ↕</th>
<th class="amount" onclick="sort('executedSum')">Исполнено ↕</th>
<th class="percent" onclick="sort('execPercent')">Исполнение % ↕</th>
<th onclick="sort('status')">Статус ↕</th>
</tr>
</thead>
<tbody id="tableBody"></tbody>
</table>
<div class="pagination">
<div class="pagination-info" id="paginationInfo"></div>
<div class="pagination-controls">
<button onclick="prevPage()" id="prevBtn">Назад</button>
<button onclick="nextPage()" id="nextBtn">Вперёд</button>
</div>
</div>
</div>
</div>
<button class="export-btn" onclick="exportToExcel()">📊 Выгрузить в Excel</button>
<script>
let allData = [];
let filteredData = [];
let currentPage = 1;
const rowsPerPage = 25;
let currentSort = null;
let sortAsc = true;
async function loadData() {
try {
const response = await fetch('data.json');
allData = await response.json();
filteredData = [...allData];
renderStats();
renderTable();
updatePagination();
} catch (error) {
document.getElementById('tableBody').innerHTML = '<tr><td colspan="7" class="loading">Ошибка загрузки данных: ' + error.message + '</td></tr>';
}
}
function formatAmount(num) {
return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num);
}
function getStatusClass(percent) {
if (percent >= 95) return 'status-ok';
if (percent >= 50) return 'status-warn';
return 'status-error';
}
function getStatusText(percent) {
if (percent >= 95) return 'Исполнено';
if (percent >= 50) return 'В работе';
return 'Не исполнено';
}
function renderStats() {
const totalBudget = allData.reduce((sum, r) => sum + (r.budgetSum || 0), 0);
const totalExecuted = allData.reduce((sum, r) => sum + (r.executedSum || 0), 0);
const avgPercent = totalBudget > 0 ? (totalExecuted / totalBudget) * 100 : 0;
const fullyExecuted = allData.filter(r => r.execPercent >= 95).length;
document.getElementById('stats').innerHTML =
'<div class="stat-card"><div class="value">' + formatAmount(totalBudget) + '</div><div class="label">Всего бюджет</div></div>' +
'<div class="stat-card"><div class="value">' + formatAmount(totalExecuted) + '</div><div class="label">Исполнено</div></div>' +
'<div class="stat-card"><div class="value">' + formatAmount(avgPercent) + '%</div><div class="label">Среднее исполнение</div></div>' +
'<div class="stat-card"><div class="value">' + fullyExecuted + '</div><div class="label">Полностью исполнено</div></div>';
}
function renderTable() {
const start = (currentPage - 1) * rowsPerPage;
const end = start + rowsPerPage;
const pageData = filteredData.slice(start, end);
const tbody = document.getElementById('tableBody');
if (pageData.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="loading">Данные не найдены</td></tr>';
return;
}
tbody.innerHTML = pageData.map(function(r) {
const statusClass = getStatusClass(r.execPercent || 0);
const statusText = getStatusText(r.execPercent || 0);
return '<tr>' +
'<td>' + (r.articleCode || '-') + '</td>' +
'<td>' + (r.serviceName || '-') + '</td>' +
'<td>' + (r.department || '-') + '</td>' +
'<td class="amount">' + formatAmount(r.budgetSum || 0) + '</td>' +
'<td class="amount">' + formatAmount(r.executedSum || 0) + '</td>' +
'<td class="percent">' + formatAmount(r.execPercent || 0) + '%</td>' +
'<td><span class="status-badge ' + statusClass + '">' + statusText + '</span></td>' +
'</tr>';
}).join('');
}
function updatePagination() {
const totalPages = Math.ceil(filteredData.length / rowsPerPage);
document.getElementById('paginationInfo').textContent = 'Показано ' +
Math.min((currentPage - 1) * rowsPerPage + 1, filteredData.length) + '-' +
Math.min(currentPage * rowsPerPage, filteredData.length) + ' из ' + filteredData.length;
document.getElementById('prevBtn').disabled = currentPage === 1;
document.getElementById('nextBtn').disabled = currentPage === totalPages || totalPages === 0;
}
function applyFilters() {
const search = document.getElementById('searchInput').value.toLowerCase();
const status = document.getElementById('statusFilter').value;
filteredData = allData.filter(function(r) {
const matchesSearch = !search ||
(r.serviceName && r.serviceName.toLowerCase().includes(search)) ||
(r.department && r.department.toLowerCase().includes(search)) ||
(r.articleCode && r.articleCode.toLowerCase().includes(search));
let matchesStatus = true;
if (status === '100') matchesStatus = r.execPercent >= 95;
else if (status === '50-99') matchesStatus = r.execPercent >= 50 && r.execPercent < 95;
else if (status === '0-49') matchesStatus = r.execPercent < 50;
return matchesSearch && matchesStatus;
});
currentPage = 1;
renderTable();
updatePagination();
}
function resetFilters() {
document.getElementById('searchInput').value = '';
document.getElementById('statusFilter').value = '';
filteredData = [...allData];
currentPage = 1;
renderTable();
updatePagination();
}
function sort(field) {
if (currentSort === field) {
sortAsc = !sortAsc;
} else {
currentSort = field;
sortAsc = true;
}
filteredData.sort(function(a, b) {
let aVal = a[field] || 0;
let bVal = b[field] || 0;
if (typeof aVal === 'string') aVal = aVal.toLowerCase();
if (typeof bVal === 'string') bVal = bVal.toLowerCase();
if (aVal < bVal) return sortAsc ? -1 : 1;
if (aVal > bVal) return sortAsc ? 1 : -1;
return 0;
});
renderTable();
}
function prevPage() {
if (currentPage > 1) {
currentPage--;
renderTable();
updatePagination();
}
}
function nextPage() {
const totalPages = Math.ceil(filteredData.length / rowsPerPage);
if (currentPage < totalPages) {
currentPage++;
renderTable();
updatePagination();
}
}
function exportToExcel() {
const head = ["Код статьи", "Наименование услуги", "Подразделение", "Бюджет", "Исполнено", "Исполнение %", "Статус"];
const body = filteredData.map(r => [
r.articleCode || '',
r.serviceName || '',
r.department || '',
r.budgetSum || 0,
r.executedSum || 0,
(r.execPercent || 0).toFixed(2),
getStatusText(r.execPercent || 0)
]);
const csv = '\uFEFF' + [head.join(';'), ...body.map(row =>
row.map(v => '"' + String(v).replace(/"/g, '""') + '"').join(';')
)].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = 'ispolnenie_byudzheta_' + new Date().toISOString().slice(0,10) + '.csv';
link.click();
}
document.getElementById('searchInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') applyFilters();
});
loadData();
</script>
</body>
</html>