v2: система учёта Starlink абонентов

This commit is contained in:
Adil 2026-08-14 10:08:25 +00:00
parent 6239d57a90
commit d5f442d0f3
3 changed files with 463 additions and 164 deletions

View File

@ -3,189 +3,343 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Оптика Алаколь — Строительство волоконно-оптической сети</title> <title>Starlink — Учёт абонентов</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;600;700&family=Unbounded:wght@700;800&display=swap" rel="stylesheet">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📡</text></svg>">
<style> <style>
:root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#FFFFFF;--gray-500:#5B6573;--gray-100:#F2F4F7;--blue:#0066FF} :root{--ink:#0F1218;--blue:#0066FF;--green:#10B981;--red:#EF4444;--gray-100:#F2F4F7;--gray-500:#5B6573}
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
body{font:17px/1.6 'Manrope',-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--white)} body{font:14px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:#fff;padding:20px}
.container{max-width:1140px;margin:0 auto;padding:80px 24px} .container{max-width:1400px;margin:0 auto}
.hero{background:linear-gradient(135deg,var(--ink) 0%,#1a1f2e 100%);color:var(--white);padding:100px 24px} .header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;flex-wrap:wrap;gap:16px}
.hero .container{padding:0;max-width:900px} .header h1{font:700 28px/1.2 sans-serif}
.hero h1{font:800 clamp(36px,6vw,56px)/1.05 'Unbounded',sans-serif;margin-bottom:24px} .stats{display:flex;gap:24px;flex-wrap:wrap}
.hero p{font-size:20px;color:#9aa3b2;margin-bottom:32px;max-width:600px} .stat{background:var(--gray-100);padding:16px 24px;border-radius:8px}
.btn{display:inline-block;background:var(--cyan);color:var(--ink);padding:16px 32px;border-radius:8px;font-weight:700;text-decoration:none;transition:transform .2s} .stat-num{font:800 24px/1 sans-serif;color:var(--blue)}
.btn:hover{transform:translateY(-2px)} .stat-label{color:var(--gray-500);font-size:13px}
.btn-outline{background:transparent;border:2px solid var(--cyan);color:var(--cyan);margin-left:12px} .btn{background:var(--blue);color:#fff;border:none;padding:12px 20px;border-radius:6px;font-weight:600;cursor:pointer;font-size:14px}
.section{padding:80px 24px} .btn:hover{opacity:0.9}
.section h2{font:700 clamp(28px,4vw,36px)/1.15 'Unbounded',sans-serif;margin-bottom:40px;text-align:center} .btn-green{background:var(--green)}
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:24px;max-width:1140px;margin:0 auto} .btn-red{background:var(--red)}
.card{background:var(--gray-100);border-radius:16px;padding:32px;transition:transform .2s} .btn-sm{padding:6px 12px;font-size:13px}
.card:hover{transform:translateY(-4px)} .card{background:#fff;border:1px solid #e0e0e0;border-radius:8px;padding:20px;margin-bottom:20px}
.card-icon{font-size:48px;margin-bottom:16px} .card h2{font:700 20px/1.3 sans-serif;margin-bottom:16px}
.card h3{font:700 22px/1.3 'Unbounded',sans-serif;margin-bottom:12px} .form-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:12px;margin-bottom:12px}
.card p{color:var(--gray-500)} input,select,textarea{width:100%;padding:10px;border:1px solid #e0e0e0;border-radius:6px;font-size:14px}
.stats{background:var(--cyan-50);text-align:center} input:focus,select:focus,textarea:focus{outline:none;border-color:var(--blue)}
.stat-number{font:800 clamp(48px,8vw,72px)/1 'Unbounded',sans-serif;color:var(--blue)} table{width:100%;border-collapse:collapse;margin-top:16px}
.stat-label{font-size:18px;color:var(--gray-500);margin-top:8px} th,td{padding:12px;text-align:left;border-bottom:1px solid #e0e0e0}
.map-section{background:var(--ink);color:var(--white)} th{font-weight:600;background:var(--gray-100)}
.map-section h2{color:var(--white)} .badge{display:inline-block;padding:4px 8px;border-radius:4px;font-size:12px;font-weight:600}
.map-placeholder{background:linear-gradient(45deg,#1a1f2e,#2a3140);border-radius:16px;height:400px;display:flex;align-items:center;justify-content:center;text-align:center;padding:40px} .badge-green{background:#D1FAE5;color:#065F46}
.map-placeholder p{color:#9aa3b2;font-size:18px} .badge-red{background:#FEE2E2;color:#991B1B}
.form-section{background:var(--gray-100)} .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}
.form-container{max-width:500px;margin:0 auto;background:var(--white);padding:40px;border-radius:16px;box-shadow:0 4px 20px rgba(0,0,0,0.08)} .modal.on{display:flex}
.form-group{margin-bottom:20px} .modal-content{background:#fff;padding:32px;border-radius:12px;max-width:500px;width:90%;max-height:90vh;overflow-y:auto}
.form-group label{display:block;font-weight:600;margin-bottom:8px} .modal-content h3{font:700 20px/1.3 sans-serif;margin-bottom:20px}
.form-group input,.form-group textarea{width:100%;padding:14px;border:1px solid #e0e0e0;border-radius:8px;font-size:16px;font-family:inherit} .close{float:right;font-size:24px;cursor:pointer}
.form-group input:focus,.form-group textarea:focus{outline:none;border-color:var(--blue)} .tabs{display:flex;gap:8px;margin-bottom:20px;border-bottom:1px solid #e0e0e0;padding-bottom:12px}
.form-group textarea{resize:vertical;min-height:100px} .tab{padding:8px 16px;border:none;background:none;cursor:pointer;font-weight:600;color:var(--gray-500)}
.submit-btn{width:100%;background:var(--blue);color:var(--white);border:none;padding:16px;border-radius:8px;font-weight:700;font-size:17px;cursor:pointer;transition:background .2s} .tab.active{color:var(--blue);border-bottom:2px solid var(--blue)}
.submit-btn:hover{background:#0052cc} .search{width:300px;padding:10px;border:1px solid #e0e0e0;border-radius:6px;margin-bottom:16px}
.contact-info{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:24px;margin-top:40px} .actions{display:flex;gap:8px;flex-wrap:wrap}
.contact-item{text-align:center} .balance-positive{color:var(--green);font-weight:700}
.contact-icon{font-size:32px;margin-bottom:8px} .balance-negative{color:var(--red);font-weight:700}
.reveal{opacity:0;transform:translateY(20px);transition:opacity .6s ease,transform .6s ease} @media(max-width:768px){.header{flex-direction:column;align-items:flex-start}.stats{width:100%}.search{width:100%}}
.reveal.in{opacity:1;transform:none}
@media(max-width:640px){.container{padding:48px 20px}.hero{padding:60px 20px}.section{padding:48px 20px}.btn-outline{margin-left:0;margin-top:12px;display:block;text-align:center}}
</style> </style>
</head> </head>
<body> <body>
<section class="hero"> <div class="container">
<div class="container"> <div class="header">
<h1>Оптика Алаколь</h1> <h1>📡 Starlink — Учёт абонентов</h1>
<p>Строительство современной волоконно-оптической сети на 400 абонентов в Алакольском районе. Высокоскоростной интернет для дома и бизнеса.</p> <div class="stats">
<a class="btn" href="#connect">Подключиться</a> <div class="stat"><div class="stat-num" id="totalSubs">0</div><div class="stat-label">Абонентов</div></div>
<a class="btn btn-outline" href="#project">О проекте</a> <div class="stat"><div class="stat-num" id="activeSubs">0</div><div class="stat-label">Активных</div></div>
</div> <div class="stat"><div class="stat-num" id="totalRevenue">0 ₸</div><div class="stat-label">Доход</div></div>
</section>
<section id="project" class="section">
<h2 class="reveal">О проекте</h2>
<div class="cards">
<div class="card reveal">
<div class="card-icon">📡</div>
<h3>Современная технология</h3>
<p>Волоконно-оптическая линия связи (ВОЛС) по технологии GPON — стабильный интернет без помех и обрывов.</p>
</div>
<div class="card reveal">
<div class="card-icon">🏘</div>
<h3>Охват района</h3>
<p>Подключение частных домов, квартир и объектов бизнеса в населённых пунктах Алакольского района.</p>
</div>
<div class="card reveal">
<div class="card-icon">⚡</div>
<h3>Высокая скорость</h3>
<p>Доступ к интернету на скоростях до 100 Мбит/с и выше. Подходит для работы, учёбы и развлечений.</p>
</div> </div>
</div> </div>
</section>
<section class="section stats"> <div class="tabs">
<div class="container"> <button class="tab active" onclick="showTab('subscribers')">Абоненты</button>
<div class="cards"> <button class="tab" onclick="showTab('payments')">Платежи</button>
<div class="card reveal"> <button class="tab" onclick="showTab('reports')">Отчёты</button>
<div class="stat-number">400</div> </div>
<div class="stat-label">абонентов</div>
</div> <div id="subscribers-tab">
<div class="card reveal"> <div class="card">
<div class="stat-number">100+</div> <h2>Добавить абонента</h2>
<div class="stat-label">км оптоволокна</div> <form id="addSubscriberForm">
</div> <div class="form-row">
<div class="card reveal"> <input type="text" name="name" placeholder="ФИО" required>
<div class="stat-number">10</div> <input type="text" name="address" placeholder="Адрес (село, улица, дом)" required>
<div class="stat-label">населённых пунктов</div> <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> </div>
</section> </div>
<section class="section"> <div class="modal" id="editModal">
<h2 class="reveal">Преимущества</h2> <div class="modal-content">
<div class="cards"> <span class="close" onclick="closeModal()">&times;</span>
<div class="card reveal"> <h3>Редактировать абонента</h3>
<div class="card-icon">🛡</div> <form id="editForm">
<h3>Надёжность</h3> <input type="hidden" name="id">
<p>Оптоволокно не боится грозы, перепадов температур и электромагнитных помех.</p> <div class="form-row">
</div> <input type="text" name="name" placeholder="ФИО" required>
<div class="card reveal"> <input type="text" name="address" placeholder="Адрес" required>
<div class="card-icon">💰</div> <input type="tel" name="phone" placeholder="Телефон" required>
<h3>Доступная цена</h3>
<p>Конкурентные тарифы на подключение и ежемесячное обслуживание.</p>
</div>
<div class="card reveal">
<div class="card-icon">🔧</div>
<h3>Поддержка</h3>
<p>Техническая поддержка и оперативное устранение неисправностей.</p>
</div>
<div class="card reveal">
<div class="card-icon">📺</div>
<h3>ТВ и телефония</h3>
<p>Возможность подключения цифрового ТВ и домашней телефонии.</p>
</div>
</div>
</section>
<section class="section map-section">
<h2 class="reveal">Зона покрытия</h2>
<div class="map-placeholder reveal">
<div>
<p style="font-size:48px;margin-bottom:16px">🗺</p>
<p>Алакольский район<br>Карта зоны покрытия сети</p>
</div>
</div>
</section>
<section id="connect" class="section form-section">
<h2 class="reveal">Заявка на подключение</h2>
<div class="form-container reveal">
<form action="https://formsubmit.co/optika.alakol@example.com" method="POST">
<div class="form-group">
<label for="name">Ваше имя</label>
<input type="text" id="name" name="name" placeholder="Иванов Иван" required>
</div> </div>
<div class="form-group"> <div class="form-row">
<label for="phone">Номер телефона</label> <select name="status">
<input type="tel" id="phone" name="phone" placeholder="+7 (777) 123-45-67" required> <option value="active">Активен</option>
<option value="blocked">Заблокирован</option>
<option value="inactive">Отключен</option>
</select>
<input type="number" name="balance" placeholder="Баланс" step="0.01">
</div> </div>
<div class="form-group"> <button type="submit" class="btn" style="margin-top:16px">Сохранить</button>
<label for="address">Адрес подключения</label>
<input type="text" id="address" name="address" placeholder="с. Ушарал, ул. Абая 15" required>
</div>
<div class="form-group">
<label for="comment">Комментарий (необязательно)</label>
<textarea id="comment" name="comment" placeholder="Пожелания или вопросы"></textarea>
</div>
<button type="submit" class="submit-btn">Отправить заявку</button>
</form> </form>
</div> </div>
</section> </div>
<section class="section">
<h2 class="reveal">Контакты</h2>
<div class="contact-info reveal">
<div class="contact-item">
<div class="contact-icon">📞</div>
<p><strong>Телефон</strong><br>+7 (777) 123-45-67</p>
</div>
<div class="contact-item">
<div class="contact-icon">✉</div>
<p><strong>Email</strong><br>info@optika-alakol.kz</p>
</div>
<div class="contact-item">
<div class="contact-icon">📍</div>
<p><strong>Адрес</strong><br>Алакольский район, с. Ушарал</p>
</div>
</div>
</section>
<script> <script>
var io=new IntersectionObserver(function(entries){ let subscribers = [];
entries.forEach(function(e){ if(e.isIntersecting) e.target.classList.add('in'); }); let payments = [];
},{threshold:.15});
document.querySelectorAll('.reveal').forEach(function(el){ io.observe(el); }); 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> </script>
</body> </body>

8
package.json Normal file
View File

@ -0,0 +1,8 @@
{
"name": "starlink-subscribers",
"private": true,
"type": "commonjs",
"scripts": {
"start": "node server.js"
}
}

137
server.js Normal file
View File

@ -0,0 +1,137 @@
const http = require("http");
const fs = require("fs");
const path = require("path");
const PORT = process.env.PORT || 3000;
const DATA = path.join(__dirname, "data.json");
function readData() {
try { return JSON.parse(fs.readFileSync(DATA, "utf8")); } catch (_) { return { subscribers: [], payments: [] }; }
}
function writeData(data) {
fs.writeFileSync(DATA, JSON.stringify(data, null, 2));
}
function body(req) {
return new Promise((resolve) => {
let s = "";
req.on("data", (c) => (s += c));
req.on("end", () => { try { resolve(JSON.parse(s || "{}")); } catch (_) { resolve({}); } });
});
}
http.createServer(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.method === "OPTIONS") {
res.writeHead(200);
return res.end();
}
// API: список абонентов
if (req.method === "GET" && req.url === "/api/subscribers") {
const data = readData();
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(data.subscribers));
}
// API: добавить абонента
if (req.method === "POST" && req.url === "/api/subscribers") {
const sub = await body(req);
const data = readData();
const newSub = {
id: Date.now(),
accountNumber: "LS" + Date.now(),
name: sub.name || "",
address: sub.address || "",
phone: sub.phone || "",
balance: 0,
status: "active",
createdAt: new Date().toISOString().slice(0, 10)
};
data.subscribers.push(newSub);
writeData(data);
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: true, subscriber: newSub }));
}
// API: обновить абонента
if (req.method === "PUT" && /^\/api\/subscribers\/\d+$/.test(req.url)) {
const id = parseInt(req.url.split("/")[3]);
const update = await body(req);
const data = readData();
const idx = data.subscribers.findIndex(s => s.id === id);
if (idx !== -1) {
data.subscribers[idx] = { ...data.subscribers[idx], ...update };
writeData(data);
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: true }));
}
res.writeHead(404);
return res.end(JSON.stringify({ error: "Not found" }));
}
// API: удалить абонента
if (req.method === "DELETE" && /^\/api\/subscribers\/\d+$/.test(req.url)) {
const id = parseInt(req.url.split("/")[3]);
const data = readData();
const idx = data.subscribers.findIndex(s => s.id === id);
if (idx !== -1) {
data.subscribers.splice(idx, 1);
writeData(data);
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: true }));
}
res.writeHead(404);
return res.end(JSON.stringify({ error: "Not found" }));
}
// API: платежи
if (req.method === "GET" && req.url.startsWith("/api/payments")) {
const data = readData();
const subscriberId = req.url.split("=")[1];
const payments = subscriberId
? data.payments.filter(p => p.subscriberId == subscriberId)
: data.payments;
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify(payments));
}
// API: добавить платёж
if (req.method === "POST" && req.url === "/api/payments") {
const payment = await body(req);
const data = readData();
const newPayment = {
id: Date.now(),
subscriberId: payment.subscriberId,
amount: parseFloat(payment.amount) || 0,
date: payment.date || new Date().toISOString().slice(0, 10),
comment: payment.comment || ""
};
data.payments.push(newPayment);
// Обновить баланс абонента
const subIdx = data.subscribers.findIndex(s => s.id == payment.subscriberId);
if (subIdx !== -1) {
data.subscribers[subIdx].balance = (data.subscribers[subIdx].balance || 0) + newPayment.amount;
}
writeData(data);
res.writeHead(200, { "Content-Type": "application/json" });
return res.end(JSON.stringify({ ok: true, payment: newPayment }));
}
// Статика
let file = req.url === "/" ? "/index.html" : req.url.split("?")[0];
const full = path.join(__dirname, file);
if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) {
const ext = path.extname(full);
const type = ext === ".css" ? "text/css" : ext === ".js" ? "application/javascript" : "text/html";
res.writeHead(200, { "Content-Type": type + "; charset=utf-8" });
return res.end(fs.readFileSync(full));
}
res.writeHead(404);
res.end("Not found");
}).listen(PORT, () => console.log("Сервер запущен на порту " + PORT));