Compare commits
No commits in common. "main" and "pages" have entirely different histories.
167
PROMPTS.md
167
PROMPTS.md
@ -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** — журналирование всех действий для администратора
|
|
||||||
96
README.md
96
README.md
@ -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
917
app.js
@ -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();
|
|
||||||
917
index.html
917
index.html
@ -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
343
package-lock.json
generated
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
256
server.js
@ -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));
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user