v3: блокировка должников + абонентская плата

This commit is contained in:
Adil 2026-08-14 10:25:51 +00:00
parent d5f442d0f3
commit d0c0e8c76b
4 changed files with 156 additions and 0 deletions

4
.vibe42-run.log Normal file
View File

@ -0,0 +1,4 @@
--- run Fri Aug 14 10:08:27 AM UTC 2026 :: npm start --silent
up to date in 401ms
Сервер запущен на порту 3000

1
.vibe42-run.pid Normal file
View File

@ -0,0 +1 @@
86

View File

@ -61,6 +61,8 @@ th{font-weight:600;background:var(--gray-100)}
<div class="tabs">
<button class="tab active" onclick="showTab('subscribers')">Абоненты</button>
<button class="tab" onclick="showTab('payments')">Платежи</button>
<button class="tab" onclick="showTab('debtors')">Должники</button>
<button class="tab" onclick="showTab('settings')">Настройки</button>
<button class="tab" onclick="showTab('reports')">Отчёты</button>
</div>
@ -128,6 +130,42 @@ th{font-weight:600;background:var(--gray-100)}
</div>
</div>
<div id="debtors-tab" style="display:none">
<div class="card">
<h2>Должники</h2>
<p style="margin-bottom:16px;color:var(--gray-500)">Абоненты с отрицательным балансом или заблокированные</p>
<div class="actions" style="margin-bottom:16px">
<button class="btn btn-red" onclick="blockAllDebtors()">Заблокировать всех должников</button>
</div>
<table>
<thead>
<tr>
<th>Лиц. счёт</th>
<th>ФИО</th>
<th>Адрес</th>
<th>Телефон</th>
<th>Баланс</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="debtorsTable"></tbody>
</table>
</div>
</div>
<div id="settings-tab" style="display:none">
<div class="card">
<h2>Абонентская плата</h2>
<p style="margin-bottom:16px;color:var(--gray-500)">Автоматическое начисление ежемесячной платы</p>
<div class="form-row" style="max-width:400px">
<input type="number" id="monthlyFee" placeholder="Сумма (₸)" value="5000">
<button class="btn" onclick="chargeMonthlyFee()">Начислить всем абонентам</button>
</div>
<p style="margin-top:12px;color:var(--gray-500);font-size:13px">⚠️ Это спишет указанную сумму со счетов всех активных абонентов</p>
</div>
</div>
<div id="reports-tab" style="display:none">
<div class="card">
<h2>Экспорт данных</h2>
@ -135,6 +173,7 @@ th{font-weight:600;background:var(--gray-100)}
<div class="actions">
<button class="btn" onclick="exportSubscribers()">Скачать абоненты (CSV)</button>
<button class="btn" onclick="exportPayments()">Скачать платежи (CSV)</button>
<button class="btn" onclick="exportDebtors()">Скачать должники (CSV)</button>
</div>
</div>
</div>
@ -189,6 +228,7 @@ function renderSubscribers() {
<td><span class="badge ${s.status === 'active' ? 'badge-green' : 'badge-red'}">${s.status === 'active' ? 'Активен' : s.status}</span></td>
<td>
<button class="btn btn-sm" onclick="editSubscriber(${s.id})">Изменить</button>
<button class="btn btn-sm ${s.status === 'active' ? 'btn-red' : 'btn-green'}" onclick="toggleBlock(${s.id})">${s.status === 'active' ? 'Заблокировать' : 'Разблокировать'}</button>
<button class="btn btn-sm btn-red" onclick="deleteSubscriber(${s.id})">Удалить</button>
</td>
</tr>
@ -308,11 +348,112 @@ async function deleteSubscriber(id) {
load();
}
async function toggleBlock(id) {
const s = subscribers.find(x => x.id === id);
if (!s) return;
const newStatus = s.status === 'active' ? 'blocked' : 'active';
await fetch('/api/subscribers/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: newStatus })
});
load();
}
function renderDebtors() {
const debtors = subscribers.filter(s => s.balance < 0 || s.status === 'blocked');
const tbody = document.getElementById('debtorsTable');
if (debtors.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;padding:40px;color:var(--gray-500)">Должников нет 🎉</td></tr>';
return;
}
tbody.innerHTML = debtors.map(s => `
<tr>
<td><strong>${s.accountNumber}</strong></td>
<td>${s.name}</td>
<td>${s.address}</td>
<td>${s.phone}</td>
<td class="balance-negative">${s.balance.toFixed(2)} ₸</td>
<td><span class="badge badge-red">${s.status === 'blocked' ? 'Заблокирован' : 'Должник'}</span></td>
<td>
<button class="btn btn-sm btn-green" onclick="toggleBlock(${s.id})">Разблокировать</button>
<button class="btn btn-sm" onclick="editSubscriber(${s.id})">Изменить</button>
</td>
</tr>
`).join('');
}
async function blockAllDebtors() {
const debtors = subscribers.filter(s => s.balance < 0 && s.status === 'active');
if (debtors.length === 0) {
alert('Нет активных должников для блокировки');
return;
}
if (!confirm(`Заблокировать ${debtors.length} должников? Это действие можно отменить вручную.`)) return;
for (const s of debtors) {
await fetch('/api/subscribers/' + s.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'blocked' })
});
}
load();
alert(`Заблокировано ${debtors.length} должников`);
}
async function chargeMonthlyFee() {
const amount = parseFloat(document.getElementById('monthlyFee').value);
if (!amount || amount <= 0) {
alert('Введите корректную сумму абонентской платы');
return;
}
if (!confirm(`Начислить абонентскую плату ${amount} ₸ всем активным абонентам? Это спишет сумму с их балансов.`)) return;
const activeSubs = subscribers.filter(s => s.status === 'active');
for (const s of activeSubs) {
await fetch('/api/subscribers/' + s.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ balance: (s.balance || 0) - amount })
});
}
// Записать как платеж (отрицательный)
for (const s of activeSubs) {
await fetch('/api/payments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subscriberId: s.id,
amount: -amount,
date: new Date().toISOString().slice(0, 10),
comment: 'Абонентская плата'
})
});
}
load();
alert(`Абонентская плата ${amount} ₸ начислена ${activeSubs.length} абонентам`);
}
function exportDebtors() {
const debtors = subscribers.filter(s => s.balance < 0 || s.status === 'blocked');
const head = ['Лиц.счёт', 'ФИО', 'Адрес', 'Телефон', 'Баланс', 'Статус'];
const body = debtors.map(s => [s.accountNumber, s.name, s.address, s.phone, s.balance, s.status]);
const csv = '\uFEFF' + [head.join(';'), ...body.map(r => r.map(v => '"' + (v ?? '').toString().replace(/"/g, '""') + '"').join(';'))].join('\n');
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
a.download = 'dolzhniki_' + new Date().toISOString().slice(0,10) + '.csv';
a.click();
}
function showTab(tab) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('[id$="-tab"]').forEach(t => t.style.display = 'none');
event.target.classList.add('active');
document.getElementById(tab + '-tab').style.display = 'block';
if (tab === 'debtors') renderDebtors();
}
function exportSubscribers() {

10
package-lock.json generated Normal file
View File

@ -0,0 +1,10 @@
{
"name": "starlink-subscribers",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "starlink-subscribers"
}
}
}