918 lines
51 KiB
JavaScript
918 lines
51 KiB
JavaScript
// 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();
|