diff --git a/.vibe42-run.log b/.vibe42-run.log
new file mode 100644
index 0000000..42cac69
--- /dev/null
+++ b/.vibe42-run.log
@@ -0,0 +1,4 @@
+--- run Fri Aug 14 10:08:27 AM UTC 2026 :: npm start --silent
+
+up to date in 401ms
+Сервер запущен на порту 3000
diff --git a/.vibe42-run.pid b/.vibe42-run.pid
new file mode 100644
index 0000000..8cf5c1a
--- /dev/null
+++ b/.vibe42-run.pid
@@ -0,0 +1 @@
+86
diff --git a/index.html b/index.html
index 0c4283b..4b001fb 100644
--- a/index.html
+++ b/index.html
@@ -61,6 +61,8 @@ th{font-weight:600;background:var(--gray-100)}
+
+
@@ -128,6 +130,42 @@ th{font-weight:600;background:var(--gray-100)}
+
+
+
Должники
+
Абоненты с отрицательным балансом или заблокированные
+
+
+
+
+
+
+ | Лиц. счёт |
+ ФИО |
+ Адрес |
+ Телефон |
+ Баланс |
+ Статус |
+ Действия |
+
+
+
+
+
+
+
+
+
+
Абонентская плата
+
Автоматическое начисление ежемесячной платы
+
+
+
+
+
⚠️ Это спишет указанную сумму со счетов всех активных абонентов
+
+
+
Экспорт данных
@@ -135,6 +173,7 @@ th{font-weight:600;background:var(--gray-100)}
+
@@ -189,6 +228,7 @@ function renderSubscribers() {
${s.status === 'active' ? 'Активен' : s.status} |
+
|
@@ -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 = '| Должников нет 🎉 |
';
+ return;
+ }
+ tbody.innerHTML = debtors.map(s => `
+
+ | ${s.accountNumber} |
+ ${s.name} |
+ ${s.address} |
+ ${s.phone} |
+ ${s.balance.toFixed(2)} ₸ |
+ ${s.status === 'blocked' ? 'Заблокирован' : 'Должник'} |
+
+
+
+ |
+
+ `).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() {
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..9aa3373
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,10 @@
+{
+ "name": "starlink-subscribers",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "starlink-subscribers"
+ }
+ }
+}