vols-registry: полное приложение учёта ВОЛС
This commit is contained in:
parent
9eb984fb6f
commit
b2ff9adf03
917
app.js
Normal file
917
app.js
Normal file
@ -0,0 +1,917 @@
|
||||
// VOLS Registry - Kazakhtelecom Business
|
||||
// State
|
||||
let state = {
|
||||
screen: 'start',
|
||||
device: null,
|
||||
mode: null,
|
||||
user: null,
|
||||
users: [],
|
||||
records: [],
|
||||
audit: [],
|
||||
filter: { segment: '', oblast: '', status: '', search: '' },
|
||||
sort: { key: null, dir: 'asc' },
|
||||
editingId: null,
|
||||
showAdminPopup: false
|
||||
};
|
||||
|
||||
const TODAY = '2026-08-22';
|
||||
const SUBDIVISIONS = ['ДПБО', 'ДПККК'];
|
||||
const SEGMENTS = ['Корпоративный', 'Гос.органы', 'Банки', 'Телеком', 'Нефтегаз', 'Энергетика', 'Транспорт', 'Другой'];
|
||||
const OBLASTS = ['Алматы', 'Астана', 'Шымкент', 'Актюбинская', 'Атырауская', 'Западно-Казахстанская', 'Карагандинская', 'Костанайская', 'Кызылординская', 'Мангистауская', 'Павлодарская', 'Северо-Казахстанская', 'Туркестанская', 'Восточно-Казахстанская', 'Жамбылская', 'Улытауская', 'Абайская'];
|
||||
const BUILD_STATUSES = ['Проект', 'СМР', 'СМР завершено', 'Исключено'];
|
||||
const CONNECT_STATUSES = ['Подключено', 'Заявка', 'Отказ', 'Перспектива'];
|
||||
|
||||
// Storage helpers
|
||||
const storage = {
|
||||
get(key, fallback) { try { return JSON.parse(localStorage.getItem(key)) ?? fallback; } catch { return fallback; } },
|
||||
set(key, val) { localStorage.setItem(key, JSON.stringify(val)); }
|
||||
};
|
||||
|
||||
// Seed data
|
||||
function seedData() {
|
||||
const defaultUsers = [
|
||||
{ id: 1, lastName: 'Торегожиев', firstName: 'Саяжан', middleName: 'Сабырович', phone: '+77771112233', role: 'admin', region: 'all' },
|
||||
{ id: 2, lastName: 'Иванов', firstName: 'Петр', middleName: '', phone: '+77774445566', role: 'editor', region: 'ДПБО' },
|
||||
{ id: 3, lastName: 'Смирнов', firstName: 'Алексей', middleName: '', phone: '+77777778899', role: 'editor', region: 'ДПККК' },
|
||||
{ id: 4, lastName: 'Гость', firstName: 'Тестовый', middleName: '', phone: '+77770001122', role: 'view', region: 'none' }
|
||||
];
|
||||
|
||||
const genRecords = () => {
|
||||
const r = [];
|
||||
const clients = ['ТОО "KazEnergy"', 'АО "Народный Банк"', 'ТОО "TelecomPlus"', 'ТОО "OilServ"', 'АО "ТрансЛогистик"', 'ТОО "TechSoft"', 'ТОО "BuildMaster"', 'АО "KazMunay"', 'ТОО "DataCenter"', 'ТОО "SmartGrid"'];
|
||||
const activities = ['Нефтегазовый сектор', 'Финансы и банки', 'Телекоммуникации', 'Транспорт и логистика', 'IT и технологии', 'Строительство', 'Энергетика', 'Государственные услуги'];
|
||||
const cities = ['Алматы', 'Астана', 'Шымкент', 'Актобе', 'Атырау', 'Уральск', 'Караганда', 'Костанай', 'Кызылорда', 'Актау', 'Павлодар', 'Петропавловск', 'Туркестан', 'Усть-Каменогорск', 'Тараз'];
|
||||
const addresses = ['ул. Абая 150', 'пр. Достык 234', 'ул. Толе би 89', 'ул. Сатпаева 45', 'пр. Абая 12', 'ул. Желтоксан 67', 'ул. Карасай батыра 101', 'пр.Tauke хана 78'];
|
||||
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const sub = SUBDIVISIONS[i % 2];
|
||||
const obl = OBLASTS[i % OBLASTS.length];
|
||||
const seg = SEGMENTS[i % SEGMENTS.length];
|
||||
const buildStart = new Date(2025, Math.floor(Math.random()*12), Math.floor(Math.random()*28)+1);
|
||||
const buildEnd = new Date(buildStart);
|
||||
buildEnd.setMonth(buildEnd.getMonth() + Math.floor(Math.random()*6)+3);
|
||||
const buildEndStr = buildEnd.toISOString().slice(0,10);
|
||||
const connected = Math.random() > 0.4;
|
||||
const connDate = connected ? new Date(buildEnd.getFullYear(), buildEnd.getMonth()+1, Math.floor(Math.random()*28)+1) : null;
|
||||
|
||||
r.push({
|
||||
id: Date.now() + i,
|
||||
subdivision: sub,
|
||||
oblast: obl,
|
||||
district: cities[i % cities.length] + 'ский',
|
||||
city: cities[i % cities.length],
|
||||
address: addresses[i % addresses.length] + ', офис ' + (Math.floor(Math.random()*500)+10),
|
||||
client: clients[i % clients.length],
|
||||
activity: activities[Math.floor(Math.random()*activities.length)],
|
||||
hasTv: Math.random() > 0.5,
|
||||
segment: seg,
|
||||
buildStatus: BUILD_STATUSES[Math.floor(Math.random()*BUILD_STATUSES.length)],
|
||||
buildStart: buildStart.toISOString().slice(0,10),
|
||||
buildEnd: buildEndStr,
|
||||
note: i % 5 === 0 ? 'Требуется согласование' : '',
|
||||
deviceNum: 'SW-' + String(1000 + i),
|
||||
connectStatus: connected ? 'Подключено' : CONNECT_STATUSES[Math.floor(Math.random()*(CONNECT_STATUSES.length-1))+1],
|
||||
connectDate: connDate ? connDate.toISOString().slice(0,10) : '',
|
||||
tariff: connected ? ['Бизнес Старт', 'Бизнес Оптима', 'Бизнес Премиум', 'Корпоративный'][Math.floor(Math.random()*4)] : '',
|
||||
arpu: connected ? Math.floor(Math.random()*150)+30 : 0
|
||||
});
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
if (!storage.get('volsUsers', null)) storage.set('volsUsers', defaultUsers);
|
||||
if (!storage.get('volsRecords', null)) storage.set('volsRecords', genRecords());
|
||||
if (!storage.get('volsAudit', null)) storage.set('volsAudit', []);
|
||||
|
||||
state.users = storage.get('volsUsers');
|
||||
state.records = storage.get('volsRecords');
|
||||
state.audit = storage.get('volsAudit');
|
||||
}
|
||||
|
||||
// Audit log
|
||||
function logAudit(action, details) {
|
||||
if (!state.user) return;
|
||||
const entry = {
|
||||
id: Date.now(),
|
||||
date: new Date().toISOString().slice(0,19).replace('T', ' '),
|
||||
user: state.user.lastName + ' ' + state.user.firstName.charAt(0) + '.',
|
||||
action,
|
||||
details
|
||||
};
|
||||
state.audit.unshift(entry);
|
||||
if (state.audit.length > 300) state.audit.pop();
|
||||
storage.set('volsAudit', state.audit);
|
||||
}
|
||||
|
||||
// Render
|
||||
function render() {
|
||||
const app = document.getElementById('app');
|
||||
if (state.screen === 'start') { app.innerHTML = renderStart(); initStart(); }
|
||||
else if (state.screen === 'auth') { app.innerHTML = renderAuth(); initAuth(); }
|
||||
else if (state.screen === 'main') { app.innerHTML = renderMain(); initMain(); }
|
||||
}
|
||||
|
||||
function renderStart() {
|
||||
return `
|
||||
<div class="start-screen">
|
||||
<svg class="fiber-trace" viewBox="0 0 800 600"><path d="M0,300 C150,250 250,350 400,300 C550,250 650,350 800,300" stroke="#8DC63F" stroke-width="2" fill="none" opacity="0.3"/><path d="M0,320 C150,270 250,370 400,320 C550,270 650,370 800,320" stroke="#4B1D6B" stroke-width="2" fill="none" opacity="0.2"/></svg>
|
||||
<div class="start-content">
|
||||
<svg viewBox="0 0 48 48" class="kt-logo"><rect width="48" height="48" rx="8" fill="#4B1D6B"/><text x="24" y="32" text-anchor="middle" fill="white" font-size="20" font-weight="800" font-family="Manrope">KT</text></svg>
|
||||
<h1 class="start-title font-manrope">Реестр объектов ВОЛС<br><span style="color:#6B6478;font-size:1rem;font-weight:500;">Kazakhtelecom Business</span></h1>
|
||||
<div class="device-grid">
|
||||
<button class="device-btn" data-device="desktop"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg><span>Компьютер</span></button>
|
||||
<button class="device-btn" data-device="mobile"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="5" y="2" width="14" height="20" rx="2"/><line x1="12" y1="18" x2="12" y2="18"/><line x1="12" y1="6" x2="12" y2="6"/></svg><span>Телефон</span></button>
|
||||
</div>
|
||||
<div class="mode-grid">
|
||||
<button class="mode-btn" data-mode="view"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg><span>Только просмотр</span></button>
|
||||
<button class="mode-btn" data-mode="work"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg><span>Редактирование</span></button>
|
||||
</div>
|
||||
<button class="continue-btn" id="continueBtn">Продолжить</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function initStart() {
|
||||
let device = null, mode = null;
|
||||
document.querySelectorAll('.device-btn').forEach(btn => {
|
||||
btn.onclick = () => { device = btn.dataset.device; document.querySelectorAll('.device-btn').forEach(b=>b.classList.remove('selected')); btn.classList.add('selected'); updateContinue(); };
|
||||
});
|
||||
document.querySelectorAll('.mode-btn').forEach(btn => {
|
||||
btn.onclick = () => { mode = btn.dataset.mode; document.querySelectorAll('.mode-btn').forEach(b=>b.classList.remove('selected')); btn.classList.add('selected'); updateContinue(); };
|
||||
});
|
||||
function updateContinue() {
|
||||
const btn = document.getElementById('continueBtn');
|
||||
if (device && mode) { btn.classList.add('active'); btn.onclick = () => { state.device = device; state.mode = mode; state.screen = 'auth'; render(); }; }
|
||||
}
|
||||
}
|
||||
|
||||
function renderAuth() {
|
||||
const demoUsers = state.users.slice(0, 4);
|
||||
return `
|
||||
<div class="auth-screen">
|
||||
<div class="auth-card">
|
||||
<h2 class="auth-title font-manrope">Вход в систему</h2>
|
||||
<p class="auth-subtitle">Выберите демо-пользователя или зарегистрируйтесь</p>
|
||||
<div class="demo-section">
|
||||
<div class="section-label">Демо-пользователи</div>
|
||||
${demoUsers.map(u => `
|
||||
<button class="user-btn" data-id="${u.id}">
|
||||
<div class="user-info">
|
||||
<div>${u.lastName} ${u.firstName}</div>
|
||||
<div>${u.phone}</div>
|
||||
</div>
|
||||
<span class="role-tag role-${u.role}">${u.role === 'admin' ? 'Админ' : u.role === 'editor' ? 'Редактор' : 'Просмотр'}</span>
|
||||
</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
<div class="register-section">
|
||||
<div class="section-label">Новый пользователь</div>
|
||||
<div class="register-grid">
|
||||
<input class="input-field" id="regLast" placeholder="Фамилия">
|
||||
<input class="input-field" id="regFirst" placeholder="Имя">
|
||||
<input class="input-field input-full" id="regMiddle" placeholder="Отчество">
|
||||
<input class="input-field input-full" id="regPhone" placeholder="Телефон">
|
||||
</div>
|
||||
<button class="register-btn" id="regBtn">Зарегистрироваться</button>
|
||||
</div>
|
||||
${state.mode === 'view' ? '<a href="#" class="back-link" id="backLink">← Назад</a>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function initAuth() {
|
||||
document.querySelectorAll('.user-btn').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const u = state.users.find(x => x.id === +btn.dataset.id);
|
||||
state.user = u;
|
||||
logAudit('Вход в систему', `Пользователь: ${u.lastName} ${u.firstName}`);
|
||||
state.screen = 'main';
|
||||
render();
|
||||
};
|
||||
});
|
||||
const regBtn = document.getElementById('regBtn');
|
||||
if (regBtn) regBtn.onclick = () => {
|
||||
const last = document.getElementById('regLast').value.trim();
|
||||
const first = document.getElementById('regFirst').value.trim();
|
||||
const middle = document.getElementById('regMiddle').value.trim();
|
||||
const phone = document.getElementById('regPhone').value.trim();
|
||||
if (!last || !first || !phone) { alert('Заполните фамилию, имя и телефон'); return; }
|
||||
const newUser = { id: Date.now(), lastName: last, firstName: first, middleName: middle, phone, role: 'view', region: 'none' };
|
||||
state.users.push(newUser);
|
||||
storage.set('volsUsers', state.users);
|
||||
state.user = newUser;
|
||||
logAudit('Регистрация', `Новый пользователь: ${last} ${first}`);
|
||||
state.screen = 'main';
|
||||
render();
|
||||
};
|
||||
const back = document.getElementById('backLink');
|
||||
if (back) back.onclick = (e) => { e.preventDefault(); state.screen = 'start'; render(); };
|
||||
}
|
||||
|
||||
function renderMain() {
|
||||
const u = state.user;
|
||||
const filtered = getFilteredRecords();
|
||||
const stats = getStats(filtered);
|
||||
const isAdmin = u.role === 'admin';
|
||||
|
||||
return `
|
||||
${isAdmin ? `<button class="admin-popup-btn" id="adminPopupBtn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg></button>` : ''}
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar ${state.device === 'mobile' ? 'open' : ''}" id="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<svg viewBox="0 0 48 48" class="sidebar-logo"><rect width="48" height="48" rx="8" fill="#8DC63F"/><text x="24" y="30" text-anchor="middle" fill="#331149" font-size="18" font-weight="800" font-family="Manrope">KT</text></svg>
|
||||
<div class="sidebar-brand font-manrope">Реестр ВОЛС<br><span style="font-weight:500;opacity:0.7;font-size:0.75rem;">Business</span></div>
|
||||
</div>
|
||||
<nav>
|
||||
<button class="nav-item active" data-view="table"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="9" y1="21" x2="9" y2="9"/></svg>Таблица</button>
|
||||
<button class="nav-item" data-view="dashboard"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>Дашборд</button>
|
||||
${isAdmin ? `<button class="nav-item" data-view="users"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>Пользователи</button>` : ''}
|
||||
${isAdmin ? `<button class="nav-item" data-view="audit"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>Аудит</button>` : ''}
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-name">${u.lastName} ${u.firstName}</div>
|
||||
<div class="user-meta">${u.role === 'admin' ? 'Администратор' : u.role === 'editor' ? 'Редактор (' + u.region + ')' : 'Просмотр'}</div>
|
||||
<button class="logout-btn" id="logoutBtn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>Выйти</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main-content">
|
||||
<div class="top-bar">
|
||||
<div class="top-header">
|
||||
<button class="mobile-menu-btn" id="menuBtn"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="18" x2="21" y2="18"/></svg></button>
|
||||
<h1 class="page-title font-manrope" id="pageTitle">Реестр объектов</h1>
|
||||
<div class="saved-badge"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/><path d="M22 12a10 10 0 1 1-2.9-7.1"/></svg>Сохранено</div>
|
||||
</div>
|
||||
<div class="action-bar" id="actionBar">
|
||||
<div class="search-box"><svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg><input class="search-input" id="searchInput" placeholder="Поиск по всем полям"></div>
|
||||
<select class="filter-select" id="segmentFilter"><option value="">Все сегменты</option>${SEGMENTS.map(s=>`<option>${s}</option>`).join('')}</select>
|
||||
<select class="filter-select" id="oblastFilter"><option value="">Все области</option>${OBLASTS.map(o=>`<option>${o}</option>`).join('')}</select>
|
||||
<select class="filter-select" id="statusFilter"><option value="">Все статусы</option>${CONNECT_STATUSES.map(s=>`<option>${s}</option>`).join('')}</select>
|
||||
${u.role !== 'view' ? `<button class="action-btn primary-btn" id="addBtn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>Добавить</button>` : ''}
|
||||
<button class="action-btn" id="exportBtn"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>Excel</button>
|
||||
<button class="reset-btn" id="resetBtn">Сбросить фильтры</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-area" id="contentArea"></div>
|
||||
</main>
|
||||
</div>
|
||||
${isAdmin ? renderAdminPopup() : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAdminPopup() {
|
||||
const u = state.user;
|
||||
return `
|
||||
<div class="admin-popup" id="adminPopup" style="display:none;">
|
||||
<div class="admin-popup-label">Администратор системы</div>
|
||||
<div class="admin-popup-name font-manrope">${u.lastName} ${u.firstName} ${u.middleName}</div>
|
||||
<div class="admin-popup-phone"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg> ${u.phone}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function initMain() {
|
||||
const u = state.user;
|
||||
let currentView = 'table';
|
||||
let detailId = null;
|
||||
|
||||
// Mobile menu
|
||||
const menuBtn = document.getElementById('menuBtn');
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
if (menuBtn) menuBtn.onclick = () => sidebar.classList.toggle('open');
|
||||
|
||||
// Nav
|
||||
document.querySelectorAll('.nav-item').forEach(item => {
|
||||
item.onclick = () => {
|
||||
document.querySelectorAll('.nav-item').forEach(i=>i.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
currentView = item.dataset.view;
|
||||
if (state.device === 'mobile') sidebar.classList.remove('open');
|
||||
renderContent();
|
||||
};
|
||||
});
|
||||
|
||||
// Logout
|
||||
const logout = document.getElementById('logoutBtn');
|
||||
if (logout) logout.onclick = () => { logAudit('Выход', ''); state.screen = 'start'; state.user = null; state.device = null; state.mode = null; render(); };
|
||||
|
||||
// Admin popup
|
||||
const adminPopupBtn = document.getElementById('adminPopupBtn');
|
||||
const adminPopup = document.getElementById('adminPopup');
|
||||
if (adminPopupBtn && adminPopup) {
|
||||
adminPopupBtn.onclick = (e) => { e.stopPropagation(); state.showAdminPopup = !state.showAdminPopup; adminPopup.style.display = state.showAdminPopup ? 'block' : 'none'; };
|
||||
document.addEventListener('click', () => { if (state.showAdminPopup) { state.showAdminPopup = false; adminPopup.style.display = 'none'; } });
|
||||
}
|
||||
|
||||
// Filters
|
||||
const searchInput = document.getElementById('searchInput');
|
||||
const segmentFilter = document.getElementById('segmentFilter');
|
||||
const oblastFilter = document.getElementById('oblastFilter');
|
||||
const statusFilter = document.getElementById('statusFilter');
|
||||
const resetBtn = document.getElementById('resetBtn');
|
||||
|
||||
[searchInput, segmentFilter, oblastFilter, statusFilter].forEach(el => {
|
||||
if (el) el.oninput = () => { state.filter = { search: searchInput?.value || '', segment: segmentFilter?.value || '', oblast: oblastFilter?.value || '', status: statusFilter?.value || '' }; renderContent(); };
|
||||
});
|
||||
|
||||
if (resetBtn) resetBtn.onclick = () => { state.filter = { search: '', segment: '', oblast: '', status: '' }; searchInput.value = ''; segmentFilter.value = ''; oblastFilter.value = ''; statusFilter.value = ''; renderContent(); };
|
||||
|
||||
// Add
|
||||
const addBtn = document.getElementById('addBtn');
|
||||
if (addBtn) addBtn.onclick = () => openModal();
|
||||
|
||||
// Export
|
||||
const exportBtn = document.getElementById('exportBtn');
|
||||
if (exportBtn) exportBtn.onclick = exportToExcel;
|
||||
|
||||
renderContent();
|
||||
|
||||
function renderContent() {
|
||||
const area = document.getElementById('contentArea');
|
||||
const title = document.getElementById('pageTitle');
|
||||
const actionBar = document.getElementById('actionBar');
|
||||
|
||||
if (currentView === 'table' || currentView === 'dashboard') {
|
||||
actionBar.style.display = 'flex';
|
||||
} else {
|
||||
actionBar.style.display = 'none';
|
||||
}
|
||||
|
||||
if (currentView === 'table') {
|
||||
title.textContent = 'Реестр объектов';
|
||||
const filtered = getFilteredRecords();
|
||||
if (state.device === 'desktop') {
|
||||
area.innerHTML = renderTable(filtered);
|
||||
initTable(area);
|
||||
} else {
|
||||
area.innerHTML = renderMobileCards(filtered);
|
||||
initMobileCards(area);
|
||||
}
|
||||
} else if (currentView === 'dashboard') {
|
||||
title.textContent = 'Дашборд';
|
||||
area.innerHTML = renderDashboard();
|
||||
initDashboard();
|
||||
} else if (currentView === 'users') {
|
||||
title.textContent = 'Пользователи';
|
||||
area.innerHTML = renderUsers();
|
||||
initUsers();
|
||||
} else if (currentView === 'audit') {
|
||||
title.textContent = 'Журнал аудита';
|
||||
area.innerHTML = renderAudit();
|
||||
}
|
||||
}
|
||||
|
||||
function getFilteredRecords() {
|
||||
const f = state.filter;
|
||||
return state.records.filter(r => {
|
||||
if (f.segment && r.segment !== f.segment) return false;
|
||||
if (f.oblast && r.oblast !== f.oblast) return false;
|
||||
if (f.status && r.connectStatus !== f.status) return false;
|
||||
if (f.search) {
|
||||
const s = f.search.toLowerCase();
|
||||
const hay = Object.values(r).join(' ').toLowerCase();
|
||||
if (!hay.includes(s)) return false;
|
||||
}
|
||||
// Role filter
|
||||
if (u.role === 'editor' && u.region !== 'all' && r.subdivision !== u.region) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(records) {
|
||||
const cols = [
|
||||
{ key: 'subdivision', label: 'Подразд.' },
|
||||
{ key: 'oblast', label: 'Область' },
|
||||
{ key: 'client', label: 'Клиент' },
|
||||
{ key: 'segment', label: 'Сегмент' },
|
||||
{ key: 'buildStatus', label: 'Стат. строит.' },
|
||||
{ key: 'connectStatus', label: 'Стат. подкл.' },
|
||||
{ key: 'arpu', label: 'ARPU' },
|
||||
{ key: 'actions', label: '' }
|
||||
];
|
||||
|
||||
const isOverdue = r => r.buildEnd < TODAY && !['СМР завершено', 'Исключено'].includes(r.buildStatus);
|
||||
|
||||
return `
|
||||
<div class="table-container">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead><tr>${cols.map(c => `<th data-sort="${c.key}">${c.label} ${state.sort.key === c.key ? (state.sort.dir === 'asc' ? '↑' : '↓') : ''}</th>`).join('')}</tr></thead>
|
||||
<tbody>
|
||||
${records.length === 0 ? '<tr><td colspan="8" style="text-align:center;padding:2rem;color:#6B6478;">Нет данных</td></tr>' : ''}
|
||||
${records.map(r => `
|
||||
<tr class="${isOverdue(r) ? 'overdue' : ''}" data-id="${r.id}">
|
||||
<td>${r.subdivision}</td>
|
||||
<td>${r.oblast}</td>
|
||||
<td>${r.client}</td>
|
||||
<td>${r.segment}</td>
|
||||
<td><span class="badge ${r.buildStatus === 'СМР завершено' ? 'badge-green' : r.buildStatus === 'СМР' ? 'badge-yellow' : r.buildStatus === 'Исключено' ? 'badge-gray' : 'badge-blue'}">${r.buildStatus}</span></td>
|
||||
<td><span class="badge ${r.connectStatus === 'Подключено' ? 'badge-green' : r.connectStatus === 'Отказ' ? 'badge-red' : 'badge-blue'}">${r.connectStatus}</span></td>
|
||||
<td style="font-weight:600;color:#4B1D6B;">${r.arpu ? r.arpu + ' ₸' : '-'}</td>
|
||||
<td class="action-cell">
|
||||
${u.role !== 'view' ? `<button class="edit-btn" data-edit="${r.id}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>` : ''}
|
||||
${u.role === 'admin' ? `<button class="delete-btn" data-del="${r.id}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function initTable(area) {
|
||||
// Sort
|
||||
area.querySelectorAll('th[data-sort]').forEach(th => {
|
||||
th.onclick = () => {
|
||||
const key = th.dataset.sort;
|
||||
if (!key || key === 'actions') return;
|
||||
if (state.sort.key === key) {
|
||||
state.sort.dir = state.sort.dir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
state.sort.key = key;
|
||||
state.sort.dir = 'asc';
|
||||
}
|
||||
renderContent();
|
||||
};
|
||||
});
|
||||
|
||||
// Edit
|
||||
area.querySelectorAll('[data-edit]').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const r = state.records.find(x => x.id === +btn.dataset.edit);
|
||||
openModal(r);
|
||||
};
|
||||
});
|
||||
|
||||
// Delete
|
||||
area.querySelectorAll('[data-del]').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const r = state.records.find(x => x.id === +btn.dataset.del);
|
||||
openConfirm(r);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderMobileCards(records) {
|
||||
if (records.length === 0) return '<div style="text-align:center;padding:2rem;color:#6B6478;">Нет данных</div>';
|
||||
return `<div class="mobile-cards">${records.map(r => `
|
||||
<div class="mobile-card ${r.buildEnd < TODAY && !['СМР завершено', 'Исключено'].includes(r.buildStatus) ? 'overdue' : ''}" data-id="${r.id}">
|
||||
<div class="mobile-card-header">
|
||||
<div class="mobile-card-title">${r.client}</div>
|
||||
<span class="badge ${r.connectStatus === 'Подключено' ? 'badge-green' : 'badge-blue'}">${r.connectStatus}</span>
|
||||
</div>
|
||||
<div class="mobile-card-meta">${r.oblast}, ${r.city}</div>
|
||||
<div class="mobile-card-badges">
|
||||
<span class="mobile-card-badge">${r.segment}</span>
|
||||
<span class="mobile-card-badge">${r.buildStatus}</span>
|
||||
<span class="mobile-card-badge">ARPU: ${r.arpu || 0} ₸</span>
|
||||
</div>
|
||||
<button class="mobile-card-edit" data-detail="${r.id}">Подробнее</button>
|
||||
</div>
|
||||
`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function initMobileCards(area) {
|
||||
area.querySelectorAll('[data-detail]').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
detailId = +btn.dataset.detail;
|
||||
openDetail(detailId);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function openDetail(id) {
|
||||
const r = state.records.find(x => x.id === id);
|
||||
if (!r) return;
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-content mobile-detail">
|
||||
<div class="mobile-detail-header">
|
||||
<div class="mobile-detail-title font-manrope">${r.client}</div>
|
||||
<button class="close-btn" id="closeDetail"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
${renderDetailRow('Подразделение', r.subdivision)}
|
||||
${renderDetailRow('Область', r.oblast)}
|
||||
${renderDetailRow('Район', r.district)}
|
||||
${renderDetailRow('Город', r.city)}
|
||||
${renderDetailRow('Адрес', r.address)}
|
||||
${renderDetailRow('Сегмент', r.segment)}
|
||||
${renderDetailRow('Вид деятельности', r.activity)}
|
||||
${renderDetailRow('Наличие ТВ', r.hasTv ? 'Да' : 'Нет')}
|
||||
${renderDetailRow('Статус строительства', r.buildStatus)}
|
||||
${renderDetailRow('Начало строительства', r.buildStart)}
|
||||
${renderDetailRow('Срок строительства', r.buildEnd)}
|
||||
${renderDetailRow('Примечание', r.note || '-')}
|
||||
${renderDetailRow('№ устройства', r.deviceNum)}
|
||||
${renderDetailRow('Статус подключения', r.connectStatus)}
|
||||
${renderDetailRow('Дата подключения', r.connectDate || '-')}
|
||||
${renderDetailRow('Тариф', r.tariff || '-')}
|
||||
${renderDetailRow('ARPU', r.arpu ? r.arpu + ' ₸' : '-')}
|
||||
${u.role !== 'view' ? `<button class="mobile-detail-edit" id="editDetail">Редактировать</button>` : ''}
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById('closeDetail').onclick = () => overlay.remove();
|
||||
const editBtn = document.getElementById('editDetail');
|
||||
if (editBtn) editBtn.onclick = () => { overlay.remove(); openModal(r); };
|
||||
}
|
||||
|
||||
function renderDetailRow(label, value) {
|
||||
return `<div class="detail-row"><span class="detail-label">${label}</span><span class="detail-value">${value}</span></div>`;
|
||||
}
|
||||
|
||||
function renderDashboard() {
|
||||
const all = state.records;
|
||||
const s = getStats(all);
|
||||
const byOblast = {};
|
||||
const byConnect = {};
|
||||
const byDept = { ДПБО: { total: 0, connected: 0, arpu: 0 }, ДПККК: { total: 0, connected: 0, arpu: 0 } };
|
||||
|
||||
all.forEach(r => {
|
||||
byOblast[r.oblast] = (byOblast[r.oblast] || 0) + 1;
|
||||
byConnect[r.connectStatus] = (byConnect[r.connectStatus] || 0) + 1;
|
||||
if (SUBDIVISIONS.includes(r.subdivision)) {
|
||||
byDept[r.subdivision].total++;
|
||||
if (r.connectStatus === 'Подключено') { byDept[r.subdivision].connected++; byDept[r.subdivision].arpu += r.arpu || 0; }
|
||||
}
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="dashboard-grid">
|
||||
<div class="dash-card"><div class="dash-value">${s.total}</div><div class="dash-label">Всего объектов</div></div>
|
||||
<div class="dash-card"><div class="dash-value">${s.connected}</div><div class="dash-label">Подключено</div></div>
|
||||
<div class="dash-card"><div class="dash-value">${s.pending}</div><div class="dash-label">В работе</div></div>
|
||||
<div class="dash-card"><div class="dash-value">${s.totalArpu.toLocaleString()} ₸</div><div class="dash-label">Суммарный ARPU</div></div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title font-manrope">Объекты по областям</div>
|
||||
<div class="chart-wrapper"><canvas id="oblastChart"></canvas></div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<div class="chart-title font-manrope">Статусы подключения</div>
|
||||
<div class="chart-wrapper"><canvas id="connectChart"></canvas></div>
|
||||
</div>
|
||||
<div class="summary-box">
|
||||
<div class="summary-item"><span>ДПБО: всего </span><b>${byDept.ДПБО.total}</b><span>, подключено </span><b>${byDept.ДПБО.connected}</b><span>, ARPU </span><b>${byDept.ДПБО.arpu.toLocaleString()} ₸</b></div>
|
||||
<div class="summary-item"><span>ДПККК: всего </span><b>${byDept.ДПККК.total}</b><span>, подключено </span><b>${byDept.ДПККК.connected}</b><span>, ARPU </span><b>${byDept.ДПККК.arpu.toLocaleString()} ₸</b></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function initDashboard() {
|
||||
const all = state.records;
|
||||
const byOblast = {};
|
||||
const byConnect = {};
|
||||
all.forEach(r => {
|
||||
byOblast[r.oblast] = (byOblast[r.oblast] || 0) + 1;
|
||||
byConnect[r.connectStatus] = (byConnect[r.connectStatus] || 0) + 1;
|
||||
});
|
||||
|
||||
new Chart(document.getElementById('oblastChart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: Object.keys(byOblast),
|
||||
datasets: [{ label: 'Объекты', data: Object.values(byOblast), backgroundColor: '#4B1D6B', borderRadius: 4 }]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, grid: { color: '#f0f0f0' } }, x: { grid: { display: false } } } }
|
||||
});
|
||||
|
||||
new Chart(document.getElementById('connectChart'), {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: Object.keys(byConnect),
|
||||
datasets: [{ data: Object.values(byConnect), backgroundColor: ['#22c55e', '#3b82f6', '#f59e0b', '#ef4444'], borderWidth: 0 }]
|
||||
},
|
||||
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'right' } } }
|
||||
});
|
||||
}
|
||||
|
||||
function renderUsers() {
|
||||
return `
|
||||
<div class="table-container">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>ФИО</th><th>Телефон</th><th>Роль</th><th>Регион</th><th>Действия</th></tr></thead>
|
||||
<tbody>
|
||||
${state.users.map(u => `
|
||||
<tr>
|
||||
<td>${u.lastName} ${u.firstName} ${u.middleName}</td>
|
||||
<td>${u.phone}</td>
|
||||
<td><span class="role-tag role-${u.role}">${u.role === 'admin' ? 'Админ' : u.role === 'editor' ? 'Редактор' : 'Просмотр'}</span></td>
|
||||
<td>${u.region === 'all' ? 'Все' : u.region === 'none' ? '-' : u.region}</td>
|
||||
<td class="action-cell">
|
||||
<button class="edit-btn" data-edit-user="${u.id}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
|
||||
${u.id !== state.user.id ? `<button class="delete-btn" data-del-user="${u.id}"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg></button>` : ''}
|
||||
</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function initUsers() {
|
||||
area = document.getElementById('contentArea');
|
||||
area.querySelectorAll('[data-edit-user]').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const user = state.users.find(x => x.id === +btn.dataset.editUser);
|
||||
openUserModal(user);
|
||||
};
|
||||
});
|
||||
area.querySelectorAll('[data-del-user]').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const user = state.users.find(x => x.id === +btn.dataset.delUser);
|
||||
if (confirm(`Удалить пользователя ${user.lastName} ${user.firstName}?`)) {
|
||||
state.users = state.users.filter(x => x.id !== user.id);
|
||||
storage.set('volsUsers', state.users);
|
||||
renderContent();
|
||||
logAudit('Удаление пользователя', `${user.lastName} ${user.firstName}`);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function openUserModal(user) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title font-manrope">Редактирование пользователя</div>
|
||||
<button class="close-btn" id="closeUserModal"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<div class="form-field"><label class="form-label">Фамилия</label><input class="form-input" id="userLast" value="${user.lastName}"></div>
|
||||
<div class="form-field"><label class="form-label">Имя</label><input class="form-input" id="userFirst" value="${user.firstName}"></div>
|
||||
<div class="form-field full"><label class="form-label">Отчество</label><input class="form-input" id="userMiddle" value="${user.middleName}"></div>
|
||||
<div class="form-field full"><label class="form-label">Телефон</label><input class="form-input" id="userPhone" value="${user.phone}"></div>
|
||||
<div class="form-field"><label class="form-label">Роль</label><select class="form-select" id="userRole"><option value="admin" ${user.role==='admin'?'selected':''}>Админ</option><option value="editor" ${user.role==='editor'?'selected':''}>Редактор</option><option value="view" ${user.role==='view'?'selected':''}>Просмотр</option></select></div>
|
||||
<div class="form-field"><label class="form-label">Регион</label><select class="form-select" id="userRegion"><option value="all" ${user.region==='all'?'selected':''}>Все</option><option value="ДПБО" ${user.region==='ДПБО'?'selected':''}>ДПБО</option><option value="ДПККК" ${user.region==='ДПККК'?'selected':''}>ДПККК</option><option value="none" ${user.region==='none'?'selected':''}>-</option></select></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" id="cancelUser">Отмена</button>
|
||||
<button class="save-btn" id="saveUser"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/><path d="M22 12a10 10 0 1 1-2.9-7.1"/></svg>Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById('closeUserModal').onclick = () => overlay.remove();
|
||||
document.getElementById('cancelUser').onclick = () => overlay.remove();
|
||||
document.getElementById('saveUser').onclick = () => {
|
||||
user.lastName = document.getElementById('userLast').value.trim();
|
||||
user.firstName = document.getElementById('userFirst').value.trim();
|
||||
user.middleName = document.getElementById('userMiddle').value.trim();
|
||||
user.phone = document.getElementById('userPhone').value.trim();
|
||||
user.role = document.getElementById('userRole').value;
|
||||
user.region = document.getElementById('userRegion').value;
|
||||
storage.set('volsUsers', state.users);
|
||||
overlay.remove();
|
||||
renderContent();
|
||||
logAudit('Изменение пользователя', `${user.lastName} ${user.firstName}`);
|
||||
};
|
||||
}
|
||||
|
||||
function renderAudit() {
|
||||
return `
|
||||
<div class="table-container">
|
||||
<div class="table-scroll">
|
||||
<table class="data-table">
|
||||
<thead><tr><th>Дата</th><th>Пользователь</th><th>Действие</th><th>Детали</th></tr></thead>
|
||||
<tbody>
|
||||
${state.audit.length === 0 ? '<tr><td colspan="4" style="text-align:center;padding:2rem;color:#6B6478;">Журнал пуст</td></tr>' : ''}
|
||||
${state.audit.map(a => `
|
||||
<tr>
|
||||
<td>${a.date}</td>
|
||||
<td>${a.user}</td>
|
||||
<td>${a.action}</td>
|
||||
<td>${a.details}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function openModal(record) {
|
||||
const isEdit = !!record;
|
||||
const canEditAll = u.role === 'admin' || u.role === 'editor';
|
||||
const isDeptEditor = u.role === 'editor' && record && u.region !== 'all' && record.subdivision !== u.region;
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<div class="modal-title font-manrope">${isEdit ? 'Редактирование' : 'Новая запись'}</div>
|
||||
<button class="close-btn" id="closeModal"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg></button>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
${renderField('Подразделение', 'subdivision', record?.subdivision || 'ДПБО', SUBDIVISIONS, !canEditAll || isDeptEditor)}
|
||||
${renderField('Область', 'oblast', record?.oblast || '', OBLASTS, !canEditAll)}
|
||||
${renderField('Район', 'district', record?.district || '', null, !canEditAll)}
|
||||
${renderField('Город', 'city', record?.city || '', null, !canEditAll)}
|
||||
${renderField('Адрес', 'address', record?.address || '', null, !canEditAll, true)}
|
||||
${renderField('Клиент', 'client', record?.client || '', null, !canEditAll)}
|
||||
${renderField('Сегмент', 'segment', record?.segment || SEGMENTS[0], SEGMENTS, !canEditAll)}
|
||||
${renderField('Вид деятельности', 'activity', record?.activity || '', null, !canEditAll, true)}
|
||||
${renderField('Наличие ТВ', 'hasTv', record?.hasTv ? 'true' : 'false', ['true', 'false'], !canEditAll)}
|
||||
${renderField('Статус строительства', 'buildStatus', record?.buildStatus || 'Проект', BUILD_STATUSES, !canEditAll)}
|
||||
${renderField('Начало строительства', 'buildStart', record?.buildStart || '', null, !canEditAll)}
|
||||
${renderField('Срок строительства', 'buildEnd', record?.buildEnd || '', null, !canEditAll)}
|
||||
${renderField('Примечание', 'note', record?.note || '', null, !canEditAll, true)}
|
||||
${renderField('№ устройства', 'deviceNum', record?.deviceNum || '', null, !canEditAll)}
|
||||
${renderField('Статус подключения', 'connectStatus', record?.connectStatus || 'Заявка', CONNECT_STATUSES, !canEditAll)}
|
||||
${renderField('Дата подключения', 'connectDate', record?.connectDate || '', null, !canEditAll)}
|
||||
${renderField('Тариф', 'tariff', record?.tariff || '', null, !canEditAll)}
|
||||
${renderField('ARPU (₸)', 'arpu', record?.arpu || '0', null, !canEditAll)}
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="cancel-btn" id="cancelModal">Отмена</button>
|
||||
<button class="save-btn" id="saveModal"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/><path d="M22 12a10 10 0 1 1-2.9-7.1"/></svg>${isEdit ? 'Сохранить' : 'Создать'}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById('closeModal').onclick = () => overlay.remove();
|
||||
document.getElementById('cancelModal').onclick = () => overlay.remove();
|
||||
document.getElementById('saveModal').onclick = () => {
|
||||
const data = {
|
||||
subdivision: document.getElementById('subdivision').value,
|
||||
oblast: document.getElementById('oblast').value,
|
||||
district: document.getElementById('district').value,
|
||||
city: document.getElementById('city').value,
|
||||
address: document.getElementById('address').value,
|
||||
client: document.getElementById('client').value,
|
||||
segment: document.getElementById('segment').value,
|
||||
activity: document.getElementById('activity').value,
|
||||
hasTv: document.getElementById('hasTv').value === 'true',
|
||||
buildStatus: document.getElementById('buildStatus').value,
|
||||
buildStart: document.getElementById('buildStart').value,
|
||||
buildEnd: document.getElementById('buildEnd').value,
|
||||
note: document.getElementById('note').value,
|
||||
deviceNum: document.getElementById('deviceNum').value,
|
||||
connectStatus: document.getElementById('connectStatus').value,
|
||||
connectDate: document.getElementById('connectDate').value,
|
||||
tariff: document.getElementById('tariff').value,
|
||||
arpu: +document.getElementById('arpu').value || 0
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
const idx = state.records.findIndex(x => x.id === record.id);
|
||||
const old = { ...state.records[idx] };
|
||||
state.records[idx] = { ...record, ...data };
|
||||
storage.set('volsRecords', state.records);
|
||||
logAudit('Редактирование', `ID: ${record.id}, Клиент: ${data.client}`);
|
||||
} else {
|
||||
const newRec = { id: Date.now(), ...data };
|
||||
state.records.push(newRec);
|
||||
storage.set('volsRecords', state.records);
|
||||
logAudit('Создание', `Клиент: ${data.client}`);
|
||||
}
|
||||
|
||||
overlay.remove();
|
||||
renderContent();
|
||||
};
|
||||
}
|
||||
|
||||
function renderField(label, key, value, options, disabled, textarea) {
|
||||
const input = textarea
|
||||
? `<textarea class="form-input" id="${key}" rows="2" ${disabled ? 'disabled' : ''}>${value || ''}</textarea>`
|
||||
: options
|
||||
? `<select class="form-select" id="${key}" ${disabled ? 'disabled' : ''}>${options.map(o => `<option value="${o}" ${value===o?'selected':''}>${o}</option>`).join('')}</select>`
|
||||
: key.includes('Date')
|
||||
? `<input type="date" class="form-input" id="${key}" value="${value || ''}" ${disabled ? 'disabled' : ''}>`
|
||||
: key === 'arpu'
|
||||
? `<input type="number" class="form-input" id="${key}" value="${value || 0}" ${disabled ? 'disabled' : ''}>`
|
||||
: `<input type="text" class="form-input" id="${key}" value="${value || ''}" ${disabled ? 'disabled' : ''}>`;
|
||||
|
||||
return `<div class="form-field${textarea?' full':''}"><label class="form-label">${label}</label>${input}</div>`;
|
||||
}
|
||||
|
||||
function openConfirm(record) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="modal-content confirm-dialog">
|
||||
<div class="confirm-icon"><svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="#DC2626" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg></div>
|
||||
<div style="font-weight:600;margin-bottom:0.5rem;">Удалить запись?</div>
|
||||
<div class="confirm-text">Клиент: <b>${record.client}</b><br>Эту операцию нельзя отменить</div>
|
||||
<div class="confirm-actions">
|
||||
<button class="cancel-btn" id="cancelConfirm">Отмена</button>
|
||||
<button class="confirm-btn" id="confirmDel">Удалить</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
document.getElementById('cancelConfirm').onclick = () => overlay.remove();
|
||||
document.getElementById('confirmDel').onclick = () => {
|
||||
state.records = state.records.filter(x => x.id !== record.id);
|
||||
storage.set('volsRecords', state.records);
|
||||
overlay.remove();
|
||||
renderContent();
|
||||
logAudit('Удаление', `Клиент: ${record.client}`);
|
||||
};
|
||||
}
|
||||
|
||||
function getStats(records) {
|
||||
const total = records.length;
|
||||
const connected = records.filter(r => r.connectStatus === 'Подключено').length;
|
||||
const pending = records.filter(r => ['Заявка', 'Перспектива'].includes(r.connectStatus)).length;
|
||||
const totalArpu = records.filter(r => r.connectStatus === 'Подключено').reduce((s, r) => s + (r.arpu || 0), 0);
|
||||
return { total, connected, pending, totalArpu };
|
||||
}
|
||||
|
||||
function exportToExcel() {
|
||||
const ws = XLSX.utils.json_to_sheet(getFilteredRecords().map(r => ({
|
||||
'Подразделение': r.subdivision,
|
||||
'Область': r.oblast,
|
||||
'Район': r.district,
|
||||
'Город': r.city,
|
||||
'Адрес': r.address,
|
||||
'Клиент': r.client,
|
||||
'Вид деятельности': r.activity,
|
||||
'Наличие ТВ': r.hasTv ? 'Да' : 'Нет',
|
||||
'Сегмент': r.segment,
|
||||
'Статус строительства': r.buildStatus,
|
||||
'Начало строительства': r.buildStart,
|
||||
'Срок строительства': r.buildEnd,
|
||||
'Примечание': r.note,
|
||||
'№ устройства': r.deviceNum,
|
||||
'Статус подключения': r.connectStatus,
|
||||
'Дата подключения': r.connectDate,
|
||||
'Тариф': r.tariff,
|
||||
'ARPU': r.arpu
|
||||
})));
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Объекты');
|
||||
XLSX.writeFile(wb, 'reestr_vols_' + TODAY + '.xlsx');
|
||||
logAudit('Экспорт в Excel', '');
|
||||
}
|
||||
}
|
||||
|
||||
function getFilteredRecords() {
|
||||
const f = state.filter;
|
||||
const u = state.user;
|
||||
return state.records.filter(r => {
|
||||
if (f.segment && r.segment !== f.segment) return false;
|
||||
if (f.oblast && r.oblast !== f.oblast) return false;
|
||||
if (f.status && r.connectStatus !== f.status) return false;
|
||||
if (f.search) {
|
||||
const s = f.search.toLowerCase();
|
||||
const hay = Object.values(r).join(' ').toLowerCase();
|
||||
if (!hay.includes(s)) return false;
|
||||
}
|
||||
if (u?.role === 'editor' && u.region !== 'all' && r.subdivision !== u.region) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function getStats(records) {
|
||||
const total = records.length;
|
||||
const connected = records.filter(r => r.connectStatus === 'Подключено').length;
|
||||
const pending = records.filter(r => r.connectStatus === 'Заявка' || r.connectStatus === 'Перспектива').length;
|
||||
const totalArpu = records.filter(r => r.connectStatus === 'Подключено').reduce((s, r) => s + (r.arpu || 0), 0);
|
||||
return { total, connected, pending, totalArpu };
|
||||
}
|
||||
|
||||
function exportToExcel() {
|
||||
const ws = XLSX.utils.json_to_sheet(getFilteredRecords().map(r => ({
|
||||
'Подразделение': r.subdivision,
|
||||
'Область': r.oblast,
|
||||
'Район': r.district,
|
||||
'Город': r.city,
|
||||
'Адрес': r.address,
|
||||
'Клиент': r.client,
|
||||
'Вид деятельности': r.activity,
|
||||
'Наличие ТВ': r.hasTv ? 'Да' : 'Нет',
|
||||
'Сегмент': r.segment,
|
||||
'Статус строительства': r.buildStatus,
|
||||
'Начало строительства': r.buildStart,
|
||||
'Срок строительства': r.buildEnd,
|
||||
'Примечание': r.note,
|
||||
'№ устройства': r.deviceNum,
|
||||
'Статус подключения': r.connectStatus,
|
||||
'Дата подключения': r.connectDate,
|
||||
'Тариф': r.tariff,
|
||||
'ARPU': r.arpu
|
||||
})));
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Объекты');
|
||||
XLSX.writeFile(wb, 'reestr_vols_' + TODAY + '.xlsx');
|
||||
}
|
||||
|
||||
// Init
|
||||
seedData();
|
||||
render();
|
||||
917
index.html
917
index.html
@ -2,740 +2,195 @@
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Система учёта клиентов</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Реестр объектов ВОЛС | Kazakhtelecom Business</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@500;700;800&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
:root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7;--green:#10B981;--red:#EF4444;--blue:#3B82F6}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;color:var(--ink);background:var(--white)}
|
||||
.container{max-width:1400px;margin:0 auto;padding:24px}
|
||||
.header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;flex-wrap:wrap;gap:12px}
|
||||
.header h1{font-size:24px;font-weight:700}
|
||||
.tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.tab{padding:8px 16px;border:1px solid var(--gray-100);border-radius:8px;cursor:pointer;background:var(--white);font-weight:500}
|
||||
.tab.active{background:var(--ink);color:var(--white);border-color:var(--ink)}
|
||||
.btn{padding:10px 20px;border-radius:8px;border:none;cursor:pointer;font-weight:600;font-size:14px}
|
||||
.btn-primary{background:var(--cyan);color:var(--ink)}
|
||||
.btn-success{background:var(--green);color:var(--white)}
|
||||
.btn-danger{background:var(--red);color:var(--white)}
|
||||
.btn-secondary{background:var(--gray-100);color:var(--ink)}
|
||||
.toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
|
||||
.table-container{overflow-x:auto;background:var(--white);border-radius:12px;border:1px solid var(--gray-100)}
|
||||
table{width:100%;border-collapse:collapse;min-width:1200px}
|
||||
th,td{padding:12px;text-align:left;border-bottom:1px solid var(--gray-100);white-space:nowrap}
|
||||
th{background:var(--gray-100);font-weight:600;position:sticky;top:0}
|
||||
tr:hover{background:var(--cyan-50)}
|
||||
.modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:1000;overflow-y:auto}
|
||||
.modal.open{display:block}
|
||||
.modal-content{background:var(--white);margin:40px auto;max-width:800px;border-radius:12px;padding:24px;max-height:90vh;overflow-y:auto}
|
||||
.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:16px;margin-bottom:16px}
|
||||
.form-group{display:flex;flex-direction:column;gap:4px}
|
||||
.form-group label{font-weight:500;font-size:13px;color:var(--gray-500)}
|
||||
.form-group input,.form-group select,.form-group textarea{padding:10px;border:1px solid var(--gray-100);border-radius:8px;font-size:14px}
|
||||
.form-group textarea{resize:vertical;min-height:80px}
|
||||
.actions{display:flex;gap:8px}
|
||||
.edit-cell{cursor:pointer;color:var(--blue)}
|
||||
.edit-cell:hover{text-decoration:underline}
|
||||
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:24px}
|
||||
.stat-card{background:var(--gray-100);padding:20px;border-radius:12px}
|
||||
.stat-card h3{font-size:14px;color:var(--gray-500);margin-bottom:8px}
|
||||
.stat-card .value{font-size:28px;font-weight:700;color:var(--ink)}
|
||||
.filter-bar{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px;align-items:center}
|
||||
.filter-bar input,.filter-bar select{padding:8px 12px;border:1px solid var(--gray-100);border-radius:8px;font-size:14px}
|
||||
.login-form{max-width:400px;margin:60px auto}
|
||||
.user-info{display:flex;align-items:center;gap:12px}
|
||||
.avatar{width:36px;height:36px;border-radius:50%;background:var(--cyan);display:flex;align-items:center;justify-content:center;font-weight:700}
|
||||
.permission-badge{font-size:11px;padding:4px 8px;background:var(--cyan-50);border-radius:4px;color:var(--ink)}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: 'Inter', sans-serif; background: #F5F3F8; color: #241934; }
|
||||
.font-manrope { font-family: 'Manrope', sans-serif; }
|
||||
|
||||
/* Colors */
|
||||
.bg-violet-soft { background: #EFE7F6; }
|
||||
.bg-violet { background: #4B1D6B; }
|
||||
.bg-violet-deep { background: #331149; }
|
||||
.text-violet { color: #4B1D6B; }
|
||||
.text-lime { color: #8DC63F; }
|
||||
.border-lime { border-color: #8DC63F; }
|
||||
.bg-lime { background: #8DC63F; }
|
||||
|
||||
/* Start Screen */
|
||||
.start-screen { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem; position: relative; overflow: hidden; background: radial-gradient(1200px 600px at 50% -10%, #EFE7F6, #F5F3F8); }
|
||||
.fiber-trace { position: absolute; inset: 0; opacity: 0.4; pointer-events: none; }
|
||||
.start-content { position: relative; z: 10; text-align: center; max-width: 500px; }
|
||||
.kt-logo { width: 48px; height: 48px; object-fit: contain; }
|
||||
.start-title { font-size: 1.5rem; font-weight: 800; margin-top: 1.5rem; line-height: 1.3; }
|
||||
.device-grid, .mode-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-top: 1.5rem; width: 100%; }
|
||||
.device-btn, .mode-btn { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; padding: 1.25rem; border-radius: 1rem; border: 2px solid transparent; background: rgba(255,255,255,0.7); transition: all 0.2s; }
|
||||
.device-btn.selected, .mode-btn.selected { border-color: #4B1D6B; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.08); }
|
||||
.device-btn svg, .mode-btn svg { width: 24px; height: 24px; }
|
||||
.device-btn span, .mode-btn span { font-size: 0.875rem; font-weight: 500; }
|
||||
.continue-btn { width: 100%; padding: 0.875rem; margin-top: 1.5rem; border-radius: 0.75rem; font-weight: 600; color: white; background: linear-gradient(90deg, #4B1D6B, #331149); font-family: 'Manrope', sans-serif; opacity: 0.4; pointer-events: none; }
|
||||
.continue-btn.active { opacity: 1; pointer-events: auto; }
|
||||
|
||||
/* Auth Screen */
|
||||
.auth-screen { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1.5rem; }
|
||||
.auth-card { width: 100%; max-width: 448px; background: white; border-radius: 1rem; border: 1px solid #E6E1ED; padding: 1.75rem; }
|
||||
.auth-title { font-size: 1.25rem; font-weight: 700; margin-bottom: 0.25rem; font-family: 'Manrope', sans-serif; }
|
||||
.auth-subtitle { font-size: 0.75rem; color: #6B6478; margin-bottom: 1.5rem; }
|
||||
.demo-section, .register-section { margin-bottom: 1.5rem; }
|
||||
.section-label { font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #6B6478; margin-bottom: 0.5rem; }
|
||||
.user-btn { width: 100%; display: flex; align-items: center; justify-content: space-between; padding: 0.625rem 0.75rem; border-radius: 0.75rem; border: 1px solid #E6E1ED; text-align: left; transition: border-color 0.2s; margin-bottom: 0.5rem; }
|
||||
.user-btn:hover { border-color: #A78BCA; }
|
||||
.user-info div:first-child { font-size: 0.875rem; font-weight: 500; }
|
||||
.user-info div:last-child { font-size: 0.6875rem; color: #6B6478; }
|
||||
.role-tag { font-size: 0.625rem; font-weight: 600; padding: 0.25rem 0.5rem; border-radius: 9999px; }
|
||||
.role-admin { color: #4B1D6B; background: rgba(75,29,107,0.09); }
|
||||
.role-editor { color: #3B7DD8; background: rgba(59,125,216,0.09); }
|
||||
.role-view { color: #8B8594; background: rgba(139,133,148,0.09); }
|
||||
.register-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||
.input-field { width: 100%; font-size: 0.875rem; border: 1px solid #E6E1ED; border-radius: 0.5rem; padding: 0.5rem 0.75rem; }
|
||||
.input-full { grid-column: span 2; }
|
||||
.register-btn { width: 100%; padding: 0.625rem; border-radius: 0.5rem; font-size: 0.875rem; font-weight: 600; color: white; background: #4B1D6B; margin-top: 0.5rem; }
|
||||
.back-link { font-size: 0.75rem; color: #6B6478; margin-bottom: 1rem; display: inline-block; }
|
||||
|
||||
/* Main App */
|
||||
.app-layout { min-height: 100vh; display: flex; }
|
||||
.sidebar { width: 15rem; background: #331149; display: none; }
|
||||
.sidebar.open { display: flex; }
|
||||
.sidebar-header { display: flex; align-items: center; gap: 0.625rem; padding: 1.25rem; margin-bottom: 2rem; }
|
||||
.sidebar-logo { width: 28px; height: 28px; }
|
||||
.sidebar-brand { color: white; font-size: 0.875rem; font-weight: 700; line-height: 1.2; font-family: 'Manrope', sans-serif; }
|
||||
.nav-item { display: flex; align-items: center; gap: 0.75rem; padding: 0.625rem 0.75rem; margin: 0 0.75rem; border-radius: 0.75rem; font-size: 0.875rem; color: rgba(255,255,255,0.65); transition: all 0.2s; width: calc(100% - 1.5rem); text-align: left; }
|
||||
.nav-item.active { background: rgba(255,255,255,0.12); color: white; }
|
||||
.sidebar-footer { border-top: 1px solid rgba(255,255,255,0.12); margin: 0 0.75rem; padding-top: 1rem; }
|
||||
.user-name { color: white; font-size: 0.75rem; font-weight: 500; }
|
||||
.user-meta { color: rgba(255,255,255,0.6); font-size: 0.6875rem; }
|
||||
.logout-btn { display: flex; align-items: center; gap: 0.5rem; width: 100%; padding: 0.5rem 0.75rem; font-size: 0.75rem; color: rgba(255,255,255,0.65); margin-top: 0.25rem; }
|
||||
|
||||
.main-content { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.top-bar { position: sticky; top: 0; z: 20; background: white; border-bottom: 1px solid #E6E1ED; padding: 0.75rem 1rem; }
|
||||
.top-header { display: flex; align-items: center; justify-content: space-between; gap: 0.75rem; margin-bottom: 0.75rem; }
|
||||
.mobile-menu-btn { display: block; }
|
||||
.desktop-menu-btn { display: none; }
|
||||
.page-title { font-size: 1.125rem; font-weight: 700; font-family: 'Manrope', sans-serif; }
|
||||
.saved-badge { font-size: 0.6875rem; color: #059669; display: flex; align-items: center; gap: 0.25rem; }
|
||||
.action-bar { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
|
||||
.search-box { position: relative; flex: 1; min-width: 160px; }
|
||||
.search-input { width: 100%; font-size: 0.875rem; padding: 0.5rem 0.75rem; padding-left: 2rem; border: 1px solid #E6E1ED; border-radius: 0.5rem; }
|
||||
.search-icon { position: absolute; left: 0.625rem; top: 0.625rem; width: 14px; height: 14px; color: #6B6478; }
|
||||
.filter-select { font-size: 0.75rem; padding: 0.5rem 0.625rem; border: 1px solid #E6E1ED; border-radius: 0.5rem; background: white; max-width: 160px; }
|
||||
.action-btn { display: flex; align-items: center; gap: 0.375rem; font-size: 0.75rem; font-weight: 500; padding: 0.5rem 0.75rem; border-radius: 0.5rem; border: 1px solid #E6E1ED; }
|
||||
.primary-btn { background: #4B1D6B; color: white; border: none; font-weight: 600; }
|
||||
.reset-btn { font-size: 0.75rem; color: #4B1D6B; padding: 0.5rem 0.75rem; }
|
||||
|
||||
.content-area { padding: 1rem; flex: 1; }
|
||||
.kpi-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; margin-bottom: 1.25rem; }
|
||||
.kpi-card { background: white; border: 1px solid #E6E1ED; border-radius: 1rem; padding: 1rem; }
|
||||
.kpi-value { font-size: 1.5rem; font-weight: 800; color: #4B1D6B; font-family: 'Manrope', sans-serif; }
|
||||
.kpi-label { font-size: 0.6875rem; color: #6B6478; margin-top: 0.125rem; }
|
||||
|
||||
/* Desktop Table */
|
||||
.table-container { background: white; border: 1px solid #E6E1ED; border-radius: 1rem; overflow: hidden; }
|
||||
.table-scroll { overflow: auto; max-height: 62vh; }
|
||||
.data-table { width: 100%; font-size: 0.875rem; }
|
||||
.data-table thead { position: sticky; top: 0; z: 10; background: #EFE7F6; }
|
||||
.data-table th { padding: 0.625rem 0.75rem; text-align: left; font-weight: 600; font-size: 0.6875rem; color: #4B1D6B; white-space: nowrap; cursor: pointer; user-select: none; }
|
||||
.data-table td { padding: 0.5rem 0.75rem; border-top: 1px solid #E6E1ED; white-space: nowrap; font-size: 0.75rem; }
|
||||
.data-table tr:hover { background: #F8F7FA; }
|
||||
.data-table tr.overdue { background: #FDF1F2; }
|
||||
.badge { display: inline-flex; align-items: center; padding: 0.125rem 0.5rem; border-radius: 9999px; font-size: 0.6875rem; font-weight: 500; border: 1px solid; }
|
||||
.badge-green { background: #ECFDF5; color: #047857; border-color: #A7F3D0; }
|
||||
.badge-yellow { background: #FFFBEB; color: #B45309; border-color: #FDE68A; }
|
||||
.badge-blue { background: #EFF6FF; color: #1D4ED8; border-color: #BFDBFE; }
|
||||
.badge-red { background: #FEF2F2; color: #B91C1C; border-color: #FECACA; }
|
||||
.badge-gray { background: #F1F5F9; color: #475569; border-color: #E2E8F0; }
|
||||
.action-cell { white-space: nowrap !important; }
|
||||
.edit-btn, .delete-btn { padding: 0.375rem; border-radius: 0.5rem; margin-right: 0.25rem; }
|
||||
.edit-btn:hover { background: #F1F5F9; }
|
||||
.delete-btn:hover { background: #FEF2F2; }
|
||||
|
||||
/* Mobile Cards */
|
||||
.mobile-cards { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||
.mobile-card { background: white; border: 1px solid #E6E1ED; border-radius: 1rem; padding: 1rem; text-align: left; }
|
||||
.mobile-card.overdue { border-color: #F3B4BC; }
|
||||
.mobile-card-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 0.25rem; }
|
||||
.mobile-card-title { font-size: 0.875rem; font-weight: 600; }
|
||||
.mobile-card-meta { font-size: 0.6875rem; color: #6B6478; margin-bottom: 0.5rem; }
|
||||
.mobile-card-badges { display: flex; flex-wrap: wrap; gap: 0.375rem; margin-bottom: 0.5rem; }
|
||||
.mobile-card-badge { font-size: 0.625rem; padding: 0.125rem 0.5rem; border-radius: 9999px; border: 1px solid #E6E1ED; color: #6B6478; }
|
||||
.mobile-card-arpu { font-size: 0.6875rem; font-weight: 500; color: #4B1D6B; margin-top: 0.5rem; }
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay { position: fixed; inset: 0; z: 50; display: flex; align-items: center; justify-content: center; padding: 1rem; }
|
||||
.modal-overlay::before { content: ''; position: absolute; inset: 0; background: rgba(0,0,0,0.4); }
|
||||
.modal-content { position: relative; background: white; border-radius: 1rem; width: 100%; max-width: 42rem; max-height: 88vh; overflow: auto; padding: 1.5rem; }
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
|
||||
.modal-title { font-size: 1.125rem; font-weight: 700; font-family: 'Manrope', sans-serif; }
|
||||
.close-btn { padding: 0.25rem; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; }
|
||||
.form-field { display: flex; flex-direction: column; }
|
||||
.form-field.full { grid-column: span 2; }
|
||||
.form-label { font-size: 0.6875rem; font-weight: 500; color: #6B6478; margin-bottom: 0.25rem; }
|
||||
.form-input, .form-select { font-size: 0.875rem; border: 1px solid #E6E1ED; border-radius: 0.5rem; padding: 0.5rem 0.625rem; }
|
||||
.form-input:disabled, .form-select:disabled { background: #F8FAFC; color: #94A3B8; }
|
||||
.modal-actions { display: flex; gap: 0.5rem; margin-top: 1.5rem; }
|
||||
.modal-actions button { flex: 1; padding: 0.625rem; border-radius: 0.75rem; font-size: 0.875rem; font-weight: 500; }
|
||||
.modal-actions .cancel-btn { border: 1px solid #E6E1ED; color: #241934; }
|
||||
.modal-actions .save-btn { background: #4B1D6B; color: white; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 0.375rem; }
|
||||
|
||||
/* Confirm Dialog */
|
||||
.confirm-dialog { max-width: 24rem; text-align: center; }
|
||||
.confirm-icon { margin: 0 auto 0.75rem; }
|
||||
.confirm-text { font-size: 0.875rem; margin-bottom: 1.25rem; }
|
||||
.confirm-actions { display: flex; gap: 0.5rem; }
|
||||
.confirm-actions button { flex: 1; padding: 0.625rem; border-radius: 0.75rem; font-size: 0.875rem; font-weight: 500; }
|
||||
.confirm-actions .cancel-btn { border: 1px solid #E6E1ED; }
|
||||
.confirm-actions .confirm-btn { background: #DC2626; color: white; font-weight: 600; }
|
||||
|
||||
/* Mobile Detail */
|
||||
.mobile-detail { width: 100%; max-width: 28rem; max-height: 85vh; overflow: auto; }
|
||||
.mobile-detail-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
|
||||
.mobile-detail-title { font-size: 1rem; font-weight: 700; font-family: 'Manrope', sans-serif; }
|
||||
.detail-row { display: flex; justify-content: space-between; font-size: 0.75rem; gap: 0.75rem; margin-bottom: 0.625rem; }
|
||||
.detail-label { color: #6B6478; }
|
||||
.detail-value { font-weight: 500; text-align: right; }
|
||||
.mobile-detail-edit { width: 100%; padding: 0.625rem; margin-top: 1.25rem; border-radius: 0.75rem; font-size: 0.875rem; font-weight: 600; color: white; background: #4B1D6B; }
|
||||
|
||||
/* Dashboard */
|
||||
.dashboard-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.75rem; margin-bottom: 1.25rem; }
|
||||
.dash-card { background: white; border: 1px solid #E6E1ED; border-radius: 1rem; padding: 1rem; }
|
||||
.dash-value { font-size: 1.25rem; font-weight: 800; color: #4B1D6B; font-family: 'Manrope', sans-serif; }
|
||||
.dash-label { font-size: 0.6875rem; color: #6B6478; margin-top: 0.125rem; }
|
||||
.chart-container { background: white; border: 1px solid #E6E1ED; border-radius: 1rem; padding: 1rem; margin-bottom: 1rem; }
|
||||
.chart-title { font-size: 0.875rem; font-weight: 600; margin-bottom: 0.75rem; }
|
||||
.chart-wrapper { height: 280px; }
|
||||
.summary-box { background: white; border: 1px solid #E6E1ED; border-radius: 1rem; padding: 1rem; font-size: 0.875rem; display: flex; flex-wrap: wrap; gap: 1.5rem; }
|
||||
.summary-item span:first-child { color: #6B6478; }
|
||||
.summary-item b { color: #4B1D6B; }
|
||||
|
||||
/* Admin info popup */
|
||||
.admin-popup-btn { position: fixed; top: 1.25rem; right: 1.25rem; z: 30; width: 2.75rem; height: 2.75rem; border-radius: 50%; background: white; border: 1px solid #E6E1ED; box-shadow: 0 1px 3px rgba(0,0,0,0.08); display: flex; align-items: center; justify-content: center; }
|
||||
.admin-popup { position: absolute; right: 0; top: 2.875rem; z: 30; width: 16rem; background: white; border-radius: 1rem; border: 1px solid #E6E1ED; box-shadow: 0 4px 12px rgba(0,0,0,0.1); padding: 1rem; }
|
||||
.admin-popup-label { font-size: 0.625rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #6B6478; margin-bottom: 0.375rem; }
|
||||
.admin-popup-name { font-size: 0.875rem; font-weight: 600; margin-bottom: 0.375rem; }
|
||||
.admin-popup-phone { font-size: 0.6875rem; color: #4B1D6B; display: flex; align-items: center; gap: 0.25rem; }
|
||||
|
||||
/* Responsive */
|
||||
@media (min-width: 768px) {
|
||||
.sidebar { display: flex; }
|
||||
.mobile-menu-btn { display: none; }
|
||||
.kpi-grid { grid-template-columns: repeat(5, 1fr); }
|
||||
.dashboard-grid { grid-template-columns: repeat(4, 1fr); }
|
||||
.content-area { padding: 1.5rem; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div id="loginSection">
|
||||
<div id="loginForm" class="login-form" style="max-width:400px">
|
||||
<h1 style="margin-bottom:24px">Вход в систему</h1>
|
||||
<div class="form-group" style="margin-bottom:16px">
|
||||
<label>ФИО</label>
|
||||
<input type="text" id="loginFullName" placeholder="Введите ваше ФИО">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:16px">
|
||||
<label>Пароль</label>
|
||||
<input type="password" id="loginPassword" placeholder="Введите пароль">
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="doLogin()" style="width:100%">Войти</button>
|
||||
<p style="margin-top:24px;text-align:center;color:var(--gray-500)">Нет учётной записи?</p>
|
||||
<button class="btn btn-secondary" onclick="showRegistration()" style="width:100%">Зарегистрироваться</button>
|
||||
</div>
|
||||
|
||||
<div id="registrationForm" class="login-form" style="max-width:500px;display:none">
|
||||
<h1 style="margin-bottom:24px">Регистрация</h1>
|
||||
<div class="form-group" style="margin-bottom:16px">
|
||||
<label>ФИО *</label>
|
||||
<input type="text" id="regFullName" placeholder="Иванов Иван Иванович">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:16px">
|
||||
<label>Номер телефона *</label>
|
||||
<input type="tel" id="regPhone" placeholder="+7 777 123 45 67">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:16px">
|
||||
<label>Филиал *</label>
|
||||
<input type="text" id="regBranch" placeholder="Например: Алматы филиал">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:16px">
|
||||
<label>Отдел *</label>
|
||||
<input type="text" id="regDepartment" placeholder="Например: Отдел продаж">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:16px">
|
||||
<label>Должность *</label>
|
||||
<input type="text" id="regPosition" placeholder="Например: Менеджер">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:16px">
|
||||
<label>Пароль *</label>
|
||||
<input type="password" id="regPassword" placeholder="Придумайте пароль">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:16px">
|
||||
<label>Подтверждение пароля *</label>
|
||||
<input type="password" id="regPasswordConfirm" placeholder="Повторите пароль">
|
||||
</div>
|
||||
<div class="form-group" style="margin-bottom:16px">
|
||||
<label>Запрашиваемая роль *</label>
|
||||
<select id="regRole">
|
||||
<option value="view">Просмотр (только чтение)</option>
|
||||
<option value="editor">Редактор (добавление и редактирование)</option>
|
||||
<option value="curator">Куратор (полный доступ)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="doRegister()" style="width:100%">Зарегистрироваться</button>
|
||||
<button class="btn btn-secondary" onclick="showLogin()" style="width:100%;margin-top:12px">Вернуться ко входу</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="mainSection" style="display:none">
|
||||
<div class="header">
|
||||
<h1>📊 Система учёта клиентов</h1>
|
||||
<div class="user-info" style="flex-wrap:wrap;gap:8px">
|
||||
<div class="avatar" id="userAvatar"></div>
|
||||
<span id="userName"></span>
|
||||
<span class="permission-badge" id="userPermissions"></span>
|
||||
<span class="permission-badge" id="userInfoDetails"></span>
|
||||
<button class="btn btn-secondary" onclick="logout()">Выйти</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs" id="sheetTabs"></div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn btn-primary" id="addRecordBtn" onclick="openModal()">+ Добавить запись</button>
|
||||
<button class="btn btn-success" onclick="exportExcel()">📥 Excel</button>
|
||||
<button class="btn btn-primary" onclick="openStatsModal()">📈 Статистика</button>
|
||||
<button class="btn btn-primary" onclick="openPermissionsModal()">🔐 Доступы</button>
|
||||
<button class="btn btn-primary" onclick="openUsersModal()">👥 Пользователи</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<input type="text" id="searchInput" placeholder="🔍 Поиск..." oninput="renderTable()" style="flex:1;min-width:200px">
|
||||
<select id="filterRegion" onchange="renderTable()">
|
||||
<option value="">Все области</option>
|
||||
</select>
|
||||
<select id="filterStatus" onchange="renderTable()">
|
||||
<option value="">Все статусы</option>
|
||||
<option value="Подключен">Подключен</option>
|
||||
<option value="В работе">В работе</option>
|
||||
<option value="Планирование">Планирование</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>№</th>
|
||||
<th>Сегмент</th>
|
||||
<th>Область</th>
|
||||
<th>Район</th>
|
||||
<th>Нас. пункт</th>
|
||||
<th>Адрес</th>
|
||||
<th>Клиент</th>
|
||||
<th>Деятельность</th>
|
||||
<th>Тех. возможность</th>
|
||||
<th>Статус строительства</th>
|
||||
<th>Начало</th>
|
||||
<th>Завершение</th>
|
||||
<th>Примечание</th>
|
||||
<th>№ устр-ва</th>
|
||||
<th>Статус</th>
|
||||
<th>Дата подключ.</th>
|
||||
<th>Тариф</th>
|
||||
<th>ARPU</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tableBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="recordModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2 style="margin-bottom:20px" id="modalTitle">Новая запись</h2>
|
||||
<div class="form-grid">
|
||||
<div class="form-group"><label>№</label><input type="text" id="recNum"></div>
|
||||
<div class="form-group"><label>Сегмент</label><input type="text" id="recSegment"></div>
|
||||
<div class="form-group"><label>Область</label><input type="text" id="recRegion"></div>
|
||||
<div class="form-group"><label>Район</label><input type="text" id="recDistrict"></div>
|
||||
<div class="form-group"><label>Населенный пункт</label><input type="text" id="recSettlement"></div>
|
||||
<div class="form-group"><label>Адрес</label><input type="text" id="recAddress"></div>
|
||||
<div class="form-group"><label>Наименование клиента</label><input type="text" id="recClientName"></div>
|
||||
<div class="form-group"><label>Вид деятельности</label><input type="text" id="recActivity"></div>
|
||||
<div class="form-group"><label>Тех. возможность</label><select id="recTechPossibility"><option value="">Не выбрано</option><option value="Есть">Есть</option><option value="Нет">Нет</option><option value="Требует уточнения">Требует уточнения</option></select></div>
|
||||
<div class="form-group"><label>Статус строительства</label><select id="recConstructionStatus"><option value="">Не выбрано</option><option value="Не начинается">Не начинается</option><option value="В работе">В работе</option><option value="Завершено">Завершено</option></select></div>
|
||||
<div class="form-group"><label>Начало строительства</label><input type="date" id="recConstructionStart"></div>
|
||||
<div class="form-group"><label>Сроки завершения</label><input type="date" id="recConstructionEnd"></div>
|
||||
<div class="form-group" style="grid-column:1/-1"><label>Примечание</label><textarea id="recNote"></textarea></div>
|
||||
<div class="form-group"><label>№ устройства</label><input type="text" id="recDeviceNum"></div>
|
||||
<div class="form-group"><label>Статус подключения</label><select id="recConnectionStatus"><option value="">Не выбрано</option><option value="Подключен">Подключен</option><option value="В работе">В работе</option><option value="Планирование">Планирование</option><option value="Отказ">Отказ</option></select></div>
|
||||
<div class="form-group"><label>Дата подключения</label><input type="date" id="recConnectionDate"></div>
|
||||
<div class="form-group"><label>Тарифный план</label><input type="text" id="recTariff"></div>
|
||||
<div class="form-group"><label>ARPU (тыс. тг)</label><input type="number" step="0.01" id="recArpu"></div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-primary" onclick="saveRecord()">Сохранить</button>
|
||||
<button class="btn btn-secondary" onclick="closeModal()">Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="statsModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2 style="margin-bottom:20px">📈 Статистика</h2>
|
||||
<div class="filter-bar">
|
||||
<div class="form-group"><label>С даты</label><input type="date" id="statsStartDate"></div>
|
||||
<div class="form-group"><label>По дату</label><input type="date" id="statsEndDate"></div>
|
||||
<div class="form-group"><label>Группировать по</label>
|
||||
<select id="statsGroupBy">
|
||||
<option value="region">Область</option>
|
||||
<option value="segment">Сегмент</option>
|
||||
<option value="connectionStatus">Статус подключения</option>
|
||||
<option value="constructionStatus">Статус строительства</option>
|
||||
<option value="activity">Вид деятельности</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="loadStats()">Показать</button>
|
||||
</div>
|
||||
<div class="stats-grid" id="statsGrid"></div>
|
||||
<div class="actions">
|
||||
<button class="btn btn-secondary" onclick="document.getElementById('statsModal').classList.remove('open')">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="permissionsModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2 style="margin-bottom:20px">🔐 Настройка доступов</h2>
|
||||
<div class="form-grid" style="margin-bottom:16px">
|
||||
<div class="form-group"><label>Пользователь</label><select id="permUser"></select></div>
|
||||
<div class="form-group"><label>Лист</label><select id="permSheet"><option value="all">Все листы</option></select></div>
|
||||
<div class="form-group"><label>Права</label>
|
||||
<select id="permLevel">
|
||||
<option value="view">Только просмотр</option>
|
||||
<option value="edit">Редактирование</option>
|
||||
<option value="full">Полный доступ</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" style="margin-bottom:16px">
|
||||
<button class="btn btn-primary" onclick="addPermission()">Добавить доступ</button>
|
||||
</div>
|
||||
<div id="permissionsList"></div>
|
||||
<div class="actions" style="margin-top:16px">
|
||||
<button class="btn btn-secondary" onclick="document.getElementById('permissionsModal').classList.remove('open')">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="usersModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2 style="margin-bottom:20px">👥 Пользователи</h2>
|
||||
<div class="form-grid" style="margin-bottom:16px">
|
||||
<div class="form-group"><label>Имя</label><input type="text" id="newUserName"></div>
|
||||
<div class="form-group"><label>Роль</label>
|
||||
<select id="newUserRole">
|
||||
<option value="user">Пользователь</option>
|
||||
<option value="admin">Администратор</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions" style="margin-bottom:16px">
|
||||
<button class="btn btn-primary" onclick="addUser()">Добавить</button>
|
||||
</div>
|
||||
<div id="usersList"></div>
|
||||
<div class="actions" style="margin-top:16px">
|
||||
<button class="btn btn-secondary" onclick="document.getElementById('usersModal').classList.remove('open')">Закрыть</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentUser = null;
|
||||
let allRecords = [];
|
||||
let currentSheet = "all";
|
||||
let editingId = null;
|
||||
|
||||
const FIELDS = ["num","segment","region","district","settlement","address","clientName","activity","techPossibility","constructionStatus","constructionStart","constructionEnd","note","deviceNum","connectionStatus","connectionDate","tariff","arpu"];
|
||||
|
||||
function showLogin() {
|
||||
document.getElementById("loginForm").style.display = "block";
|
||||
document.getElementById("registrationForm").style.display = "none";
|
||||
}
|
||||
|
||||
function showRegistration() {
|
||||
document.getElementById("loginForm").style.display = "none";
|
||||
document.getElementById("registrationForm").style.display = "block";
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
const fullName = document.getElementById("loginFullName").value.trim();
|
||||
const password = document.getElementById("loginPassword").value;
|
||||
|
||||
if (!fullName) return alert("Введите ФИО");
|
||||
if (!password) return alert("Введите пароль");
|
||||
|
||||
const res = await fetch("/api/users");
|
||||
const data = await res.json();
|
||||
let user = data.users.find(u => u.fullName === fullName);
|
||||
|
||||
if (!user) {
|
||||
const recover = confirm(`Пользователь "${fullName}" не найден.\n\nВосстановить доступ через Telegram?`);
|
||||
if (recover) {
|
||||
window.open("https://t.me/vibe42_support", "_blank");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.password !== password) {
|
||||
const recover = confirm("Неверный пароль.\n\nВосстановить доступ через Telegram?");
|
||||
if (recover) {
|
||||
window.open("https://t.me/vibe42_support", "_blank");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
currentUser = user;
|
||||
document.getElementById("loginSection").style.display = "none";
|
||||
document.getElementById("mainSection").style.display = "block";
|
||||
document.getElementById("userAvatar").textContent = fullName[0].toUpperCase();
|
||||
document.getElementById("userName").textContent = fullName;
|
||||
const roleText = user.approvedRole === "curator" ? "Куратор" : user.approvedRole === "editor" ? "Редактор" : "Просмотр";
|
||||
const requestedText = user.requestedRole !== user.approvedRole ? ` (запрошено: ${user.requestedRole === 'curator' ? 'Куратор' : user.requestedRole === 'editor' ? 'Редактор' : 'Просмотр'})` : '';
|
||||
document.getElementById("userPermissions").textContent = `${roleText}${requestedText}`;
|
||||
document.getElementById("userInfoDetails").textContent = `${user.branch || '-'} | ${user.department || '-'}`;
|
||||
|
||||
initApp();
|
||||
}
|
||||
|
||||
async function doRegister() {
|
||||
const fullName = document.getElementById("regFullName").value.trim();
|
||||
const phone = document.getElementById("regPhone").value.trim();
|
||||
const branch = document.getElementById("regBranch").value.trim();
|
||||
const department = document.getElementById("regDepartment").value.trim();
|
||||
const position = document.getElementById("regPosition").value.trim();
|
||||
const password = document.getElementById("regPassword").value;
|
||||
const passwordConfirm = document.getElementById("regPasswordConfirm").value;
|
||||
const requestedRole = document.getElementById("regRole").value;
|
||||
|
||||
if (!fullName) return alert("Введите ФИО");
|
||||
if (!phone) return alert("Введите номер телефона");
|
||||
if (!branch) return alert("Введите филиал");
|
||||
if (!department) return alert("Введите отдел");
|
||||
if (!position) return alert("Введите должность");
|
||||
if (!password) return alert("Введите пароль");
|
||||
if (password !== passwordConfirm) return alert("Пароли не совпадают");
|
||||
|
||||
const res = await fetch("/api/users");
|
||||
const data = await res.json();
|
||||
let user = data.users.find(u => u.fullName === fullName);
|
||||
|
||||
if (user) {
|
||||
alert("Пользователь с таким ФИО уже существует. Войдите.");
|
||||
showLogin();
|
||||
return;
|
||||
}
|
||||
|
||||
user = {
|
||||
fullName: fullName,
|
||||
phone: phone,
|
||||
branch: branch,
|
||||
department: department,
|
||||
position: position,
|
||||
password: password,
|
||||
requestedRole: requestedRole,
|
||||
role: "user",
|
||||
approvedRole: "view"
|
||||
};
|
||||
const result = await fetch("/api/users", { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify(user) });
|
||||
const userData = await result.json();
|
||||
user.id = userData.ok ? Date.now() : user.id;
|
||||
|
||||
const allPerms = await fetch("/api/permissions").then(r => r.json());
|
||||
const hasDefaultPerm = allPerms.permissions.some(p => p.userName === fullName);
|
||||
if (!hasDefaultPerm) {
|
||||
await fetch("/api/permissions", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type":"application/json"},
|
||||
body: JSON.stringify({ userId: fullName, userName: fullName, sheetName: "", level: "view" })
|
||||
});
|
||||
}
|
||||
|
||||
alert("✅ Регистрация успешна!\n\nТеперь вы можете войти в систему.");
|
||||
showLogin();
|
||||
}
|
||||
|
||||
function logout() {
|
||||
currentUser = null;
|
||||
document.getElementById("loginSection").style.display = "block";
|
||||
document.getElementById("mainSection").style.display = "none";
|
||||
}
|
||||
|
||||
async function initApp() {
|
||||
await loadRecords();
|
||||
await loadUserPermissions();
|
||||
loadTabs();
|
||||
renderTable();
|
||||
updateUIByPermission();
|
||||
}
|
||||
|
||||
function updateUIByPermission() {
|
||||
const canEdit = currentUser && (currentUser.role === "admin" || currentUser.approvedRole === "editor" || currentUser.approvedRole === "curator" || canEditRecord({}));
|
||||
const addBtn = document.getElementById("addRecordBtn");
|
||||
if (addBtn) {
|
||||
addBtn.style.display = canEdit ? "inline-block" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserPermissions() {
|
||||
if (!currentUser) return;
|
||||
const res = await fetch("/api/permissions");
|
||||
const data = await res.json();
|
||||
window.userPermissions = data.permissions.filter(p => p.userName === currentUser.fullName || p.userId === currentUser.fullName);
|
||||
}
|
||||
|
||||
async function loadRecords() {
|
||||
const res = await fetch("/api/records");
|
||||
const data = await res.json();
|
||||
allRecords = data.records || [];
|
||||
|
||||
const regions = [...new Set(allRecords.map(r => r.region).filter(Boolean))];
|
||||
const filterRegion = document.getElementById("filterRegion");
|
||||
filterRegion.innerHTML = '<option value="">Все области</option>' + regions.map(r => `<option value="${r}">${r}</option>`).join("");
|
||||
|
||||
const sheets = ["all", "Алматы", "Астана", "Шымкент", "Алматинская", "Жамбылская", "Кызылординская", "Мангистауская", "Павлодарская", "Восточно-Казахстанская", "Северо-Казахстанская", "Костанайская", "Акмолинская", "Актюбинская", "Западно-Казахстанская", "Атырауская", "Карагандинская", "Туркестанская", "ДПБО", "ДПКК"];
|
||||
currentSheet = currentSheet || "all";
|
||||
|
||||
const tabsContainer = document.getElementById("sheetTabs");
|
||||
tabsContainer.innerHTML = sheets.map(s => `<div class="tab ${s===currentSheet?'active':''}" onclick="setSheet('${s}')">${s==="all"?"Все":s}</div>`).join("");
|
||||
}
|
||||
|
||||
function setSheet(sheet) {
|
||||
currentSheet = sheet;
|
||||
loadTabs();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function loadTabs() {
|
||||
const sheets = ["all", "Алматы", "Астана", "Шымкент", "Алматинская", "Жамбылская", "Кызылординская", "Мангистауская", "Павлодарская", "Восточно-Казахстанская", "Северо-Казахстанская", "Костанайская", "Акмолинская", "Актюбинская", "Западно-Казахстанская", "Атырауская", "Карагандинская", "Туркестанская", "ДПБО", "ДПКК"];
|
||||
const tabsContainer = document.getElementById("sheetTabs");
|
||||
tabsContainer.innerHTML = sheets.map(s => `<div class="tab ${s===currentSheet?'active':''}" onclick="setSheet('${s}')">${s==="all"?"Все":s}</div>`).join("");
|
||||
}
|
||||
|
||||
function getFilteredRecords() {
|
||||
let records = allRecords;
|
||||
|
||||
if (currentSheet !== "all") {
|
||||
if (["ДПБО","ДПКК"].includes(currentSheet)) {
|
||||
records = records.filter(r => r.segment === currentSheet);
|
||||
} else {
|
||||
records = records.filter(r => r.region === currentSheet);
|
||||
}
|
||||
}
|
||||
|
||||
const search = document.getElementById("searchInput").value.toLowerCase();
|
||||
if (search) {
|
||||
records = records.filter(r =>
|
||||
(r.clientName||"").toLowerCase().includes(search) ||
|
||||
(r.address||"").toLowerCase().includes(search) ||
|
||||
(r.num||"").toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
const region = document.getElementById("filterRegion").value;
|
||||
if (region) records = records.filter(r => r.region === region);
|
||||
|
||||
const status = document.getElementById("filterStatus").value;
|
||||
if (status) records = records.filter(r => r.connectionStatus === status);
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
function renderTable() {
|
||||
const records = getFilteredRecords();
|
||||
const tbody = document.getElementById("tableBody");
|
||||
|
||||
if (records.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="19" style="text-align:center;padding:40px">Нет записей</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = records.map(r => {
|
||||
const canEdit = currentUser && (currentUser.role === "admin" || canEditRecord(r));
|
||||
return '<tr>' + [
|
||||
r.num||"", r.segment||"", r.region||"", r.district||"", r.settlement||"", r.address||"",
|
||||
r.clientName||"", r.activity||"", r.techPossibility||"", r.constructionStatus||"",
|
||||
formatDate(r.constructionStart), formatDate(r.constructionEnd), r.note||"",
|
||||
r.deviceNum||"", r.connectionStatus||"", formatDate(r.connectionDate), r.tariff||"", r.arpu||""
|
||||
].map(v => `<td>${v||""}</td>`).join("") +
|
||||
`<td>${canEdit ? `<span class="edit-cell" onclick="editRecord(${r.id})">✏️</span>` : ""}</td>` +
|
||||
'</tr>';
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return "";
|
||||
const date = new Date(d);
|
||||
return isNaN(date.getTime()) ? d : date.toLocaleDateString("ru-RU");
|
||||
}
|
||||
|
||||
function canEditRecord(record) {
|
||||
if (currentUser && currentUser.role === "admin") return true;
|
||||
|
||||
const perms = window.userPermissions || [];
|
||||
const perm = perms.find(p => p.userName === currentUser.fullName && (p.sheetName === "" || p.sheetName === currentSheet || p.sheetName === "all"));
|
||||
|
||||
return perm && (perm.level === "editor" || perm.level === "curator");
|
||||
}
|
||||
|
||||
function openModal(id = null) {
|
||||
editingId = id;
|
||||
const modal = document.getElementById("recordModal");
|
||||
const title = document.getElementById("modalTitle");
|
||||
|
||||
FIELDS.forEach(f => document.getElementById("rec" + f.charAt(0).toUpperCase() + f.slice(1)).value = "");
|
||||
|
||||
if (id) {
|
||||
const record = allRecords.find(r => r.id === id);
|
||||
if (record) {
|
||||
title.textContent = "Редактирование записи";
|
||||
FIELDS.forEach(f => {
|
||||
const el = document.getElementById("rec" + f.charAt(0).toUpperCase() + f.slice(1));
|
||||
if (el) el.value = record[f] || "";
|
||||
});
|
||||
}
|
||||
} else {
|
||||
title.textContent = "Новая запись";
|
||||
}
|
||||
|
||||
modal.classList.add("open");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById("recordModal").classList.remove("open");
|
||||
editingId = null;
|
||||
}
|
||||
|
||||
async function saveRecord() {
|
||||
const record = {};
|
||||
FIELDS.forEach(f => {
|
||||
const el = document.getElementById("rec" + f.charAt(0).toUpperCase() + f.slice(1));
|
||||
if (el) record[f] = el.value;
|
||||
});
|
||||
|
||||
const canEdit = currentUser && (currentUser.role === "admin" || canEditRecord({}));
|
||||
if (!canEdit && !editingId) {
|
||||
return alert("У вас нет прав на добавление записей. Обратитесь к администратору.");
|
||||
}
|
||||
if (!canEdit && editingId) {
|
||||
return alert("У вас нет прав на редактирование. Обратитесь к администратору.");
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
record.id = editingId;
|
||||
await fetch("/api/records", { method: "PUT", headers: {"Content-Type":"application/json"}, body: JSON.stringify(record) });
|
||||
} else {
|
||||
await fetch("/api/records", { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify(record) });
|
||||
}
|
||||
|
||||
closeModal();
|
||||
await loadRecords();
|
||||
await loadUserPermissions();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
async function editRecord(id) {
|
||||
openModal(id);
|
||||
}
|
||||
|
||||
async function exportExcel() {
|
||||
window.open("/api/export/excel", "_blank");
|
||||
}
|
||||
|
||||
async function openStatsModal() {
|
||||
document.getElementById("statsModal").classList.add("open");
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
const startDate = document.getElementById("statsStartDate").value;
|
||||
const endDate = document.getElementById("statsEndDate").value;
|
||||
const groupBy = document.getElementById("statsGroupBy").value;
|
||||
|
||||
const res = await fetch("/api/stats", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type":"application/json"},
|
||||
body: JSON.stringify({ startDate, endDate, groupBy })
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
const grid = document.getElementById("statsGrid");
|
||||
grid.innerHTML = Object.entries(data.stats).map(([key, count]) =>
|
||||
`<div class="stat-card"><h3>${key}</h3><div class="value">${count}</div></div>`
|
||||
).join("");
|
||||
}
|
||||
|
||||
async function openPermissionsModal() {
|
||||
document.getElementById("permissionsModal").classList.add("open");
|
||||
await loadPermissions();
|
||||
await loadUsersForPermissions();
|
||||
await loadSheetsForPermissions();
|
||||
}
|
||||
|
||||
async function loadUsersForPermissions() {
|
||||
const res = await fetch("/api/users");
|
||||
const data = await res.json();
|
||||
const select = document.getElementById("permUser");
|
||||
select.innerHTML = data.users.map(u => `<option value="${u.id || u.fullName}">${u.fullName || u.name} (${u.branch || '-'})</option>`).join("");
|
||||
}
|
||||
|
||||
async function loadSheetsForPermissions() {
|
||||
const sheets = ["all", "Алматы", "Астана", "Шымкент", "Алматинская", "Жамбылская", "Кызылординская", "Мангистауская", "Павлодарская", "Восточно-Казахстанская", "Северо-Казахстанская", "Костанайская", "Акмолинская", "Актюбинская", "Западно-Казахстанская", "Атырауская", "Карагандинская", "Туркестанская", "ДПБО", "ДПКК"];
|
||||
const select = document.getElementById("permSheet");
|
||||
select.innerHTML = sheets.map(s => `<option value="${s}">${s==="all"?"Все листы":s}</option>`).join("");
|
||||
}
|
||||
|
||||
async function loadPermissions() {
|
||||
const res = await fetch("/api/permissions");
|
||||
const data = await res.json();
|
||||
const usersRes = await fetch("/api/users");
|
||||
const usersData = await usersRes.json();
|
||||
const list = document.getElementById("permissionsList");
|
||||
list.innerHTML = data.permissions.map(p => {
|
||||
const user = usersData.users.find(u => u.fullName === p.userName);
|
||||
return `<div style="padding:12px;background:var(--gray-100);border-radius:8px;margin-bottom:8px;display:flex;justify-content:space-between;align-items:center">
|
||||
<span><b>${p.userName}</b> ${user ? `(${user.branch || '-'})` : ''} → ${p.sheetName || "Все листы"} (<b>${p.level === 'curator' ? 'Куратор' : p.level === 'editor' ? 'Редактор' : 'Просмотр'}</b>)</span>
|
||||
<button class="btn btn-danger" onclick="deletePermission(${p.id})">Удалить</button>
|
||||
</div>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
async function addPermission() {
|
||||
const userId = document.getElementById("permUser").value;
|
||||
const sheet = document.getElementById("permSheet").value;
|
||||
const level = document.getElementById("permLevel").value;
|
||||
|
||||
const usersRes = await fetch("/api/users");
|
||||
const usersData = await usersRes.json();
|
||||
const user = usersData.users.find(u => (u.id || u.fullName) === userId);
|
||||
const userName = user ? user.fullName : userId;
|
||||
|
||||
await fetch("/api/permissions", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type":"application/json"},
|
||||
body: JSON.stringify({ userId, userName, sheetName: sheet === "all" ? "" : sheet, level })
|
||||
});
|
||||
|
||||
loadPermissions();
|
||||
}
|
||||
|
||||
async function deletePermission(id) {
|
||||
await fetch("/api/permissions", { method: "DELETE", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ id }) });
|
||||
loadPermissions();
|
||||
}
|
||||
|
||||
async function openUsersModal() {
|
||||
document.getElementById("usersModal").classList.add("open");
|
||||
await loadUsersList();
|
||||
}
|
||||
|
||||
async function loadUsersList() {
|
||||
const res = await fetch("/api/users");
|
||||
const data = await res.json();
|
||||
const list = document.getElementById("usersList");
|
||||
list.innerHTML = data.users.map(u =>
|
||||
`<div style="padding:12px;background:var(--gray-100);border-radius:8px;margin-bottom:8px;display:flex;flex-direction:column;gap:8px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span><b>${u.fullName || u.name}</b> (${u.branch || ""} | ${u.department || ""})</span>
|
||||
<span class="permission-badge">${u.approvedRole === 'curator' ? 'Куратор' : u.approvedRole === 'editor' ? 'Редактор' : 'Просмотр'}${u.requestedRole !== u.approvedRole ? ' (запрос: ' + (u.requestedRole === 'curator' ? 'Куратор' : u.requestedRole === 'editor' ? 'Редактор' : 'Просмотр') + ')' : ''}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<span style="font-size:12px;color:var(--gray-500)">Тел: ${u.phone || '-'}</span>
|
||||
<span style="font-size:12px;color:var(--gray-500)">Должность: ${u.position || '-'}</span>
|
||||
<select onchange="updateUserRole(${u.id || u.fullName}, this.value)" style="padding:6px;border-radius:6px;border:1px solid var(--gray-100)">
|
||||
<option value="view" ${u.approvedRole === 'view' ? 'selected' : ''}>Просмотр</option>
|
||||
<option value="editor" ${u.approvedRole === 'editor' ? 'selected' : ''}>Редактор</option>
|
||||
<option value="curator" ${u.approvedRole === 'curator' ? 'selected' : ''}>Куратор</option>
|
||||
</select>
|
||||
<button class="btn btn-danger" onclick="deleteUser(${u.id || '${u.fullName}'})">Удалить</button>
|
||||
</div>
|
||||
</div>`
|
||||
).join("");
|
||||
}
|
||||
|
||||
async function updateUserRole(userId, newRole) {
|
||||
const res = await fetch("/api/users");
|
||||
const data = await res.json();
|
||||
const user = data.users.find(u => (u.id || u.fullName) === userId || u.id === userId);
|
||||
if (user) {
|
||||
user.approvedRole = newRole;
|
||||
await fetch("/api/users", { method: "PUT", headers: {"Content-Type":"application/json"}, body: JSON.stringify(user) });
|
||||
|
||||
const permsRes = await fetch("/api/permissions");
|
||||
const permsData = await permsRes.json();
|
||||
const existingPerm = permsData.permissions.find(p => p.userName === user.fullName);
|
||||
if (existingPerm) {
|
||||
await fetch("/api/permissions", { method: "DELETE", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ id: existingPerm.id }) });
|
||||
}
|
||||
await fetch("/api/permissions", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type":"application/json"},
|
||||
body: JSON.stringify({ userId: user.fullName, userName: user.fullName, sheetName: "", level: newRole })
|
||||
});
|
||||
}
|
||||
loadUsersList();
|
||||
}
|
||||
|
||||
async function addUser() {
|
||||
const name = document.getElementById("newUserName").value;
|
||||
const role = document.getElementById("newUserRole").value;
|
||||
|
||||
if (!name) return alert("Введите имя");
|
||||
|
||||
await fetch("/api/users", {
|
||||
method: "POST",
|
||||
headers: {"Content-Type":"application/json"},
|
||||
body: JSON.stringify({ name, role })
|
||||
});
|
||||
|
||||
document.getElementById("newUserName").value = "";
|
||||
loadUsersList();
|
||||
}
|
||||
|
||||
async function deleteUser(id) {
|
||||
await fetch("/api/users", { method: "DELETE", headers: {"Content-Type":"application/json"}, body: JSON.stringify({ id }) });
|
||||
loadUsersList();
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", e => {
|
||||
if (e.key === "Escape") {
|
||||
document.querySelectorAll(".modal.open").forEach(m => m.classList.remove("open"));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
<div id="app"></div>
|
||||
<script src="app.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
343
package-lock.json
generated
343
package-lock.json
generated
@ -1,343 +0,0 @@
|
||||
{
|
||||
"name": "client-tracker",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "client-tracker",
|
||||
"dependencies": {
|
||||
"formidable": "^3.5.1",
|
||||
"pptxgenjs": "^3.12.0",
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^14.21.3 || >=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@paralleldrive/cuid2": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz",
|
||||
"integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "18.19.130",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
|
||||
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/asap": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz",
|
||||
"integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/dezalgo": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz",
|
||||
"integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"asap": "^2.0.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/formidable": {
|
||||
"version": "3.5.4",
|
||||
"resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz",
|
||||
"integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"dezalgo": "^1.0.4",
|
||||
"once": "^1.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://ko-fi.com/tunnckoCore/commissions"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/https": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/https/-/https-1.0.0.tgz",
|
||||
"integrity": "sha512-4EC57ddXrkaF0x83Oj8sM6SLQHAWXw90Skqu2M4AEWENZ3F02dFJE/GARA8igO79tcgYqGrD7ae4f5L3um2lgg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/image-size": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/image-size/-/image-size-1.2.1.tgz",
|
||||
"integrity": "sha512-rH+46sQJ2dlwfjfhCyNx5thzrv+dtmBIhPHk0zgRUukHzZ/kRueTJXoYYsclBaKcSMBWuGbOFXtioLpzTb5euw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"queue": "6.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"image-size": "bin/image-size.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.x"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/pptxgenjs": {
|
||||
"version": "3.12.0",
|
||||
"resolved": "https://registry.npmjs.org/pptxgenjs/-/pptxgenjs-3.12.0.tgz",
|
||||
"integrity": "sha512-ZozkYKWb1MoPR4ucw3/aFYlHkVIJxo9czikEclcUVnS4Iw/M+r+TEwdlB3fyAWO9JY1USxJDt0Y0/r15IR/RUA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "^18.7.3",
|
||||
"https": "^1.0.0",
|
||||
"image-size": "^1.0.0",
|
||||
"jszip": "^3.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/queue": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/queue/-/queue-6.0.2.tgz",
|
||||
"integrity": "sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "~2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,13 +1,11 @@
|
||||
{
|
||||
"name": "client-tracker",
|
||||
"name": "vols-registry",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node --env-file=.env server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"xlsx": "^0.18.5",
|
||||
"pptxgenjs": "^3.12.0",
|
||||
"formidable": "^3.5.1"
|
||||
"xlsx": "^0.18.5"
|
||||
}
|
||||
}
|
||||
|
||||
267
server.js
267
server.js
@ -1,267 +0,0 @@
|
||||
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");
|
||||
const USERS = path.join(__dirname, "users.json");
|
||||
const PERMISSIONS = path.join(__dirname, "permissions.json");
|
||||
|
||||
function readJSON(file, empty) {
|
||||
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch (_) { return empty; }
|
||||
}
|
||||
function writeJSON(file, data) {
|
||||
fs.writeFileSync(file, JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
function loadRecords() {
|
||||
const data = readJSON(DATA, { records: [], sheets: [] });
|
||||
if (!data.records) data.records = [];
|
||||
if (!data.sheets) data.sheets = [];
|
||||
return data;
|
||||
}
|
||||
function saveRecords(data) {
|
||||
writeJSON(DATA, data);
|
||||
}
|
||||
|
||||
function loadUsers() {
|
||||
return readJSON(USERS, { users: [] });
|
||||
}
|
||||
function saveUsers(data) {
|
||||
writeJSON(USERS, data);
|
||||
}
|
||||
|
||||
function loadPermissions() {
|
||||
return readJSON(PERMISSIONS, { permissions: [] });
|
||||
}
|
||||
function savePermissions(data) {
|
||||
writeJSON(PERMISSIONS, data);
|
||||
}
|
||||
|
||||
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({}); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const mime = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".css": "text/css; charset=utf-8",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".svg": "image/svg+xml",
|
||||
};
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
const url = req.url.split("?")[0];
|
||||
const method = req.method;
|
||||
|
||||
// API: Records
|
||||
if (method === "GET" && url === "/api/records") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(loadRecords()));
|
||||
}
|
||||
if (method === "POST" && url === "/api/records") {
|
||||
const rec = await body(req);
|
||||
const data = loadRecords();
|
||||
data.records.push({ id: Date.now(), ...rec });
|
||||
saveRecords(data);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ ok: true }));
|
||||
}
|
||||
if (method === "PUT" && url === "/api/records") {
|
||||
const rec = await body(req);
|
||||
const data = loadRecords();
|
||||
const idx = data.records.findIndex(r => r.id === rec.id);
|
||||
if (idx >= 0) {
|
||||
data.records[idx] = { ...data.records[idx], ...rec };
|
||||
saveRecords(data);
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ ok: true }));
|
||||
}
|
||||
if (method === "DELETE" && url === "/api/records") {
|
||||
const { id } = await body(req);
|
||||
const data = loadRecords();
|
||||
data.records = data.records.filter(r => r.id !== id);
|
||||
saveRecords(data);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ ok: true }));
|
||||
}
|
||||
|
||||
// API: Sheets (области, города, ДПБО, ДПКК)
|
||||
if (method === "GET" && url === "/api/sheets") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(loadRecords()));
|
||||
}
|
||||
if (method === "POST" && url === "/api/sheets") {
|
||||
const sheet = await body(req);
|
||||
const data = loadRecords();
|
||||
data.sheets.push({ id: Date.now(), ...sheet });
|
||||
saveRecords(data);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ ok: true }));
|
||||
}
|
||||
|
||||
// API: Users
|
||||
if (method === "GET" && url === "/api/users") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(loadUsers()));
|
||||
}
|
||||
if (method === "POST" && url === "/api/users") {
|
||||
const user = await body(req);
|
||||
const data = loadUsers();
|
||||
data.users.push({ id: Date.now(), ...user });
|
||||
saveUsers(data);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ ok: true }));
|
||||
}
|
||||
if (method === "PUT" && url === "/api/users") {
|
||||
const user = await body(req);
|
||||
const data = loadUsers();
|
||||
const idx = data.users.findIndex(u => u.id === user.id);
|
||||
if (idx >= 0) {
|
||||
data.users[idx] = { ...data.users[idx], ...user };
|
||||
saveUsers(data);
|
||||
}
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ ok: true }));
|
||||
}
|
||||
if (method === "DELETE" && url === "/api/users") {
|
||||
const { id } = await body(req);
|
||||
const data = loadUsers();
|
||||
data.users = data.users.filter(u => u.id !== id);
|
||||
saveUsers(data);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ ok: true }));
|
||||
}
|
||||
|
||||
// API: Permissions
|
||||
if (method === "GET" && url === "/api/permissions") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify(loadPermissions()));
|
||||
}
|
||||
if (method === "POST" && url === "/api/permissions") {
|
||||
const perm = await body(req);
|
||||
const data = loadPermissions();
|
||||
data.permissions.push({ id: Date.now(), ...perm });
|
||||
savePermissions(data);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ ok: true }));
|
||||
}
|
||||
if (method === "DELETE" && url === "/api/permissions") {
|
||||
const { id } = await body(req);
|
||||
const data = loadPermissions();
|
||||
data.permissions = data.permissions.filter(p => p.id !== id);
|
||||
savePermissions(data);
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ ok: true }));
|
||||
}
|
||||
|
||||
// API: Statistics
|
||||
if (method === "POST" && url === "/api/stats") {
|
||||
const { startDate, endDate, groupBy } = await body(req);
|
||||
const data = loadRecords();
|
||||
const start = startDate ? new Date(startDate) : null;
|
||||
const end = endDate ? new Date(endDate) : null;
|
||||
|
||||
let filtered = data.records.filter(r => {
|
||||
if (!r.date) return true;
|
||||
const d = new Date(r.date);
|
||||
if (start && d < start) return false;
|
||||
if (end && d > end) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const stats = {};
|
||||
filtered.forEach(r => {
|
||||
const key = r[groupBy] || "Не указано";
|
||||
if (!stats[key]) stats[key] = 0;
|
||||
stats[key]++;
|
||||
});
|
||||
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end(JSON.stringify({ stats, total: filtered.length }));
|
||||
}
|
||||
|
||||
// API: Export to Excel
|
||||
if (method === "GET" && url.startsWith("/api/export/excel")) {
|
||||
const XLSX = require("xlsx");
|
||||
const data = loadRecords();
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
const ws = XLSX.utils.json_to_sheet(data.records.map(r => ({
|
||||
"№": r.num || "",
|
||||
"Сегмент": r.segment || "",
|
||||
"Область": r.region || "",
|
||||
"Район": r.district || "",
|
||||
"Населенный пункт": r.settlement || "",
|
||||
"Адрес": r.address || "",
|
||||
"Наименование клиента": r.clientName || "",
|
||||
"Вид деятельности": r.activity || "",
|
||||
"Тех. возможность": r.techPossibility || "",
|
||||
"Статус строительства": r.constructionStatus || "",
|
||||
"Начало строительства": r.constructionStart || "",
|
||||
"Сроки завершения": r.constructionEnd || "",
|
||||
"Примечание": r.note || "",
|
||||
"№ устройства": r.deviceNum || "",
|
||||
"Статус подключения": r.connectionStatus || "",
|
||||
"Дата подключения": r.connectionDate || "",
|
||||
"Тарифный план": r.tariff || "",
|
||||
"ARPU (тыс. тг)": r.arpu || ""
|
||||
})));
|
||||
|
||||
XLSX.utils.book_append_sheet(wb, ws, "Все записи");
|
||||
|
||||
const regions = [...new Set(data.records.map(r => r.region).filter(Boolean))];
|
||||
regions.forEach(region => {
|
||||
const regionData = data.records.filter(r => r.region === region);
|
||||
const regionWs = XLSX.utils.json_to_sheet(regionData.map(r => ({
|
||||
"№": r.num || "",
|
||||
"Сегмент": r.segment || "",
|
||||
"Район": r.district || "",
|
||||
"Населенный пункт": r.settlement || "",
|
||||
"Адрес": r.address || "",
|
||||
"Наименование клиента": r.clientName || "",
|
||||
"Вид деятельности": r.activity || "",
|
||||
"Тех. возможность": r.techPossibility || "",
|
||||
"Статус строительства": r.constructionStatus || "",
|
||||
"Начало строительства": r.constructionStart || "",
|
||||
"Сроки завершения": r.constructionEnd || "",
|
||||
"Примечание": r.note || "",
|
||||
"№ устройства": r.deviceNum || "",
|
||||
"Статус подключения": r.connectionStatus || "",
|
||||
"Дата подключения": r.connectionDate || "",
|
||||
"Тарифный план": r.tariff || "",
|
||||
"ARPU (тыс. тг)": r.arpu || ""
|
||||
})));
|
||||
XLSX.utils.book_append_sheet(wb, regionWs, region.slice(0, 31));
|
||||
});
|
||||
|
||||
const buffer = XLSX.write(wb, { type: "buffer", bookType: "xlsx" });
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"Content-Disposition": "attachment; filename=clients.xlsx"
|
||||
});
|
||||
return res.end(buffer);
|
||||
}
|
||||
|
||||
// Static files
|
||||
let file = url === "/" ? "/index.html" : 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 = mime[ext] || "text/plain";
|
||||
res.writeHead(200, { "Content-Type": type });
|
||||
return res.end(fs.readFileSync(full));
|
||||
}
|
||||
|
||||
res.writeHead(404, { "Content-Type": "text/plain" });
|
||||
res.end("Not found");
|
||||
}).listen(PORT, () => console.log("Сервер запущен на порту " + PORT));
|
||||
Loading…
Reference in New Issue
Block a user