347 lines
14 KiB
HTML
347 lines
14 KiB
HTML
<!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('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="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>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="modal" id="editModal">
|
||
<div class="modal-content">
|
||
<span class="close" onclick="closeModal()">×</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 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();
|
||
}
|
||
|
||
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';
|
||
}
|
||
|
||
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>
|