sozday-sayt-monitoring-ispol/index.html

387 lines
18 KiB
HTML
Raw Permalink 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; 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>