Compare commits

..

No commits in common. "main" and "pages" have entirely different histories.
main ... pages

7 changed files with 1327 additions and 1375 deletions

View File

@ -1,167 +0,0 @@
# Промты для создания приложения VOLS Registry
## Исходный запрос
```
Создай приложение для учёта объектов корпоративного сегмента ВОЛС Kazakhtelecom Business
```
## Детализация требований
### 1. Структура данных
```
18 колонок:
1. Сегмент
2. Подразделение (ДПБО / ДПККК)
3. Область (17 областей Казахстана)
4. Район
5. Населенный пункт
6. Адрес
7. Клиент
8. Вид деятельности
9. Наличие ТВ (да/нет)
10. Статус строительства ВОЛС (Проект, СМР, СМР завершено, Исключено)
11. Начало строительства (дата)
12. Срок строительства (дата)
13. Примечание
14. № устройства
15. Статус подключения (Подключено, Заявка, Отказ, Перспектива)
16. Дата подключения (дата)
17. Тариф
18. ARPU (тенге)
```
### 2. Ролевая модель
```
- admin: полный доступ ко всем объектам и пользователям
- editor: редактирование объектов только своего подразделения (ДПБО или ДПККК)
- view: только просмотр всех объектов
```
### 3. Дизайн-система
```
Брендинг Kazakhtelecom Business:
- Основной цвет: #4B1D6B (глубокий фиолетовый)
- Акцентный цвет: #8DC63F (лайм)
- Фоновый цвет: #F5F3F8 (светло-серый с фиолетовым оттенком)
- Шрифты: Manrope (заголовки), Inter (текст)
- Скругления: 1rem для карточек, 0.75rem для кнопок
- Тени: мягкие, рассеянные
```
### 4. Функциональные требования
```
Экраны:
1. Start Screen — выбор устройства (ПК/мобильное) и режима (просмотр/редактирование)
2. Auth Screen — выбор демо-пользователя или регистрация нового
3. Main App — таблица/карточки объектов, дашборд, админка пользователей, журнал аудита
Таблица объектов:
- Сортировка по клику на заголовок колонки
- Фильтры: сегмент, область, статус подключения, поиск по всем полям
- Подсветка просроченных объектов (красный фон)
- Кнопки редактирования и удаления (в зависимости от роли)
- Экспорт в Excel
Дашборд:
- KPI: всего объектов, подключено, в работе, суммарный ARPU
- График по областям (bar chart)
- График статусов подключения (doughnut chart)
- Сводка по подразделениям (ДПБО/ДПККК)
Мобильная версия:
- Карточки вместо таблицы
- Детальный просмотр объекта по клику
- Редактирование из детального просмотра
Админ-панель:
- Список всех пользователей
- Редактирование пользователя (ФИО, телефон, роль, регион)
- Удаление пользователя
- Журнал аудита (последние 300 записей)
```
### 5. Технические требования
```
- Single Page Application на чистом JavaScript (без фреймворков)
- Хранение данных: localStorage (volsRecords, volsUsers, volsAudit)
- Seed-данные при первом запуске: 30 объектов + 4 пользователя
- Экспорт в Excel через библиотеку XLSX (CDN)
- Графики через Chart.js (CDN)
- Адаптивная вёрстка (mobile-first подход)
- Модальные окна для редактирования/удаления
- Audit log всех действий пользователя
```
### 6. Seed-данные
```
Генерация 30 тестовых объектов:
- Равномерное распределение по подразделениям (ДПБО/ДПККК)
- Все области Казахстана
- Различные сегменты (Корпоративный, Банки, Телеком, Нефтегаз и т.д.)
- Случайные статусы строительства и подключения
- Даты строительства в прошлом (2025-2026)
- ARPU от 30 до 180 тенге для подключенных объектов
4 демо-пользователя:
1. admin: Торегожиев Саяжан Сабырович, +77771112233, регион: all
2. editor: Иванов Петр, +77774445566, регион: ДПБО
3. editor: Смирнов Алексей, +77777778899, регион: ДПККК
4. view: Гость Тестовый, +77770001122, регион: none
```
### 7. Бизнес-правила
```
- Просроченные объекты: дата окончания строительства < TODAY и статус не "СМР завершено"/"Исключено"
- TODAY = "2026-08-22" (фиксированная дата для демонстрации)
- Редактор может редактировать только объекты своего подразделения
- Админ может редактировать все объекты и пользователей
- Журнал аудита хранит последние 300 записей
- Все данные сохраняются в localStorage автоматически
```
### 8. Финальный промт для генерации
```
Создай Single Page Application (один HTML файл + один JS файл) для реестра объектов ВОЛС.
Требования:
- Весь код в index.html (стили) и app.js (логика)
- Использование CDN для Chart.js и XLSX
- Ролевая модель: admin, editor, view
- 18 полей объекта, 4 экрана (start, auth, main table/cards, dashboard)
- Seed-данные: 30 объектов + 4 пользователя
- localStorage для персистентности
- Audit log всех действий
- Экспорт в Excel
- Мобильная адаптация (карточки вместо таблицы)
- Дизайн в цветах Kazakhtelecom (#4B1D6B, #8DC63F)
- Шрифты Manrope и Inter
Структура app.js:
1. State object (screen, device, mode, user, users, records, audit, filter, sort)
2. Storage helpers (get/set localStorage)
3. Seed data function (30 records + 4 users)
4. Audit logging function
5. Render functions (start, auth, main)
6. Init functions (event handlers)
7. Helper functions (filter, sort, stats, export)
Начни с index.html, затем app.js.
```
## Итоговые файлы проекта
1. `index.html` — 500+ строк (стили + разметка)
2. `app.js` — 1000+ строк (вся логика приложения)
3. `server.js` — статический сервер для раздачи файлов
4. `package.json` — зависимости (xlsx)
5. `README.md` — документация проекта
6. `PROMPTS.md` — этот файл с историей промтов
## Ключевые решения в процессе разработки
1. **Отказ от React** — изначальный план использовать React был изменён на чистый JS для надёжности и простоты развёртывания
2. **localStorage вместо бэкенда** — все данные хранятся в браузере пользователя
3. **Один файл app.js** — вся логика в одном файле для простоты поддержки
4. **Модальные окна** — вместо отдельных страниц для редактирования/удаления
5. **Адаптивный дизайн** — карточки на мобильных, таблица на ПК
6. **Audit log** — журналирование всех действий для администратора

View File

@ -1,96 +1,2 @@
# Реестр объектов ВОЛС | Kazakhtelecom Business # https-docs-google-com-spread
Приложение для учёта и мониторинга объектов корпоративного сегмента ВОЛС.
## Возможности
- ✅ Учёт объектов: 18 полей (клиент, адрес, сегмент, статусы строительства и подключения, ARPU и др.)
- ✅ Фильтрация и поиск: по сегментам, областям, статусам, тексту
- ✅ Сортировка таблицы по любой колонке
- ✅ Дашборд с KPI и графиками (объекты по областям, статусы подключения)
- ✅ Аналитика по подразделениям (ДПБО / ДПККК)
- ✅ Ролевой доступ: админ (полный), редактор (ограниченные поля), просмотр
- ✅ Мобильная версия: карточки вместо таблицы
- ✅ Экспорт в Excel (.xlsx)
- ✅ Журнал аудита всех действий
- ✅ Сохранение данных в localStorage
## Демо-пользователи
| Роль | ФИО | Телефон | Доступ |
|------|-----|---------|--------|
| Админ | Торегожиев Саяжан Сабырович | +77771112233 | Полный доступ ко всем объектам и пользователям |
| Редактор | Иванов Петр | +77774445566 | Редактирование объектов ДПБО |
| Редактор | Смирнов Алексей | +77777778899 | Редактирование объектов ДПККК |
| Просмотр | Гость Тестовый | +77770001122 | Только просмотр |
## Поля объекта
1. Подразделение (ДПБО / ДПККК)
2. Область
3. Район
4. Город
5. Адрес
6. Клиент
7. Вид деятельности
8. Наличие ТВ
9. Сегмент (Корпоративный, Гос.органы, Банки, Телеком, Нефтегаз, Энергетика, Транспорт, Другой)
10. Статус строительства (Проект, СМР, СМР завершено, Исключено)
11. Начало строительства
12. Срок строительства
13. Примечание
14. № устройства
15. Статус подключения (Подключено, Заявка, Отказ, Перспектива)
16. Дата подключения
17. Тариф
18. ARPU (₸)
## Запуск
```bash
npm install
npm start
```
Или через команду `run` в среде Vibe42.
## Технологии
- HTML5, CSS3, Vanilla JavaScript (ES6+)
- Chart.js — графики и диаграммы
- XLSX — экспорт в Excel
- localStorage — персистентное хранилище данных
- Kazakhtelecom branding: цвета #4B1D6B (фиолетовый), #8DC63F (лайм), шрифты Manrope/Inter
## Структура файлов
- `index.html` — разметка и стили
- `app.js` — логика приложения (состояние, рендеринг, управление данными)
- `server.js` — статический сервер для раздачи файлов
- `package.json` — зависимости и скрипты
## Данные
Данные хранятся в localStorage браузера:
- `volsRecords` — массив объектов реестра
- `volsUsers` — массив пользователей
- `volsAudit` — журнал аудита (последние 300 записей)
При первом запуске генерируются seed-данные: 30 объектов и 4 пользователя.
## Администрирование
Администратор может:
- Просматривать список всех пользователей
- Редактировать пользователей (ФИО, телефон, роль, регион)
- Удалять пользователей (кроме себя)
- Просматривать журнал аудита
- Видеть popup с контактами администратора (кнопка вверху справа)
## Экспорт
Кнопка «Excel» в верхней панели экспортирует все отфильтрованные записи в файл `reestr_vols_ГГГГ-ММ-ДД.xlsx` с полями:
- Подразделение, Область, Район, Город, Адрес
- Клиент, Вид деятельности, Наличие ТВ, Сегмент
- Статус строительства, Начало строительства, Срок строительства, Примечание
- № устройства, Статус подключения, Дата подключения, Тариф, ARPU

917
app.js
View File

@ -1,917 +0,0 @@
// 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();

View File

@ -2,195 +2,740 @@
<html lang="ru"> <html lang="ru">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Реестр объектов ВОЛС | Kazakhtelecom Business</title> <title>Система учёта клиентов</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> <style>
* { margin: 0; padding: 0; box-sizing: border-box; } :root{--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7;--green:#10B981;--red:#EF4444;--blue:#3B82F6}
body { font-family: 'Inter', sans-serif; background: #F5F3F8; color: #241934; } *{box-sizing:border-box;margin:0;padding:0}
.font-manrope { font-family: 'Manrope', sans-serif; } 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}
/* Colors */ .header{display:flex;justify-content:space-between;align-items:center;margin-bottom:24px;flex-wrap:wrap;gap:12px}
.bg-violet-soft { background: #EFE7F6; } .header h1{font-size:24px;font-weight:700}
.bg-violet { background: #4B1D6B; } .tabs{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
.bg-violet-deep { background: #331149; } .tab{padding:8px 16px;border:1px solid var(--gray-100);border-radius:8px;cursor:pointer;background:var(--white);font-weight:500}
.text-violet { color: #4B1D6B; } .tab.active{background:var(--ink);color:var(--white);border-color:var(--ink)}
.text-lime { color: #8DC63F; } .btn{padding:10px 20px;border-radius:8px;border:none;cursor:pointer;font-weight:600;font-size:14px}
.border-lime { border-color: #8DC63F; } .btn-primary{background:var(--cyan);color:var(--ink)}
.bg-lime { background: #8DC63F; } .btn-success{background:var(--green);color:var(--white)}
.btn-danger{background:var(--red);color:var(--white)}
/* Start Screen */ .btn-secondary{background:var(--gray-100);color:var(--ink)}
.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); } .toolbar{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px}
.fiber-trace { position: absolute; inset: 0; opacity: 0.4; pointer-events: none; } .table-container{overflow-x:auto;background:var(--white);border-radius:12px;border:1px solid var(--gray-100)}
.start-content { position: relative; z: 10; text-align: center; max-width: 500px; } table{width:100%;border-collapse:collapse;min-width:1200px}
.kt-logo { width: 48px; height: 48px; object-fit: contain; } th,td{padding:12px;text-align:left;border-bottom:1px solid var(--gray-100);white-space:nowrap}
.start-title { font-size: 1.5rem; font-weight: 800; margin-top: 1.5rem; line-height: 1.3; } th{background:var(--gray-100);font-weight:600;position:sticky;top:0}
.device-grid, .mode-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-top: 1.5rem; width: 100%; } tr:hover{background:var(--cyan-50)}
.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; } .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}
.device-btn.selected, .mode-btn.selected { border-color: #4B1D6B; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.08); } .modal.open{display:block}
.device-btn svg, .mode-btn svg { width: 24px; height: 24px; } .modal-content{background:var(--white);margin:40px auto;max-width:800px;border-radius:12px;padding:24px;max-height:90vh;overflow-y:auto}
.device-btn span, .mode-btn span { font-size: 0.875rem; font-weight: 500; } .form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(250px,1fr));gap:16px;margin-bottom:16px}
.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; } .form-group{display:flex;flex-direction:column;gap:4px}
.continue-btn.active { opacity: 1; pointer-events: auto; } .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}
/* Auth Screen */ .form-group textarea{resize:vertical;min-height:80px}
.auth-screen { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1.5rem; } .actions{display:flex;gap:8px}
.auth-card { width: 100%; max-width: 448px; background: white; border-radius: 1rem; border: 1px solid #E6E1ED; padding: 1.75rem; } .edit-cell{cursor:pointer;color:var(--blue)}
.auth-title { font-size: 1.25rem; font-weight: 700; margin-bottom: 0.25rem; font-family: 'Manrope', sans-serif; } .edit-cell:hover{text-decoration:underline}
.auth-subtitle { font-size: 0.75rem; color: #6B6478; margin-bottom: 1.5rem; } .stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:24px}
.demo-section, .register-section { margin-bottom: 1.5rem; } .stat-card{background:var(--gray-100);padding:20px;border-radius:12px}
.section-label { font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #6B6478; margin-bottom: 0.5rem; } .stat-card h3{font-size:14px;color:var(--gray-500);margin-bottom:8px}
.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; } .stat-card .value{font-size:28px;font-weight:700;color:var(--ink)}
.user-btn:hover { border-color: #A78BCA; } .filter-bar{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px;align-items:center}
.user-info div:first-child { font-size: 0.875rem; font-weight: 500; } .filter-bar input,.filter-bar select{padding:8px 12px;border:1px solid var(--gray-100);border-radius:8px;font-size:14px}
.user-info div:last-child { font-size: 0.6875rem; color: #6B6478; } .login-form{max-width:400px;margin:60px auto}
.role-tag { font-size: 0.625rem; font-weight: 600; padding: 0.25rem 0.5rem; border-radius: 9999px; } .user-info{display:flex;align-items:center;gap:12px}
.role-admin { color: #4B1D6B; background: rgba(75,29,107,0.09); } .avatar{width:36px;height:36px;border-radius:50%;background:var(--cyan);display:flex;align-items:center;justify-content:center;font-weight:700}
.role-editor { color: #3B7DD8; background: rgba(59,125,216,0.09); } .permission-badge{font-size:11px;padding:4px 8px;background:var(--cyan-50);border-radius:4px;color:var(--ink)}
.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> </style>
</head> </head>
<body> <body>
<div id="app"></div> <div class="container">
<script src="app.js" type="module"></script> <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>
</body> </body>
</html> </html>

343
package-lock.json generated Normal file
View File

@ -0,0 +1,343 @@
{
"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"
}
}
}
}

View File

@ -1,11 +1,13 @@
{ {
"name": "vols-registry", "name": "client-tracker",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"scripts": { "scripts": {
"start": "node --env-file=.env server.js" "start": "node --env-file=.env server.js"
}, },
"dependencies": { "dependencies": {
"xlsx": "^0.18.5" "xlsx": "^0.18.5",
"pptxgenjs": "^3.12.0",
"formidable": "^3.5.1"
} }
} }

256
server.js
View File

@ -3,25 +3,265 @@ const fs = require("fs");
const path = require("path"); const path = require("path");
const PORT = process.env.PORT || 3000; 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");
const mimeTypes = { 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", ".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8", ".css": "text/css; charset=utf-8",
".js": "application/javascript; charset=utf-8", ".js": "application/javascript; charset=utf-8",
".json": "application/json; charset=utf-8" ".json": "application/json; charset=utf-8",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
}; };
http.createServer((req, res) => { http.createServer(async (req, res) => {
let file = req.url === "/" ? "/index.html" : req.url.split("?")[0]; const url = req.url.split("?")[0];
const full = path.join(__dirname, file); 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()) { if (full.startsWith(__dirname) && fs.existsSync(full) && fs.statSync(full).isFile()) {
const ext = path.extname(full); const ext = path.extname(full);
const type = mimeTypes[ext] || "application/octet-stream"; const type = mime[ext] || "text/plain";
res.writeHead(200, { "Content-Type": type }); res.writeHead(200, { "Content-Type": type });
return res.end(fs.readFileSync(full)); return res.end(fs.readFileSync(full));
} }
res.writeHead(404); res.writeHead(404, { "Content-Type": "text/plain" });
res.end("Not found"); res.end("Not found");
}).listen(PORT, () => console.log("VOLS Registry на порту " + PORT)); }).listen(PORT, () => console.log("Сервер запущен на порту " + PORT));