387 lines
18 KiB
HTML
387 lines
18 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; padding: 0; box-sizing: border-box; }
|
||
body { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; background: #f5f7fa; color: #1a1a1a; }
|
||
.header { background: linear-gradient(135deg, #1e3a5f 0%, #2d5a87 100%); color: white; padding: 24px 32px; }
|
||
.header h1 { font-size: 28px; margin-bottom: 8px; }
|
||
.header p { opacity: 0.9; font-size: 14px; }
|
||
.container { max-width: 1400px; margin: 0 auto; padding: 24px; }
|
||
|
||
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 24px; }
|
||
.card { background: white; border-radius: 12px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||
.card-label { font-size: 13px; color: #6b7280; margin-bottom: 8px; }
|
||
.card-value { font-size: 28px; font-weight: 700; color: #1e3a5f; }
|
||
.card-value.green { color: #059669; }
|
||
.card-value.orange { color: #d97706; }
|
||
.card-value.red { color: #dc2626; }
|
||
.card-sub { font-size: 12px; color: #9ca3af; margin-top: 4px; }
|
||
|
||
.filters { background: white; border-radius: 12px; padding: 16px 20px; margin-bottom: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.08); display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||
.filters input { padding: 10px 14px; border: 1px solid #e5e7eb; border-radius: 8px; font-size: 14px; min-width: 250px; }
|
||
.filters select { padding: 10px 14px; border: 1px solid #e5e7eb; border-radius: 8px; font-size: 14px; background: white; }
|
||
.filters button { padding: 10px 20px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; transition: all 0.2s; }
|
||
.filters .btn-primary { background: #2d5a87; color: white; }
|
||
.filters .btn-primary:hover { background: #1e3a5f; }
|
||
.filters .btn-outline { background: white; border: 1px solid #e5e7eb; color: #374151; }
|
||
.filters .btn-outline:hover { background: #f9fafb; }
|
||
|
||
.chips { display: flex; gap: 8px; flex-wrap: wrap; }
|
||
.chip { padding: 6px 14px; border-radius: 20px; font-size: 13px; cursor: pointer; border: 1px solid #e5e7eb; background: white; transition: all 0.2s; }
|
||
.chip:hover { background: #f3f4f6; }
|
||
.chip.active { background: #2d5a87; color: white; border-color: #2d5a87; }
|
||
|
||
.table-container { background: white; border-radius: 12px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th { background: #f9fafb; text-align: left; padding: 14px 16px; font-size: 12px; font-weight: 600; color: #6b7280; border-bottom: 2px solid #e5e7eb; white-space: nowrap; }
|
||
td { padding: 14px 16px; border-bottom: 1px solid #f3f4f6; font-size: 13px; }
|
||
tr:hover { background: #f9fafb; }
|
||
.status { padding: 4px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; display: inline-block; }
|
||
.status.paid { background: #d1fae5; color: #065f46; }
|
||
.status.pending { background: #fef3c7; color: #92400e; }
|
||
.status.draft { background: #e5e7eb; color: #374151; }
|
||
.progress-bar { width: 100px; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }
|
||
.progress-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #10b981); border-radius: 4px; transition: width 0.3s; }
|
||
.actions { display: flex; gap: 8px; }
|
||
.actions button { padding: 6px 12px; border: none; border-radius: 6px; font-size: 12px; cursor: pointer; }
|
||
.btn-edit { background: #dbeafe; color: #1e40af; }
|
||
.btn-delete { background: #fee2e2; color: #991b1b; }
|
||
|
||
.modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 1000; }
|
||
.modal-overlay.on { display: flex; }
|
||
.modal { background: white; border-radius: 12px; padding: 32px; max-width: 600px; width: 90%; max-height: 90vh; overflow-y: auto; }
|
||
.modal h2 { margin-bottom: 20px; color: #1e3a5f; }
|
||
.form-group { margin-bottom: 16px; }
|
||
.form-group label { display: block; margin-bottom: 6px; font-size: 13px; color: #374151; font-weight: 500; }
|
||
.form-group input, .form-group select, .form-group textarea { width: 100%; padding: 10px 14px; border: 1px solid #e5e7eb; border-radius: 8px; font-size: 14px; }
|
||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
.modal-actions { display: flex; gap: 12px; justify-content: flex-end; margin-top: 24px; }
|
||
.modal-actions button { padding: 10px 24px; border: none; border-radius: 8px; font-size: 14px; cursor: pointer; }
|
||
.btn-cancel { background: #f3f4f6; color: #374151; }
|
||
.btn-save { background: #2d5a87; color: white; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="header">
|
||
<h1>📊 Мониторинг исполнения бюджета закупок</h1>
|
||
<p>Филиал ДЦБ • Актуально на <span id="currentDate"></span></p>
|
||
</div>
|
||
|
||
<div class="container">
|
||
<div class="cards">
|
||
<div class="card">
|
||
<div class="card-label">Всего закупок</div>
|
||
<div class="card-value" id="totalCount">0</div>
|
||
<div class="card-sub">записей в реестре</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-label">Общий бюджет</div>
|
||
<div class="card-value" id="totalBudget">0 ₸</div>
|
||
<div class="card-sub">плановая сумма</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-label">Исполнено</div>
|
||
<div class="card-value green" id="totalSpent">0 ₸</div>
|
||
<div class="card-sub">фактические расходы</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-label">Остаток</div>
|
||
<div class="card-value orange" id="totalRemaining">0 ₸</div>
|
||
<div class="card-sub">не освоено</div>
|
||
</div>
|
||
<div class="card">
|
||
<div class="card-label">% исполнения</div>
|
||
<div class="card-value red" id="avgPercent">0%</div>
|
||
<div class="card-sub">средний показатель</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filters">
|
||
<input type="text" id="searchInput" placeholder="Поиск по названию, поставщику, номеру договора...">
|
||
<select id="statusFilter">
|
||
<option value="">Все статусы</option>
|
||
<option value="оплачена">Оплачена</option>
|
||
<option value="в работе">В работе</option>
|
||
<option value="планируется">Планируется</option>
|
||
</select>
|
||
<select id="departmentFilter">
|
||
<option value="">Все отделы</option>
|
||
</select>
|
||
<div class="chips" id="statusChips"></div>
|
||
<button class="btn-primary" onclick="openModal()">+ Добавить закупку</button>
|
||
<button class="btn-outline" onclick="exportToExcel()">📥 Выгрузить в Excel</button>
|
||
</div>
|
||
|
||
<div class="table-container">
|
||
<table>
|
||
<thead>
|
||
<tr>
|
||
<th>№</th>
|
||
<th>Код статьи</th>
|
||
<th>Наименование закупки</th>
|
||
<th>Отдел</th>
|
||
<th>Поставщик</th>
|
||
<th>Бюджет (₸)</th>
|
||
<th>Факт (₸)</th>
|
||
<th>Остаток (₸)</th>
|
||
<th>% исп.</th>
|
||
<th>Статус</th>
|
||
<th>Действия</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="tableBody"></tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal-overlay" id="modalOverlay">
|
||
<div class="modal">
|
||
<h2 id="modalTitle">Добавить закупку</h2>
|
||
<form id="procurementForm">
|
||
<input type="hidden" id="editId">
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Код статьи</label>
|
||
<input type="text" id="articleCode" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Статус</label>
|
||
<select id="status" required>
|
||
<option value="оплачена">Оплачена</option>
|
||
<option value="в работе">В работе</option>
|
||
<option value="планируется">Планируется</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Наименование закупки</label>
|
||
<input type="text" id="name" required>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Отдел</label>
|
||
<input type="text" id="department" required>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Поставщик</label>
|
||
<input type="text" id="supplier" required>
|
||
</div>
|
||
</div>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label>Бюджет (₸)</label>
|
||
<input type="number" id="budget" required step="0.01">
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Факт (₸)</label>
|
||
<input type="number" id="spent" required step="0.01">
|
||
</div>
|
||
</div>
|
||
<div class="form-group">
|
||
<label>Номер договора</label>
|
||
<input type="text" id="contractNumber">
|
||
</div>
|
||
<div class="modal-actions">
|
||
<button type="button" class="btn-cancel" onclick="closeModal()">Отмена</button>
|
||
<button type="submit" class="btn-save">Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<script>
|
||
document.getElementById('currentDate').textContent = new Date().toLocaleDateString('ru-RU', { year: 'numeric', month: 'long', day: 'numeric' });
|
||
|
||
let items = [];
|
||
let editingId = null;
|
||
|
||
async function loadItems() {
|
||
try {
|
||
const res = await fetch('/api/items');
|
||
items = await res.json();
|
||
if (items.length === 0) {
|
||
await fetch('/api/items', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
articleCode: 'B5-2',
|
||
name: 'Тех.поддержка информационных систем',
|
||
department: 'Департамент бизнес решений',
|
||
supplier: 'ТОО КТ "Cloud Lab"',
|
||
budget: 33547994.50,
|
||
spent: 33547994.50,
|
||
status: 'оплачена',
|
||
contractNumber: '9800118445'
|
||
})
|
||
});
|
||
items = await (await fetch('/api/items')).json();
|
||
}
|
||
render();
|
||
} catch (e) {
|
||
console.error('Ошибка загрузки:', e);
|
||
}
|
||
}
|
||
|
||
function formatMoney(amount) {
|
||
return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'KZT', maximumFractionDigits: 0 }).format(amount);
|
||
}
|
||
|
||
function calcStats() {
|
||
const total = items.length;
|
||
const budget = items.reduce((s, i) => s + (parseFloat(i.budget) || 0), 0);
|
||
const spent = items.reduce((s, i) => s + (parseFloat(i.spent) || 0), 0);
|
||
const remaining = budget - spent;
|
||
const avgPercent = budget > 0 ? (spent / budget) * 100 : 0;
|
||
|
||
document.getElementById('totalCount').textContent = total;
|
||
document.getElementById('totalBudget').textContent = formatMoney(budget);
|
||
document.getElementById('totalSpent').textContent = formatMoney(spent);
|
||
document.getElementById('totalRemaining').textContent = formatMoney(remaining);
|
||
document.getElementById('avgPercent').textContent = avgPercent.toFixed(1) + '%';
|
||
}
|
||
|
||
function render() {
|
||
const search = document.getElementById('searchInput').value.toLowerCase();
|
||
const statusFilter = document.getElementById('statusFilter').value;
|
||
const deptFilter = document.getElementById('departmentFilter').value;
|
||
|
||
const filtered = items.filter(item => {
|
||
const matchSearch = !search ||
|
||
(item.name && item.name.toLowerCase().includes(search)) ||
|
||
(item.supplier && item.supplier.toLowerCase().includes(search)) ||
|
||
(item.contractNumber && item.contractNumber.includes(search)) ||
|
||
(item.articleCode && item.articleCode.toLowerCase().includes(search));
|
||
const matchStatus = !statusFilter || item.status === statusFilter;
|
||
const matchDept = !deptFilter || item.department === deptFilter;
|
||
return matchSearch && matchStatus && matchDept;
|
||
});
|
||
|
||
const tbody = document.getElementById('tableBody');
|
||
tbody.innerHTML = filtered.map((item, idx) => {
|
||
const percent = item.budget > 0 ? ((item.spent / item.budget) * 100) : 0;
|
||
const remaining = (item.budget || 0) - (item.spent || 0);
|
||
return `
|
||
<tr>
|
||
<td>${idx + 1}</td>
|
||
<td>${item.articleCode || ''}</td>
|
||
<td>${item.name || ''}</td>
|
||
<td>${item.department || ''}</td>
|
||
<td>${item.supplier || ''}</td>
|
||
<td>${formatMoney(item.budget || 0)}</td>
|
||
<td>${formatMoney(item.spent || 0)}</td>
|
||
<td style="color: ${remaining < 0 ? '#dc2626' : '#059669'}">${formatMoney(remaining)}</td>
|
||
<td>
|
||
<div style="display: flex; align-items: center; gap: 8px;">
|
||
<div class="progress-bar"><div class="progress-fill" style="width: ${Math.min(100, percent)}%"></div></div>
|
||
<span>${percent.toFixed(1)}%</span>
|
||
</div>
|
||
</td>
|
||
<td><span class="status ${item.status === 'оплачена' ? 'paid' : item.status === 'в работе' ? 'pending' : 'draft'}">${item.status || ''}</span></td>
|
||
<td class="actions">
|
||
<button class="btn-edit" onclick="editItem(${item.id})">✏️</button>
|
||
<button class="btn-delete" onclick="deleteItem(${item.id})">🗑️</button>
|
||
</td>
|
||
</tr>
|
||
`;
|
||
}).join('');
|
||
|
||
calcStats();
|
||
updateDepartments();
|
||
}
|
||
|
||
function updateDepartments() {
|
||
const depts = [...new Set(items.map(i => i.department).filter(Boolean))];
|
||
const select = document.getElementById('departmentFilter');
|
||
select.innerHTML = '<option value="">Все отделы</option>' + depts.map(d => `<option value="${d}">${d}</option>`).join('');
|
||
}
|
||
|
||
function openModal(id = null) {
|
||
editingId = id;
|
||
document.getElementById('modalTitle').textContent = id ? 'Редактировать закупку' : 'Добавить закупку';
|
||
document.getElementById('modalOverlay').classList.add('on');
|
||
|
||
if (id) {
|
||
const item = items.find(i => i.id === id);
|
||
if (item) {
|
||
document.getElementById('editId').value = item.id;
|
||
document.getElementById('articleCode').value = item.articleCode || '';
|
||
document.getElementById('name').value = item.name || '';
|
||
document.getElementById('department').value = item.department || '';
|
||
document.getElementById('supplier').value = item.supplier || '';
|
||
document.getElementById('budget').value = item.budget || 0;
|
||
document.getElementById('spent').value = item.spent || 0;
|
||
document.getElementById('status').value = item.status || 'оплачена';
|
||
document.getElementById('contractNumber').value = item.contractNumber || '';
|
||
}
|
||
} else {
|
||
document.getElementById('procurementForm').reset();
|
||
}
|
||
}
|
||
|
||
function closeModal() {
|
||
document.getElementById('modalOverlay').classList.remove('on');
|
||
editingId = null;
|
||
}
|
||
|
||
document.getElementById('procurementForm').addEventListener('submit', async (e) => {
|
||
e.preventDefault();
|
||
const data = {
|
||
articleCode: document.getElementById('articleCode').value,
|
||
name: document.getElementById('name').value,
|
||
department: document.getElementById('department').value,
|
||
supplier: document.getElementById('supplier').value,
|
||
budget: parseFloat(document.getElementById('budget').value),
|
||
spent: parseFloat(document.getElementById('spent').value),
|
||
status: document.getElementById('status').value,
|
||
contractNumber: document.getElementById('contractNumber').value
|
||
};
|
||
|
||
if (editingId) {
|
||
await fetch(`/api/items/${editingId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
||
} else {
|
||
await fetch('/api/items', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) });
|
||
}
|
||
|
||
closeModal();
|
||
loadItems();
|
||
});
|
||
|
||
async function editItem(id) { openModal(id); }
|
||
|
||
async function deleteItem(id) {
|
||
if (confirm('Удалить эту закупку?')) {
|
||
await fetch(`/api/items/${id}`, { method: 'DELETE' });
|
||
loadItems();
|
||
}
|
||
}
|
||
|
||
function exportToExcel() {
|
||
const head = ["Код статьи", "Наименование", "Отдел", "Поставщик", "Бюджет", "Факт", "Остаток", "% исп.", "Статус", "Договор"];
|
||
const body = items.map(r => [
|
||
r.articleCode, r.name, r.department, r.supplier,
|
||
(r.budget || 0).toString().replace('.', ','),
|
||
(r.spent || 0).toString().replace('.', ','),
|
||
((r.budget || 0) - (r.spent || 0)).toString().replace('.', ','),
|
||
(r.budget > 0 ? ((r.spent / r.budget) * 100).toFixed(1) : 0) + '%',
|
||
r.status, r.contractNumber
|
||
]);
|
||
const csv = '\uFEFF' + [head.join(';'), ...body.map(row => row.map(v => '"' + String(v ?? "").replace(/"/g, '""') + '"').join(';'))].join('\n');
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
|
||
a.download = 'monitoring_byudzheta_' + new Date().toISOString().slice(0,10) + '.csv';
|
||
a.click();
|
||
}
|
||
|
||
document.getElementById('searchInput').addEventListener('input', render);
|
||
document.getElementById('statusFilter').addEventListener('change', render);
|
||
document.getElementById('departmentFilter').addEventListener('change', render);
|
||
|
||
loadItems();
|
||
</script>
|
||
</body>
|
||
</html>
|