https-docs-google-com-spread/app.js

918 lines
51 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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();