296 lines
14 KiB
HTML
296 lines
14 KiB
HTML
<!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 style="margin-top: 20px; padding: 15px; background: rgba(255,255,255,0.1); border-radius: 8px; font-size: 13px; line-height: 1.6;">
|
||
<strong>📊 О данных:</strong> Система отображает информацию по исполнению бюджета закупок организации.
|
||
Данные включают код статьи бюджета, наименование услуг/работ, подразделение-заказчик, плановые суммы и фактическое исполнение.
|
||
Статусы рассчитываются автоматически: <span style="color: #86efac;">зелёный</span> — исполнение 95-100%,
|
||
<span style="color: #fde68a;">жёлтый</span> — 50-94%, <span style="color: #fca5a5;">красный</span> — менее 50%.
|
||
Используйте поиск и фильтры для быстрого нахождения нужной информации.
|
||
</div>
|
||
</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>
|