v2: система учёта Starlink абонентов
This commit is contained in:
parent
6239d57a90
commit
d5f442d0f3
484
index.html
484
index.html
@ -3,189 +3,343 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Оптика Алаколь — Строительство волоконно-оптической сети</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>">
|
||||
<title>Starlink — Учёт абонентов</title>
|
||||
<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}
|
||||
body{font:17px/1.6 'Manrope',-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--white)}
|
||||
.container{max-width:1140px;margin:0 auto;padding:80px 24px}
|
||||
.hero{background:linear-gradient(135deg,var(--ink) 0%,#1a1f2e 100%);color:var(--white);padding:100px 24px}
|
||||
.hero .container{padding:0;max-width:900px}
|
||||
.hero h1{font:800 clamp(36px,6vw,56px)/1.05 'Unbounded',sans-serif;margin-bottom:24px}
|
||||
.hero p{font-size:20px;color:#9aa3b2;margin-bottom:32px;max-width:600px}
|
||||
.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}
|
||||
.btn:hover{transform:translateY(-2px)}
|
||||
.btn-outline{background:transparent;border:2px solid var(--cyan);color:var(--cyan);margin-left:12px}
|
||||
.section{padding:80px 24px}
|
||||
.section h2{font:700 clamp(28px,4vw,36px)/1.15 'Unbounded',sans-serif;margin-bottom:40px;text-align:center}
|
||||
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:24px;max-width:1140px;margin:0 auto}
|
||||
.card{background:var(--gray-100);border-radius:16px;padding:32px;transition:transform .2s}
|
||||
.card:hover{transform:translateY(-4px)}
|
||||
.card-icon{font-size:48px;margin-bottom:16px}
|
||||
.card h3{font:700 22px/1.3 'Unbounded',sans-serif;margin-bottom:12px}
|
||||
.card p{color:var(--gray-500)}
|
||||
.stats{background:var(--cyan-50);text-align:center}
|
||||
.stat-number{font:800 clamp(48px,8vw,72px)/1 'Unbounded',sans-serif;color:var(--blue)}
|
||||
.stat-label{font-size:18px;color:var(--gray-500);margin-top:8px}
|
||||
.map-section{background:var(--ink);color:var(--white)}
|
||||
.map-section h2{color:var(--white)}
|
||||
.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}
|
||||
.map-placeholder p{color:#9aa3b2;font-size:18px}
|
||||
.form-section{background:var(--gray-100)}
|
||||
.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)}
|
||||
.form-group{margin-bottom:20px}
|
||||
.form-group label{display:block;font-weight:600;margin-bottom:8px}
|
||||
.form-group input,.form-group textarea{width:100%;padding:14px;border:1px solid #e0e0e0;border-radius:8px;font-size:16px;font-family:inherit}
|
||||
.form-group input:focus,.form-group textarea:focus{outline:none;border-color:var(--blue)}
|
||||
.form-group textarea{resize:vertical;min-height:100px}
|
||||
.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}
|
||||
.submit-btn:hover{background:#0052cc}
|
||||
.contact-info{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:24px;margin-top:40px}
|
||||
.contact-item{text-align:center}
|
||||
.contact-icon{font-size:32px;margin-bottom:8px}
|
||||
.reveal{opacity:0;transform:translateY(20px);transition:opacity .6s ease,transform .6s ease}
|
||||
.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}}
|
||||
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>
|
||||
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<h1>Оптика Алаколь</h1>
|
||||
<p>Строительство современной волоконно-оптической сети на 400 абонентов в Алакольском районе. Высокоскоростной интернет для дома и бизнеса.</p>
|
||||
<a class="btn" href="#connect">Подключиться</a>
|
||||
<a class="btn btn-outline" href="#project">О проекте</a>
|
||||
<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>
|
||||
</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 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 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>
|
||||
</section>
|
||||
|
||||
<section class="section stats">
|
||||
<div class="container">
|
||||
<div class="cards">
|
||||
<div class="card reveal">
|
||||
<div class="stat-number">400</div>
|
||||
<div class="stat-label">абонентов</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>
|
||||
<div class="card reveal">
|
||||
<div class="stat-number">100+</div>
|
||||
<div class="stat-label">км оптоволокна</div>
|
||||
</div>
|
||||
<div class="card reveal">
|
||||
<div class="stat-number">10</div>
|
||||
<div class="stat-label">населённых пунктов</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2 class="reveal">Преимущества</h2>
|
||||
<div class="cards">
|
||||
<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 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 class="form-group">
|
||||
<label for="phone">Номер телефона</label>
|
||||
<input type="tel" id="phone" name="phone" placeholder="+7 (777) 123-45-67" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<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>
|
||||
<button type="submit" class="btn">Добавить абонента</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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 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>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
var io=new IntersectionObserver(function(entries){
|
||||
entries.forEach(function(e){ if(e.isIntersecting) e.target.classList.add('in'); });
|
||||
},{threshold:.15});
|
||||
document.querySelectorAll('.reveal').forEach(function(el){ io.observe(el); });
|
||||
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>
|
||||
|
||||
8
package.json
Normal file
8
package.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "starlink-subscribers",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
}
|
||||
}
|
||||
137
server.js
Normal file
137
server.js
Normal 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));
|
||||
Loading…
Reference in New Issue
Block a user