napishi-programmu-ispolnenie/index.html

338 lines
16 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; }
.about-section { background: white; border-radius: 12px; padding: 30px; margin-bottom: 30px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
.about-section h2 { font-size: 22px; margin-bottom: 20px; color: #1d1d1f; }
.about-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; }
.about-card { background: #f5f5f7; padding: 20px; border-radius: 10px; }
.about-card h3 { font-size: 16px; margin: 12px 0 8px; color: #1d1d1f; }
.about-card p { font-size: 13px; color: #515154; line-height: 1.6; margin: 0; }
.about-icon { font-size: 32px; }
.status-dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 6px; }
.status-dot.status-ok { background: #22c55e; }
.status-dot.status-warn { background: #eab308; }
.status-dot.status-error { background: #ef4444; }
</style>
</head>
<body>
<header>
<div class="container">
<h1>Исполнение бюджета закупок</h1>
<p>Мониторинг и анализ исполнения бюджета по статьям закупок</p>
</div>
</header>
<div class="container">
<section class="about-section">
<h2>📊 О системе</h2>
<div class="about-grid">
<div class="about-card">
<div class="about-icon">📋</div>
<h3>Что это</h3>
<p>Система отображает информацию по исполнению бюджета закупок организации в режиме реального времени</p>
</div>
<div class="about-card">
<div class="about-icon">📈</div>
<h3>Данные</h3>
<p>Краткий текст материала, подразделение (МВЗ), тип бюджета (БКВ/ОБ), бюджет закупок, сумма поставки, экономия</p>
</div>
<div class="about-card">
<div class="about-icon">🎯</div>
<h3>Статусы</h3>
<p><span class="status-dot status-ok"></span> 95-100% — Исполнено<br><span class="status-dot status-warn"></span> 50-94% — В работе<br><span class="status-dot status-error"></span> &lt;50% — Не исполнено</p>
</div>
<div class="about-card">
<div class="about-icon">🔍</div>
<h3>Возможности</h3>
<p>Поиск по названию услуг и статьям, фильтры по проценту исполнения, выгрузка данных в Excel</p>
</div>
</div>
</section>
</div>
<div class="container">
<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('serviceName')">Наименование ↕</th>
<th onclick="sort('department')">Подразделение ↕</th>
<th onclick="sort('budgetType')">Тип бюджета ↕</th>
<th class="amount" onclick="sort('budgetSum')">Бюджет закупок, тг ↕</th>
<th class="amount" onclick="sort('executedSum')">Сумма поставки, тг ↕</th>
<th class="amount" onclick="sort('economy')">Экономия, тг ↕</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 totalEconomy = allData.reduce((sum, r) => sum + (r.economy || 0), 0);
const avgPercent = totalBudget > 0 ? (totalExecuted / totalBudget) * 100 : 0;
const fullyExecuted = allData.filter(r => r.execPercent >= 95).length;
const bkvCount = allData.filter(r => r.budgetType === 'БКВ').length;
const obCount = allData.filter(r => r.budgetType === 'ОБ').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(totalEconomy) + '</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">' + bkvCount + '</div><div class="label">БКВ</div></div>' +
'<div class="stat-card"><div class="value">' + obCount + '</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.serviceName || '-') + '</td>' +
'<td>' + (r.department || '-') + '</td>' +
'<td>' + (r.budgetType || '-') + '</td>' +
'<td class="amount">' + formatAmount(r.budgetSum || 0) + '</td>' +
'<td class="amount">' + formatAmount(r.executedSum || 0) + '</td>' +
'<td class="amount" style="color: ' + (r.economy > 0 ? '#22c55e' : '#666') + '">' + formatAmount(r.economy || 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.serviceName || '',
r.department || '',
r.budgetType || '',
r.budgetSum || 0,
r.executedSum || 0,
r.economy || 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>