napishi-programmu-ispolnenie/index.html

364 lines
17 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: #0f0f0f; color: #e0e0e0; }
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); color: #e0e0e0; padding: 30px 20px; margin-bottom: 30px; border-bottom: 1px solid #2a2a3e; }
header h1 { font-size: 28px; margin-bottom: 8px; color: #ffffff; }
header p { opacity: 0.8; font-size: 14px; color: #a0a0a0; }
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; margin-bottom: 30px; }
.stat-card { background: #1a1a1a; padding: 20px; border-radius: 8px; border: 1px solid #2a2a2a; overflow: hidden; }
.stat-card .value { font-size: 24px; font-weight: bold; color: #4a9eff; word-break: break-word; line-height: 1.3; }
.stat-card .label { font-size: 13px; color: #808080; margin-top: 4px; }
.filters { background: #1a1a1a; padding: 20px; border-radius: 8px; border: 1px solid #2a2a2a; 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 #3a3a3a; border-radius: 6px; font-size: 14px; background: #252525; color: #e0e0e0; }
.filters input { flex: 1; min-width: 250px; }
.filters input::placeholder { color: #606060; }
.filters button { padding: 10px 20px; background: #4a9eff; color: #000; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500; }
.filters button:hover { background: #3a8eef; }
.filters button.secondary { background: #404040; color: #e0e0e0; }
.filters button.secondary:hover { background: #505050; }
.table-container { background: #1a1a1a; border-radius: 8px; border: 1px solid #2a2a2a; overflow: hidden; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
thead { background: #252525; position: sticky; top: 0; }
th, td { padding: 12px 15px; text-align: left; border-bottom: 1px solid #2a2a2a; }
th { font-weight: 600; color: #c0c0c0; white-space: nowrap; cursor: pointer; }
th:hover { background: #303030; }
tr:hover { background: #252525; }
.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: rgba(34, 197, 94, 0.2); color: #4ade80; border: 1px solid #22c55e; }
.status-warn { background: rgba(234, 179, 8, 0.2); color: #facc15; border: 1px solid #eab308; }
.status-error { background: rgba(239, 68, 68, 0.2); color: #f87171; border: 1px solid #ef4444; }
.pagination { display: flex; justify-content: space-between; align-items: center; padding: 15px 20px; border-top: 1px solid #2a2a2a; background: #1f1f1f; }
.pagination-info { font-size: 13px; color: #808080; }
.pagination-controls { display: flex; gap: 8px; }
.pagination-controls button { padding: 8px 16px; border: 1px solid #3a3a3a; background: #252525; color: #e0e0e0; border-radius: 6px; cursor: pointer; }
.pagination-controls button:disabled { opacity: 0.4; cursor: not-allowed; }
.pagination-controls button:hover:not(:disabled) { background: #303030; }
.loading { text-align: center; padding: 60px 20px; color: #808080; }
.about-section { background: #1a1a1a; border-radius: 8px; padding: 30px; margin-bottom: 30px; border: 1px solid #2a2a2a; }
.about-section h2 { font-size: 22px; margin-bottom: 20px; color: #e0e0e0; }
.about-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 20px; }
.about-card { background: #252525; padding: 20px; border-radius: 8px; border: 1px solid #2a2a2a; }
.about-card h3 { font-size: 16px; margin: 12px 0 8px; color: #e0e0e0; }
.about-card p { font-size: 13px; color: #a0a0a0; 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: #4ade80; }
.status-dot.status-warn { background: #facc15; }
.status-dot.status-error { background: #f87171; }
</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="procurementMethodFilter">
<option value="">Все способы закупки</option>
</select>
<select id="procurementTypeFilter">
<option value="">Все виды закупа</option>
</select>
<select id="budgetTypeFilter">
<option value="">Все типы бюджета</option>
<option value="БКВ">БКВ</option>
<option value="ОБ">ОБ</option>
</select>
<button onclick="applyFilters()">Применить</button>
<button class="secondary" onclick="resetFilters()">Сбросить</button>
</div>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th onclick="sort('uniqueObjectId')">Уник. номер ↕</th>
<th onclick="sort('lotNumber')">Номер лота ↕</th>
<th onclick="sort('sppElement')">СПП-элемент ↕</th>
<th onclick="sort('serviceName')">Наименование ↕</th>
<th onclick="sort('department')">Подразделение ↕</th>
<th onclick="sort('budgetType')">Тип бюджета ↕</th>
<th onclick="sort('procurementMethod')">Способ закупки ↕</th>
<th onclick="sort('procurementType')">Вид закупа ↕</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>
</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>
<script>
let allData = [];
let filteredData = [];
let currentPage = 1;
const rowsPerPage = 25;
let currentSort = null;
let sortAsc = true;
function populateFilters() {
const methods = [...new Set(allData.map(r => r.procurementMethod).filter(Boolean))].sort();
const types = [...new Set(allData.map(r => r.procurementType).filter(Boolean))].sort();
const methodSelect = document.getElementById('procurementMethodFilter');
const typeSelect = document.getElementById('procurementTypeFilter');
methods.forEach(m => {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = m;
methodSelect.appendChild(opt);
});
types.forEach(t => {
const opt = document.createElement('option');
opt.value = t;
opt.textContent = t;
typeSelect.appendChild(opt);
});
}
async function loadData() {
try {
const response = await fetch('data.json');
allData = await response.json();
filteredData = [...allData];
populateFilters();
renderStats();
renderTable();
updatePagination();
} catch (error) {
document.getElementById('tableBody').innerHTML = '<tr><td colspan="12" class="loading">Ошибка загрузки данных: ' + error.message + '</td></tr>';
}
}
function formatAmount(num) {
return new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 0 }).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;
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>';
}
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) {
return '<tr>' +
'<td>' + (r.uniqueObjectId || '-') + '</td>' +
'<td>' + (r.lotNumber || '-') + '</td>' +
'<td>' + (r.sppElement || '-') + '</td>' +
'<td>' + (r.serviceName || '-') + '</td>' +
'<td>' + (r.department || '-') + '</td>' +
'<td>' + (r.budgetType || '-') + '</td>' +
'<td>' + (r.procurementMethod || '-') + '</td>' +
'<td>' + (r.procurementType || '-') + '</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>' +
'</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 procurementMethod = document.getElementById('procurementMethodFilter').value;
const procurementType = document.getElementById('procurementTypeFilter').value;
const budgetType = document.getElementById('budgetTypeFilter').value;
filteredData = allData.filter(function(r) {
const matchesSearch = !search ||
(r.serviceName && r.serviceName.toLowerCase().includes(search)) ||
(r.department && r.department.toLowerCase().includes(search));
const matchesMethod = !procurementMethod || r.procurementMethod === procurementMethod;
const matchesType = !procurementType || r.procurementType === procurementType;
const matchesBudget = !budgetType || r.budgetType === budgetType;
return matchesSearch && matchesMethod && matchesType && matchesBudget;
});
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>