hochu-sdelat-stroitelstvo-po/index.html

488 lines
20 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>Starlink — Учёт абонентов</title>
<style>
:root{--ink:#0F1218;--blue:#0066FF;--green:#10B981;--red:#EF4444;--gray-100:#F2F4F7;--gray-500:#5B6573}
*{box-sizing:border-box;margin:0;padding:0}
body{font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:#fff;padding:20px}
.container{max-width:1400px;margin:0 auto}
.header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;flex-wrap:wrap;gap:16px}
.header h1{font:700 28px/1.2 sans-serif}
.stats{display:flex;gap:24px;flex-wrap:wrap}
.stat{background:var(--gray-100);padding:16px 24px;border-radius:8px}
.stat-num{font:800 24px/1 sans-serif;color:var(--blue)}
.stat-label{color:var(--gray-500);font-size:13px}
.btn{background:var(--blue);color:#fff;border:none;padding:12px 20px;border-radius:6px;font-weight:600;cursor:pointer;font-size:14px}
.btn:hover{opacity:0.9}
.btn-green{background:var(--green)}
.btn-red{background:var(--red)}
.btn-sm{padding:6px 12px;font-size:13px}
.card{background:#fff;border:1px solid #e0e0e0;border-radius:8px;padding:20px;margin-bottom:20px}
.card h2{font:700 20px/1.3 sans-serif;margin-bottom:16px}
.form-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:12px}
input,select,textarea{width:100%;padding:10px;border:1px solid #e0e0e0;border-radius:6px;font-size:14px}
input:focus,select:focus,textarea:focus{outline:none;border-color:var(--blue)}
table{width:100%;border-collapse:collapse;margin-top:16px}
th,td{padding:12px;text-align:left;border-bottom:1px solid #e0e0e0}
th{font-weight:600;background:var(--gray-100)}
.badge{display:inline-block;padding:4px 8px;border-radius:4px;font-size:12px;font-weight:600}
.badge-green{background:#D1FAE5;color:#065F46}
.badge-red{background:#FEE2E2;color:#991B1B}
.modal{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);align-items:center;justify-content:center;z-index:1000}
.modal.on{display:flex}
.modal-content{background:#fff;padding:32px;border-radius:12px;max-width:500px;width:90%;max-height:90vh;overflow-y:auto}
.modal-content h3{font:700 20px/1.3 sans-serif;margin-bottom:20px}
.close{float:right;font-size:24px;cursor:pointer}
.tabs{display:flex;gap:8px;margin-bottom:20px;border-bottom:1px solid #e0e0e0;padding-bottom:12px}
.tab{padding:8px 16px;border:none;background:none;cursor:pointer;font-weight:600;color:var(--gray-500)}
.tab.active{color:var(--blue);border-bottom:2px solid var(--blue)}
.search{width:300px;padding:10px;border:1px solid #e0e0e0;border-radius:6px;margin-bottom:16px}
.actions{display:flex;gap:8px;flex-wrap:wrap}
.balance-positive{color:var(--green);font-weight:700}
.balance-negative{color:var(--red);font-weight:700}
@media(max-width:768px){.header{flex-direction:column;align-items:flex-start}.stats{width:100%}.search{width:100%}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📡 Starlink — Учёт абонентов</h1>
<div class="stats">
<div class="stat"><div class="stat-num" id="totalSubs">0</div><div class="stat-label">Абонентов</div></div>
<div class="stat"><div class="stat-num" id="activeSubs">0</div><div class="stat-label">Активных</div></div>
<div class="stat"><div class="stat-num" id="totalRevenue">0 ₸</div><div class="stat-label">Доход</div></div>
</div>
</div>
<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>
<div id="subscribers-tab">
<div class="card">
<h2>Добавить абонента</h2>
<form id="addSubscriberForm">
<div class="form-row">
<input type="text" name="name" placeholder="ФИО" required>
<input type="text" name="address" placeholder="Адрес (село, улица, дом)" required>
<input type="tel" name="phone" placeholder="Телефон" required>
</div>
<button type="submit" class="btn">Добавить абонента</button>
</form>
</div>
<div class="card">
<h2>Все абоненты</h2>
<input type="text" class="search" id="searchSub" placeholder="Поиск по имени, адресу, телефону..." oninput="filterSubscribers()">
<table>
<thead>
<tr>
<th>Лиц. счёт</th>
<th>ФИО</th>
<th>Адрес</th>
<th>Телефон</th>
<th>Баланс</th>
<th>Статус</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="subscribersTable"></tbody>
</table>
</div>
</div>
<div id="payments-tab" style="display:none">
<div class="card">
<h2>Внести платёж</h2>
<form id="addPaymentForm">
<div class="form-row">
<select name="subscriberId" id="paymentSubscriber" required></select>
<input type="number" name="amount" placeholder="Сумма (₸)" required min="1">
<input type="date" name="date" required>
<input type="text" name="comment" placeholder="Комментарий">
</div>
<button type="submit" class="btn btn-green">Внести платёж</button>
</form>
</div>
<div class="card">
<h2>История платежей</h2>
<table>
<thead>
<tr>
<th>Дата</th>
<th>Лиц. счёт</th>
<th>Абонент</th>
<th>Сумма</th>
<th>Комментарий</th>
</tr>
</thead>
<tbody id="paymentsTable"></tbody>
</table>
</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>
<p style="margin-bottom:16px;color:var(--gray-500)">Выгрузить список абонентов и платежей в Excel для отчётности</p>
<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>
</div>
<div class="modal" id="editModal">
<div class="modal-content">
<span class="close" onclick="closeModal()">&times;</span>
<h3>Редактировать абонента</h3>
<form id="editForm">
<input type="hidden" name="id">
<div class="form-row">
<input type="text" name="name" placeholder="ФИО" required>
<input type="text" name="address" placeholder="Адрес" required>
<input type="tel" name="phone" placeholder="Телефон" required>
</div>
<div class="form-row">
<select name="status">
<option value="active">Активен</option>
<option value="blocked">Заблокирован</option>
<option value="inactive">Отключен</option>
</select>
<input type="number" name="balance" placeholder="Баланс" step="0.01">
</div>
<button type="submit" class="btn" style="margin-top:16px">Сохранить</button>
</form>
</div>
</div>
<script>
let subscribers = [];
let payments = [];
async function load() {
subscribers = await fetch('/api/subscribers').then(r => r.json());
payments = await fetch('/api/payments').then(r => r.json());
renderSubscribers();
renderPayments();
updateStats();
populateSubscriberSelect();
}
function renderSubscribers() {
const tbody = document.getElementById('subscribersTable');
tbody.innerHTML = subscribers.map(s => `
<tr>
<td><strong>${s.accountNumber}</strong></td>
<td>${s.name}</td>
<td>${s.address}</td>
<td>${s.phone}</td>
<td class="${s.balance >= 0 ? 'balance-positive' : 'balance-negative'}">${s.balance.toFixed(2)} ₸</td>
<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>
`).join('');
}
function renderPayments() {
const tbody = document.getElementById('paymentsTable');
const rows = payments.slice().reverse().map(p => {
const sub = subscribers.find(s => s.id == p.subscriberId);
return `
<tr>
<td>${p.date}</td>
<td>${sub ? sub.accountNumber : '-'}</td>
<td>${sub ? sub.name : '-'}</td>
<td class="balance-positive">+${p.amount.toFixed(2)} ₸</td>
<td>${p.comment || '-'}</td>
</tr>
`;
});
tbody.innerHTML = rows.join('');
}
function updateStats() {
document.getElementById('totalSubs').textContent = subscribers.length;
document.getElementById('activeSubs').textContent = subscribers.filter(s => s.status === 'active').length;
const total = payments.reduce((sum, p) => sum + p.amount, 0);
document.getElementById('totalRevenue').textContent = total.toFixed(0) + ' ₸';
}
function populateSubscriberSelect() {
const select = document.getElementById('paymentSubscriber');
select.innerHTML = subscribers.map(s => `<option value="${s.id}">${s.accountNumber} — ${s.name}</option>`).join('');
}
function filterSubscribers() {
const q = document.getElementById('searchSub').value.toLowerCase();
const rows = document.querySelectorAll('#subscribersTable tr');
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(q) ? '' : 'none';
});
}
document.getElementById('addSubscriberForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
await fetch('/api/subscribers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name.value,
address: form.address.value,
phone: form.phone.value
})
});
form.reset();
load();
});
document.getElementById('editForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
const id = form.id.value;
await fetch('/api/subscribers/' + id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name.value,
address: form.address.value,
phone: form.phone.value,
status: form.status.value,
balance: parseFloat(form.balance.value) || 0
})
});
closeModal();
load();
});
document.getElementById('addPaymentForm').addEventListener('submit', async (e) => {
e.preventDefault();
const form = e.target;
await fetch('/api/payments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
subscriberId: form.subscriberId.value,
amount: parseFloat(form.amount.value),
date: form.date.value,
comment: form.comment.value
})
});
form.reset();
load();
});
function editSubscriber(id) {
const s = subscribers.find(x => x.id === id);
if (!s) return;
const form = document.getElementById('editForm');
form.id.value = s.id;
form.name.value = s.name;
form.address.value = s.address;
form.phone.value = s.phone;
form.status.value = s.status;
form.balance.value = s.balance || 0;
document.getElementById('editModal').classList.add('on');
}
function closeModal() {
document.getElementById('editModal').classList.remove('on');
}
async function deleteSubscriber(id) {
if (!confirm('Удалить этого абонента? Это действие нельзя отменить.')) return;
await fetch('/api/subscribers/' + id, { method: 'DELETE' });
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() {
const head = ['Лиц.счёт', 'ФИО', 'Адрес', 'Телефон', 'Баланс', 'Статус', 'Дата подключения'];
const body = subscribers.map(s => [s.accountNumber, s.name, s.address, s.phone, s.balance, s.status, s.createdAt]);
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 = 'abonenty_' + new Date().toISOString().slice(0,10) + '.csv';
a.click();
}
function exportPayments() {
const head = ['Дата', 'Лиц.счёт', 'ФИО', 'Сумма', 'Комментарий'];
const body = payments.map(p => {
const s = subscribers.find(x => x.id == p.subscriberId);
return [p.date, s ? s.accountNumber : '-', s ? s.name : '-', p.amount, p.comment];
});
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 = 'platezhi_' + new Date().toISOString().slice(0,10) + '.csv';
a.click();
}
document.getElementById('paymentForm').date.value = new Date().toISOString().slice(0, 10);
load();
</script>
</body>
</html>