chore: remove agent artifacts

This commit is contained in:
Z User
2026-08-05 13:40:43 +00:00
parent 482e8cf444
commit 83706f1ffa
10 changed files with 0 additions and 9595 deletions

View File

@@ -1,28 +0,0 @@
# Task 9-11 — All Remaining API Routes & Page Components
**Status**: Completed
## Files Created
### API Routes (4 new)
1. `src/app/api/purchases/bulk/route.ts` — GET paginated purchases with user/product includes, status filter
2. `src/app/api/audit/bulk/route.ts` — GET paginated audit log, ordered by id DESC
3. `src/app/api/settings/route.ts` — GET hardcoded settings, PUT accept key/value
4. `src/app/api/locales/route.ts` — GET hardcoded 3-language demo data, PUT accept lang/key/value
5. `src/app/api/seed/data/route.ts` — GET check if DB has data
6. `src/app/api/seed/demo/route.ts` — POST seed demo data with reauth, clears first then seeds
7. `src/app/api/seed/clear/route.ts` — POST clear all tables with reauth, resets autoincrement
### Page Components (7 new)
1. `src/components/purchases/purchases-page.tsx` — Status filter tabs, paginated table with copy-hash, user links
2. `src/components/audit/audit-page.tsx` — Color-coded action badges, collapsible JSON details, pagination
3. `src/components/categories/categories-page.tsx` — CRUD dialog, location select grouped, switch toggle, delete protection
4. `src/components/locations/locations-page.tsx` — CRUD dialog, switch toggle, delete protection with category check
5. `src/components/settings/settings-page.tsx` — Sectioned settings, masked secrets, per-field save, restart warning
6. `src/components/locales/locales-page.tsx` — Inline-editable table, section headers with rowSpan, blur-to-save
7. `src/components/seed/seed-page.tsx` — Super admin gate, danger cards, reauth AlertDialog, DB status indicator
## Notes
- Categories and Locations APIs already existed from catalog agent (Task 6)
- All ESLint clean, 0 errors
- Pre-existing dev.log error (admin-header.tsx import) is unrelated

View File

@@ -1,18 +0,0 @@
# Task ID: new-features-2
## Agent: Main Coordinator
## Task: Activity Feed, Export Button, Sortable Header, Clipboard Utility, Integration
### Files Created
- `src/components/layout/activity-feed.tsx` — ActivityFeed component (fetches audit/bulk, 30s refresh, color-coded icons, relative time)
- `src/components/shared/export-button.tsx` — ExportButton component (CSV export via DropdownMenu, Blob download)
- `src/components/shared/sortable-header.tsx` — SortableHeader component (3-state sort toggle with arrow icons)
- `src/lib/clipboard.ts` — copyToClipboard utility (navigator.clipboard + textarea fallback)
### Files Modified
- `src/components/dashboard/dashboard-page.tsx` — Replaced inline activity feed with ActivityFeed, removed unused imports/types
- `src/components/purchases/purchases-page.tsx` — Added ExportButton, SortableHeader (date/amount/status), client-side sort, clipboard util
- `src/components/audit/audit-page.tsx` — Added ExportButton, SortableHeader (action/date), client-side sort
- `src/components/users/users-page.tsx` — Added ExportButton next to search input
### Lint Result
- ESLint: 0 errors, 0 warnings

View File

@@ -1,751 +0,0 @@
1→# Админ-панель Telegram Shop — Техническое описание фронтенда и API
2→
3→> **Назначение документа**: комплексное ТЗ для отдела фронтенда по реализации полноценного админ-кабинета поверх существующего бэкенда.
4→> **Версия бэкенда**: v1.2.4 (2026-08-05)
5→> **Дата**: 2026-08-05
6→> **Статус**: актуально на момент передачи
7→
8→---
9→
10→## Оглавление
11→
12→1. [Обзор системы](#1-обзор-системы)
13→2. [Технологический стек](#2-технологический-стек)
14→3. [Архитектура развёртывания](#3-архитектура-развёртывания)
15→4. [Доступ и безопасность](#4-доступ-и-безопасность)
16→5. [Схема базы данных](#5-схема-базы-данных)
17→6. [Карта эндпоинтов (полная)](#6-карта-эндпоинтов-полная)
18→7. [Спецификация экранов](#7-спецификация-экранов)
19→8. [Роли и права](#8-роли-и-права)
20→9. [Формат данных](#9-формат-данных)
21→10. [Существующие ограничения и подводные камни](#10-существующие-ограничения-и-подводные-камни)
22→11. [Требования к новому фронтенду](#11-требования-к-новому-фронтенду)
23→12. [API-контракты JSON (дополнить бэкенду)](#12-api-контракты-json-дополнить-бэкенду)
24→13. [Чек-лист приёмки](#13-чек-лист-приёмки)
25→
26→---
27→
28→## 1. Обзор системы
29→
30→Telegram Shop — это **Telegram-бот-магазин** (Node.js) с **встроенной админ-панелью** на Express + EJS (шаблон SmartAdmin). Продажи идут через Telegram-бота, админка управляет всем магазином через браузер.
31→
32→Бизнес-сущности:
33→
34→| Сущность | Описание |
35→|---|---|
36→| **Users** | Покупатели из Telegram (id, ник, локация, статус, балансы, язык) |
37→| **Locations** | Гео-иерархия: Страна → Город → Район (округа) |
38→| **Categories / Subcategories** | Категории товаров, привязанные к локации; подкатегории внутри категории |
39→| **Products** | Товары: цена, остаток, публичное фото, скрытый контент (фото/координаты/описание), private-заметки, цифровые (mono) |
40→| **Purchases** | Заказы: пользователь, товар, валюта, tx_hash, количество, сумма, статус |
41→| **Crypto Wallets** | Криптокошельки пользователей (BTC/LTC/ETH/USDT/USDC), с зашифрованными seed-фразами |
42→| **Transactions** | Ончейн-транзакции пользователей |
43→| **Commission payments** | Платежи комиссии владельцу платформы (SaaS-модель) |
44→| **Audit log** | Журнал действий админов |
45→| **Locales (i18n)** | JSON-файлы переводов бота (en/es/de), редактируются из админки |
46→
47→**Ключевой факт**: бот и админка живут в **одном процессе** и используют **одну SQLite-базу** (`db/shop.db`) — поэтому любые изменения в админке мгновенно видны боту и наоборот. Отдельного REST API **нет** — админка рендерит EJS на сервере и ходит в БД напрямую.
48→
49→---
50→
51→## 2. Технологический стек
52→
53→### Текущий (серверная часть — НЕ трогать без отдельной задачи)
54→
55→| Слой | Технология |
56→|---|---|
57→| Язык | Node.js ≥ 18 (ES Modules, `"type": "module"`) |
58→| Web-фреймворк | Express 4 |
59→| Шаблонизация | EJS + `express-ejs-layouts` (layout: `views/layout.ejs`) |
60→| Шаблон UI | SmartAdmin (собственные партиалы в `views/partials/`, SCSS в `public/sass/`, сборка `smartapp.min.css`) |
61→| БД | SQLite через `better-sqlite3` (обёртка `db.runAsync/allAsync/getAsync` в `src/config/database.js`) |
62→| Загрузка файлов | Multer (только картинки jpeg/png/webp/gif, ≤ 10 МБ) в `uploads/` |
63→| QR | `qrcode` (для seed-фраз) |
64→| CDN-ресурсы | Bootstrap 5.3.2, FontAwesome 6.4.2 с jsDelivr (только в legacy-`layout.js`) |
65→
66→### Текущий фронтенд (что есть)
67→
68→- **Два параллельных набора вьюх**:
69→ 1. **EJS-шаблоны** (`views/*.ejs`) — используются роутами (dashboard, users, wallets, purchases, audit, settings, categories, locations, payment-wallets, seed, locales, catalog, products, product-edit, user-detail).
70→ 2. **JS-рендер-функции** (`views/*.js`) — legacy/дублирующий слой, возвращающий HTML-строки (`layout()`, `renderCatalog()`, `renderWalletLayout()` и т.д.). **На практике роуты рендерят EJS; JS-слой частично устарел** (дублирует catalog и wallets). Новый фронтенд их не использует.
71→- Статика: Bootstrap 5 (SmartAdmin-тема), ApexCharts, jQuery, FontAwesome, собственные скрипты `public/scripts/`.
72→- AJAX-вызовы есть только в нескольких местах (catalog-модалка, wallets-обновление балансов, locales).
73→
74→---
75→
76→## 3. Архитектура развёртывания
77→
78→```
79→Internet
80→ │
81→ ├── HTTPS/LAN ───────────────► telegram_shop_prod :3001 (Express: бот + админка)
82→ │
83→ └── Tor Network ──► tor-proxy ──► HiddenService :80 ──► telegram_shop_prod :3001
84→ (admin onion-адрес, работа без HTTPS)
85→```
86→
87→- Единственный контейнер `telegram_shop_prod` (node:22-alpine) слушает порт `3001` (`ADMIN_PORT`).
88→- Доступ к админке: напрямую `http://host:3001` или через `.onion` (Tor Browser).
89→- Volume: `db/` (SQLite), `uploads/` (фото товаров, отдаются через `/uploads`), `.env` (только чтение).
90→- WireGuard опционален (`WG_ENABLED`).
91→
92→**Следствие для фронтенда**: админка доступна по **HTTP без HTTPS** (в т.ч. через Tor). Это накладывает жёсткие ограничения:
93→- нельзя использовать Secure-куки;
94→- нельзя полагаться на Origin-проверку (Tor) — CSRF реализован вручную (см. §10);
95→- весь JS/CSS должен быть либо локальным, либо закешированным (CDN может быть недоступен из Tor).
96→
97→---
98→
99→## 4. Доступ и безопасность
100→
101→### 4.1. Аутентификация (важно для фронта)
102→
103→Реализована в `src/admin/auth.js`. **Не JWT, а самописный HMAC-токен**:
104→
105→| Параметр | Значение |
106→|---|---|
107→| Кука | `admin_token` (httpOnly, `sameSite=false`, maxAge 24ч) |
108→| Формат токена | `base64(payload).hmac_sha256(payload)` |
109→| Payload | `{ role, jti, iat, exp }` |
110→| Секрет | `ADMIN_SECRET` (env) |
111→| Вход | POST `/login` с полем `token` (пароль = админ-токен) |
112→| Выход | GET `/logout` |
113→
114→Роли (определяются **каким секретом залогинились**):
115→
116→| Роль | Условие | Права |
117→|---|---|---|
118→| `admin` | токен = `ADMIN_SECRET` | Все страницы, кроме seed-выгрузки |
119→| `super_admin` | токен = `SUPER_ADMIN_SECRET` (если задан и ≠ ADMIN_SECRET) | + просмотр/экспорт seed-фраз, комиссии |
120→
121→При `SUPER_ADMIN_SECRET` не заданном — **все админы считаются супер-админами** (`config.SUPER_ADMIN_IDS` по умолчанию = `ADMIN_IDS`, но роли в админке задаются именно через секреты, не через `ADMIN_IDS`).
122→
123→Rate-limit логина: 5 попыток / 15 минут с IP.
124→
125→**Деструктивные действия** (`/seed/*`) требуют повторного ввода токена в поле `reauth_token` (middleware `requireReAuth`).
126→
127→### 4.2. Защита статики и загрузок
128→
129→- `/uploads/*` — только после `requireAuth`, заголовки `X-Content-Type-Options: nosniff` + `Content-Disposition: attachment` (фото не рендерятся inline из uploads, только скачивание).
130→- Файлы uploads именуются `{timestamp}-{hex}.{ext}`, MIME-белый список.
131→- CSRF: заглушка (`csrf.js` возвращает пустой токен и no-op) — **сознательно отключено ради совместимости с Tor**. См. §10.
132→
133→### 4.3. Существующие уязвимости, которые фронтенд не должен усугублять
134→
135→1. Нет реальной CSRF-защиты — все POST-формы уязвимы к cross-site-запросам. В новом фронте использовать токены, если бэкенд их вернёт, иначе как минимум не убирать подтверждения на деструктив.
136→2. Некоторые вьюхи выводят данные с ручным `esc()`/`escapeHtml()`; часть EJS использует `<%= %>` (экранируется) — но есть места с `JSON.stringify` прямо в `<script>` (инъекция через данные). Новому фронту: **все данные из API экранировать на клиенте**.
137→3. Пароль админа (ADMIN_SECRET) отправляется как обычное поле формы.
138→4. Seed-фразы — сверхчувствительные данные; показывать только super_admin, с аудитом (logAudit: `seed_phrase_viewed`, `seed_phrase_qr_viewed`, `csv_seed_export`), желательно с подтверждением.
139→
140→---
141→
142→## 5. Схема базы данных
143→
144→Источник истины: `src/migrations/*.js` (001012). SQLite, FK включены, WAL-режим.
145→
146→### 5.1. `users`
147→
148→| Колонка | Тип | Описание |
149→|---|---|---|
150→| `id` | INTEGER PK AUTOINCREMENT | Внутренний ID |
151→| `telegram_id` | TEXT UNIQUE NOT NULL | ID в Telegram (строка!) |
152→| `username` | TEXT | Ник |
153→| `country` / `city` / `district` | TEXT | Гео из бота (снимок) |
154→| `status` | INTEGER DEFAULT 0 | `0`=активен, `2`=заблокирован, прочее=удалён |
155→| `total_balance` | REAL DEFAULT 0 | Основной баланс в USD |
156→| `bonus_balance` | REAL DEFAULT 0 | Бонусный баланс в USD |
157→| `language` | TEXT DEFAULT 'en' | Язык бота (`en`/`es`/`de`) |
158→| `language_set` | INTEGER DEFAULT 0 | Флаг выбора языка |
159→| `created_at` | DATETIME | Регистрация |
160→
161→### 5.2. `crypto_wallets`
162→
163→| Колонка | Тип | Описание |
164→|---|---|---|
165→| `id` | INTEGER PK | |
166→| `user_id` | INTEGER FK→users (CASCADE) | |
167→| `wallet_type` | TEXT | BTC/LTC/ETH/USDT/USDC (+ архивные с суффиксом `#N`, отфильтровываются `NOT LIKE '%#_%'`) |
168→| `address` | TEXT | Адрес |
169→| `derivation_path` | TEXT | Derivation path |
170→| `mnemonic` | TEXT | **Зашифрована** (`encrypt(mnemonic, user_id)`) |
171→| `balance` | REAL DEFAULT 0 | Кэш баланса (обновляется с блокчейна) |
172→| `created_at` | DATETIME | |
173→| UNIQUE | (user_id, wallet_type) | |
174→
175→### 5.3. `transactions`
176→
177→| Колонка | Тип | Описание |
178→|---|---|---|
179→| `id` | INTEGER PK | |
180→| `user_id` | INTEGER FK | |
181→| `wallet_type` | TEXT | |
182→| `tx_hash` | TEXT | |
183→| `amount` | REAL | |
184→| `created_at` | DATETIME | |
185→
186→### 5.4. `locations`
187→
188→| Колонка | Тип | Описание |
189→|---|---|---|
190→| `id` | INTEGER PK | |
191→| `country` | TEXT NOT NULL | |
192→| `city` | TEXT NOT NULL | |
193→| `district` | TEXT NOT NULL | Район (может быть пустой строкой) |
194→| `is_active` | INTEGER NOT NULL DEFAULT 1 | Отключённые локации скрыты из бота |
195→| `created_at` | DATETIME | |
196→| UNIQUE | (country, city, district) | |
197→
198→### 5.5. `categories`
199→
200→| Колонка | Тип | Описание |
201→|---|---|---|
202→| `id` | INTEGER PK | |
203→| `location_id` | INTEGER FK→locations (CASCADE) | |
204→| `name` | TEXT NOT NULL | |
205→| `is_active` | INTEGER NOT NULL DEFAULT 1 | |
206→| `created_at` | DATETIME | |
207→| UNIQUE | (location_id, name) | |
208→
209→### 5.6. `subcategories`
210→
211→| Колонка | Тип | Описание |
212→|---|---|---|
213→| `id` | INTEGER PK | |
214→| `category_id` | INTEGER FK→categories (CASCADE) | |
215→| `name` | TEXT NOT NULL | |
216→| `is_active` | INTEGER NOT NULL DEFAULT 1 | |
217→| `created_at` | DATETIME | |
218→| UNIQUE | (category_id, name) | |
219→
220→### 5.7. `products`
221→
222→| Колонка | Тип | Описание |
223→|---|---|---|
224→| `id` | INTEGER PK | |
225→| `location_id` | INTEGER FK→locations (CASCADE) | |
226→| `category_id` | INTEGER FK→categories (CASCADE) | |
227→| `subcategory_id` | INTEGER FK→subcategories (SET NULL) | |
228→| `name` | TEXT NOT NULL | |
229→| `description` | TEXT | Публичное описание |
230→| `private_data` | TEXT | Внутренние заметки (не показывать) |
231→| `price` | REAL NOT NULL CHECK (price > 0) | USD |
232→| `quantity_in_stock` | INTEGER DEFAULT 0 | Остаток |
233→| `photo_url` | TEXT | Публичное фото (URL или `/uploads/...`) |
234→| `hidden_photo_url` | TEXT | Фото после покупки |
235→| `hidden_coordinates` | TEXT | Координаты после покупки (`lat,lng`) |
236→| `hidden_description` | TEXT | Текст после покупки |
237→| `is_mono` | INTEGER DEFAULT 0 | **Цифровой товар**: бесконечный остаток (`1` ⇒ stock=999999, в боте продаётся без складских проверок) |
238→| `created_at` | DATETIME | |
239→
240→### 5.8. `purchases`
241→
242→| Колонка | Тип | Описание |
243→|---|---|---|
244→| `id` | INTEGER PK | |
245→| `user_id` | INTEGER FK→users (CASCADE) | |
246→| `product_id` | INTEGER FK→products (CASCADE) | |
247→| `wallet_type` | TEXT | Валюта оплаты |
248→| `tx_hash` | TEXT | Хэш транзакции |
249→| `quantity` | INTEGER CHECK (quantity > 0) | |
250→| `total_price` | REAL CHECK (total_price > 0) | USD |
251→| `purchase_date` | DATETIME | |
252→| `status` | TEXT DEFAULT 'pending' | `pending` / `completed` / `cancelled` |
253→
254→### 5.9. `commission_payments`
255→
256→| Колонка | Тип | Описание |
257→|---|---|---|
258→| `id` | INTEGER PK | |
259→| `total_balance_usd` | REAL NOT NULL | Снимок баланса на момент платежа |
260→| `commission_rate` | REAL NOT NULL | % комиссии |
261→| `commission_amount_usd` | REAL NOT NULL | Начисленная комиссия |
262→| `paid_amount_usd` | REAL NOT NULL | Фактически уплачено |
263→| `wallet_count` | INTEGER NOT NULL | Кошельков на момент |
264→| `note` | TEXT | |
265→| `created_at` | DATETIME | |
266→
267→### 5.10. `audit_log`
268→
269→| Колонка | Тип | Описание |
270→|---|---|---|
271→| `id` | INTEGER PK | |
272→| `action` | TEXT NOT NULL | Слаг действия: `balance_adjust`, `csv_seed_export`, `seed_phrase_viewed`, `seed_phrase_qr_viewed`, `login` и т.д. |
273→| `admin_id` | TEXT NOT NULL | Роль или ID |
274→| `details` | TEXT | JSON-строка с контекстом |
275→| `created_at` | DATETIME | |
276→
277→### 5.11. `user_states`
278→
279→| Колонка | Тип | Описание |
280→|---|---|---|
281→| `chat_id` | TEXT PK | |
282→| `state_data` | TEXT | JSON состояния диалога бота |
283→| `updated_at` | INTEGER | |
284→
285→---
286→
287→## 6. Карта эндпоинтов (полная)
288→
289→Базовый URL: `http://<host>:3001`. **Все маршруты, кроме `/login`, `/logout`, `/health`, требуют куки `admin_token`** (redirect на `/login`).
290→
291→Легенда: 🔓 — только super_admin · ⚠️ — деструктивно, требует `requireReAuth` · 📁 — загрузка файла · 🔁 — редирект после POST.
292→
293→### 6.1. Служебные
294→
295→| Метод | Путь | Назначение |
296→|---|---|---|
297→| GET | `/health` | Health-check → `{ status:'ok', uptime }` |
298→| GET | `/login` | Страница логина (поле `token`) |
299→| POST | `/login` | Вход; body `{ token }`; 429 при rate-limit; 401 неверный токен |
300→| GET | `/logout` | Выход + инвалидация jti |
301→| GET | `/error` (рендер) | Страница ошибки (глобальный обработчик) |
302→
303→### 6.2. Dashboard (роут: `routes/dashboard.js`)
304→
305→| Метод | Путь | Ответ |
306→|---|---|---|
307→| GET | `/` | EJS `dashboard` со статами и данными графиков |
308→
309→Query-параметры-флеши: `?seeded=1`, `?cleared=1` (показывают сообщения).
310→
311→**Данные, передаваемые в шаблон** (это и есть «API» дашборда):
312→
313→| Поле | Тип | Содержимое |
314→|---|---|---|
315→| `stats.totalUsers` | int | Всего пользователей |
316→| `stats.totalProducts` | int | Всего товаров |
317→| `stats.totalPurchases` | int | Всего покупок |
318→| `stats.totalRevenue` | float | Выручка по `status='completed'` |
319→| `stats.totalSubcategories` | int | Подкатегорий |
320→| `stats.aov` | float | Средний чек (completed) |
321→| `stats.conversionRate` | float % (1 знак) | Купившие / все пользователи × 100 |
322→| `stats.completedPurchases` / `pendingPurchases` / `cancelledPurchases` | int | Сплит по статусам |
323→| `chartData.days` | string[] | Последние 7 дат `YYYY-MM-DD` |
324→| `chartData.days30` | string[] | Последние 30 дат |
325→| `chartData.revenueData` | number[] | Выручка по дням (7д) |
326→| `chartData.revenueData30` | number[] | Выручка по дням (30д) |
327→| `chartData.usersData` | number[] | Новые пользователи по дням (7д) |
328→| `chartData.topProducts` | `{name, qty, revenue}[]` | Топ-5 товаров за 30д (по кол-ву) |
329→| `chartData.topSpenders` | `{username, spent}[]` | Топ-5 покупателей за 30д |
330→| `chartData.revenueByCategory` | `{name, revenue}[]` | Выручка по категориям за 30д |
331→| `chartData.topCountries` | `{country, count}[]` | Топ-5 стран из locations |
332→| `activities` | `{type, id, username, item, total_price, date}` (или `{type:'audit', ...}`) | Последние 10 completed-покупок; если пусто — последние 10 audit_log |
333→| `wallets` | `{wallet_type, count, total}[]` | Сводка кошельков по валютам (верхний регистр) |
334→| `commission` | `{enabled, rate, due, currentCommission, lastPaidAmount, totalBalance}` | SaaS-комиссия: начислено/оплачено/к оплате |
335→
336→### 6.3. Catalog (роут: `routes/catalog.js` — дерево; `routes/catalogProducts.js` — товары)
337→
338→Дерево: Страна → Город → Район(location) → Категории → Подкатегории.
339→
340→| Метод | Путь | Body / Param | Назначение |
341→|---|---|---|---|
342→| GET | `/catalog` | query: `?loc=`, `?cat=`, `?sub=` (фильтры), `?msg=&msg_type=` (флеш) | EJS `catalog` + `treeHtml` + `products` |
343→| POST | `/catalog/locations` | `country, city, district?` | + локация |
344→| POST | `/catalog/locations/add-city` | `country, city` | + город (district='') |
345→| POST | `/catalog/locations/add-district` | `country, city, district` | + район |
346→| POST | `/catalog/locations/:id/update` | `country, city, district` | Переименование |
347→| POST | `/catalog/locations/:id/toggle` | — | Вкл/выкл `is_active` |
348→| POST | `/catalog/locations/:id/delete` | — | Удаление (запрещено при наличии категорий/товаров) |
349→| POST | `/catalog/categories` | `name, location_id` | + категория |
350→| POST | `/catalog/categories/json` | JSON `{name, location_id}` | + категория (AJAX, возвращает объект категории) |
351→| POST | `/catalog/categories/:id/update` | `name, location_id?` | Переименование/перенос |
352→| POST | `/catalog/categories/:id/toggle` | — | Вкл/выкл |
353→| POST | `/catalog/categories/:id/delete` | — | Удаление (блокируется при товарах) |
354→| POST | `/catalog/categories/:id/subcategories` | `name` | + подкатегория |
355→| POST | `/catalog/categories/:id/subcategories/json` | JSON `{name}` | + подкатегория (AJAX) |
356→| POST | `/catalog/subcategories/:id/update` | `name` | Переименование |
357→| POST | `/catalog/subcategories/:id/toggle` | — | Вкл/выкл |
358→| POST | `/catalog/subcategories/:id/delete` | — | Удаление (блокируется при товарах) |
359→| POST | `/catalog/products` | multipart: поля товара + `photo_file`, `hidden_photo_file` | + товар |
360→| POST | `/catalog/products/:id/edit` | multipart: поля товара + файлы | Обновление |
361→| POST | `/catalog/products/:id/delete` | — | Удаление товара |
362→| GET | `/catalog/products/:id/json` | — | **AJAX**: товар с `country, city, district, category_name, subcategory_name` |
363→
364→**Поля формы товара** (обязательные: `name, price>0, description, photo`):
365→
366→`name, price, quantity_in_stock, description, photo_url, hidden_photo_url, hidden_coordinates, hidden_description, private_data, category_id, subcategory_id?, location_id?, is_mono`
367→
368→Логика:
369→- `is_mono=1` ⇒ `quantity_in_stock` принудительно `999999`.
370→- Если `location_id` не передан — берётся `location_id` категории.
371→- Файл заменяет URL (`photo_url`), если загружен.
372→- JSON-ответы: `{error: '...'}` при 400.
373→
374→### 6.4. Products (роут: `routes/products.js` — упрощённый, дублирует catalog)
375→
376→| Метод | Путь | Назначение |
377→|---|---|---|
378→| GET | `/products` | EJS `products` (таблица до 100 товаров) |
379→| POST | `/products` | + товар (без файлов; `photo_url` обязателен) |
380→| GET | `/products/:id/edit` | EJS `product-edit` |
381→| POST | `/products/:id/update` | Обновление |
382→| POST | `/products/:id/delete` | Удаление |
383→
384→> **Рекомендация фронту**: считать **catalog** основным экраном товаров; `/products` — legacy-дубль.
385→
386→### 6.5. Categories (роут: `routes/categories.js` — плоские таблицы)
387→
388→| Метод | Путь | Назначение |
389→|---|---|---|
390→| GET | `/categories` | EJS `categories` + `subcategoriesByCategory`, флеши `?error=`, `?success=` |
391→| POST | `/categories` | + категория (`name, location_id`) |
392→| POST | `/categories/:id/update` | `name, location_id` |
393→| POST | `/categories/:id/toggle` | Вкл/выкл |
394→| POST | `/categories/:id/delete` | Удаление (блок при товарах; удаляет и подкатегории) |
395→| POST | `/categories/:id/subcategories` | + подкатегория |
396→| POST | `/categories/subcategories/:id/update` | `name` |
397→| POST | `/categories/subcategories/:id/toggle` | Вкл/выкл |
398→| POST | `/categories/subcategories/:id/delete` | Удаление (блок при товарах) |
399→
400→### 6.6. Locations (роут: `routes/locations.js`)
401→
402→| Метод | Путь | Назначение |
403→|---|---|---|
404→| GET | `/locations` | EJS `locations` с `category_count`, `product_count` |
405→| POST | `/locations` | + локация |
406→| POST | `/locations/:id/update` | `country, city, district` |
407→| POST | `/locations/:id/toggle` | Вкл/выкл |
408→| POST | `/locations/:id/delete` | Блок при категориях/товарах |
409→
410→### 6.7. Users (роут: `routes/users.js`)
411→
412→| Метод | Путь | Назначение |
413→|---|---|---|
414→| GET | `/users` | EJS `users` (последние 100, `ORDER BY id DESC`) |
415→| GET | `/users/:id` | EJS `user-detail` + последние 20 покупок с `product_name` |
416→| POST | `/users/:id/toggle-status` | Бан/разбан (`status` 0↔2) |
417→| POST | `/users/:id/adjust-balance` | `amount, currency` (`total_balance`|`bonus_balance`); пишет audit `balance_adjust`; редирект на `/users/:id` |
418→
419→### 6.8. Wallets (роут: `routes/wallets.js`) 🔓 для seed-части
420→
421→| Метод | Путь | Назначение |
422→|---|---|---|
423→| GET | `/wallets` | EJS `wallets`; query `?user=`, `?seeds=1`; полный срез: `users` (с `wallet_count`), `wallets` выбранного юзера, `stats` (см. ниже) |
424→| POST | `/wallets/record-payment` | `paid_amount, note` → запись в `commission_payments`; редирект `/wallets?payment=recorded` |
425→| POST | `/wallets/export-seeds` 🔓 | CSV всех кошельков с расшифрованными seed-фразами; audit `csv_seed_export` |
426→| GET | `/wallets/refresh-balances/:userId` | **AJAX**: обновить балансы с блокчейна; ответ JSON см. ниже |
427→| GET | `/wallets/seed/:walletId` 🔓 | **AJAX**: расшифрованная seed-фраза + derivation; audit `seed_phrase_viewed` |
428→| GET | `/wallets/seed-qr/:walletId` 🔓 | **AJAX**: PNG QR-код seed-фразы (image/png); audit `seed_phrase_qr_viewed` |
429→
430→**Формат `stats`** (передаётся в шаблон wallets):
431→
432→| Поле | Тип | Описание |
433→|---|---|---|
434→| `totals` | `{btc,ltc,eth,usdt,usdc}` | Суммы балансов по валютам |
435→| `walletCounts` | `{...}` | Кол-во кошельков по валютам |
436→| `usdValues` | `{...}` | USD-оценка по валютам (курсы с CoinGecko) |
437→| `totalUsd` | float | Итог в USD |
438→| `prices` | `{btc,ltc,eth}` | Текущие курсы |
439→| `totalWallets` | int | Активных кошельков |
440→| `commissionRate` | float | % |
441→| `currentCommission` | float | Ставка × totalUsd |
442→| `lastPaidAmount` | float | Последняя оплата |
443→| `commissionDue` | float | max(0, current last) |
444→| `commissionEnabled` | bool | |
445→| `commissionWallets` | `{BTC,LTC,USDT,USDC,ETH}` | Адреса для оплаты комиссии |
446→| `totalUsers` | int | |
447→| `payments` | `commission_payments[]` | Последние 20 платежей |
448→| `seedsPaid` | bool | lastPaid ≥ current |
449→
450→**Формат `refresh-balances` (JSON)**:
451→
452→```json
453→{
454→ "wallets": [{ "id", "wallet_type", "address", "balance", "usdValue", "created_at" }],
455→ "totalBalance": 123.45,
456→ "balances": { "BTC": { "amount", "usdValue" }, ... },
457→ "timestamp": "ISO",
458→ "error": "Blockchain API unavailable — showing cached balances" // опционально
459→}
460→```
461→
462→**Формат `seed/:walletId` (JSON)**: `{ walletId, walletType, address, derivationPath, mnemonic, userId, username }` (404 `{error}` при отсутствии).
463→
464→**Логика разблокировки seed**: кнопка «Unlock» доступна, только если `seedsPaid` (комиссия оплачена). Параметр `?seeds=1` открывает таблицу всех seed-фраз.
465→
466→### 6.9. Purchases (роут: `routes/purchases.js`)
467→
468→| Метод | Путь | Назначение |
469→|---|---|---|
470→| GET | `/purchases` | EJS `purchases` (последние 200, JOIN product_name) |
471→
472→### 6.10. Audit (роут: `routes/audit.js`)
473→
474→| Метод | Путь | Назначение |
475→|---|---|---|
476→| GET | `/audit` | EJS `audit` (последние 200 записей `audit_log`) |
477→
478→### 6.11. Settings (роут: `routes/settings.js`)
479→
480→| Метод | Путь | Назначение |
481→|---|---|---|
482→| GET | `/settings` | EJS `settings`; флеши `?saved=1`, `?error=1` |
483→| POST | `/settings` | Обновление `.env` |
484→
485→**Редактируемые ключи** (только они; остальные строки `.env` не трогаются):
486→
487→`BOT_TOKEN, SUPPORT_LINK, ADMIN_IDS, SUPER_ADMIN_IDS, WG_ENABLED, WG_ENDPOINT, WG_ADDRESS, WG_PUBLIC_KEY, WG_DNS, ADMIN_PORT, ADMIN_URL, CATALOG_PATH, GITEA_API_URL`
488→
489→**Никогда не перезаписываются** (показываются как `•••••••`): `ENCRYPTION_KEY, ADMIN_SECRET, GITEA_TOKEN, WG_PRIVATE_KEY, WG_PRESHARED_KEY, COMMISSION_*`.
490→
491→⚠ Изменения применяются только **после рестарта контейнера** (сообщение об этом на странице).
492→
493→### 6.12. Payment Wallets (роут: `routes/paymentWallets.js`)
494→
495→| Метод | Путь | Назначение |
496→|---|---|---|
497→| GET | `/payment-wallets` | EJS `payment-wallets` — **только чтение** конфига `COMMISSION_WALLETS` (адреса для уплаты комиссии владельцу) |
498→
499→### 6.13. Seed & Reset (роут: `routes/seed.js`) ⚠️ requireReAuth
500→
501→| Метод | Путь | Назначение |
502→|---|---|---|
503→| GET | `/seed` | EJS `seed` (форма с подтверждением токеном) |
504→| POST | `/seed/seed-demo` | **Полная очистка БД + заливка демо-данных**. Body: `reauth_token` |
505→| POST | `/seed/clear-all` | **Полная очистка всех таблиц**. Body: `reauth_token` |
506→
507→Очищаемые таблицы: `purchases, transactions, crypto_wallets, audit_log, user_states, products, subcategories, categories, users, locations, commission_payments` + сброс `sqlite_sequence`.
508→
509→### 6.14. Locales (роут: `routes/locales.js`)
510→
511→| Метод | Путь | Назначение |
512→|---|---|---|
513→| GET | `/locales` | EJS `locales` + `sections` (ключи верхнего уровня из `en.json`) |
514→| POST | `/locales/save` | JSON `{lang, key, value}` (точечный путь, напр. `menu.shop`); запись в `src/i18n/locales/{lang}.json`; 400 при неверном `lang` |
515→
516→Языки: `en`, `es`, `de` (`AVAILABLE_LANGUAGES`). Структура файлов — вложенные объекты; ключ в запросе — dot-path.
517→
518→---
519→
520→## 7. Спецификация экранов
521→
522→### 7.1. Login (`/login`)
523→- Одно поле `token` (пароль-токен), кнопка Submit.
524→- Ошибки: `401 Invalid token`, `429 Too many attempts...` (15 мин).
525→- После успеха — redirect `/`.
526→- Подсказка: если `SUPER_ADMIN_SECRET` задан — вход с ним даёт роль super_admin.
527→
528→### 7.2. Dashboard (`/`)
529→KPI-карточки:
530→- Пользователи всего, Товары, Покупки всего, Выручка ($, completed), Средний чек ($), Конверсия (%), Сплит по статусам (completed/pending/cancelled), Подкатегории.
531→
532→Графики (данные готовы в `chartData`):
533→1. Выручка 7 дней (line/area) — `days` × `revenueData`.
534→2. Выручка 30 дней (line/area) — `days30` × `revenueData30`.
535→3. Новые пользователи 7 дней (bar) — `days` × `usersData`.
536→4. Топ-5 товаров (bar) — `topProducts`.
537→5. Топ-5 покупателей (bar) — `topSpenders`.
538→6. Выручка по категориям (pie/donut) — `revenueByCategory`.
539→7. Топ-5 стран (bar) — `topCountries`.
540→
541→Блоки: Активность (последние покупки/аудит), Сводка кошельков по валютам, SaaS-комиссия (enabled/rate/due/последняя оплата).
542→
543→### 7.3. Catalog (`/catalog`) — самый сложный экран
544→Две колонки:
545→- **Слева — дерево**: accordion Страна → Город → Район → Категория → Подкатегория. У каждого узла:
546→ - счётчик товаров (включая подкатегории),
547→ - бейдж «Disabled» у неактивных,
548→ - inline-формы: переименовать, вкл/выкл (⏻), добавить ребёнка, удалить (✕, с confirm).
549→- **Справа — таблица товаров** (до 200): ID, фото (40×40), название, категория, подкатегория, цена $, остаток (∞ для mono), действия: ✎ Edit (модалка), ✕ Delete (confirm).
550→- Фильтры через клик по узлам дерева (`?loc=`, `?cat=`, `?sub=`).
551→- **Модалка товара** (Add/Edit): каскадные селекты Страна→Город→Район→(Категория фильтруется по району)→Подкатегория; inline-создание категории/подкатегории через `fetch` к `/json`-эндпоинтам; чекбокс «Digital Product (infinite stock)» блокирует поле остатка; загрузка 2 фото (public, hidden) или URL; поля скрытого контента (фото, координаты, описание) и private_data.
552→- Флеш-сообщения: `?msg=` + `?msg_type=` (success/error/info).
553→
554→### 7.4. Products (`/products`) — legacy
555→Простая таблица (ID, фото, название, категория, цена, остаток, действия) + формы Add/Edit на отдельных страницах. **Фронту: экран можно заменить ссылкой на Catalog.**
556→
557→### 7.5. Categories (`/categories`)
558→Плоские таблицы: категории (с локацией и product_count) + подкатегории (сгруппированы по категориям). Действия: add/edit/toggle/delete. Ошибки через `?error=` (с счётчиком блокирующих товаров), успех через `?success=`.
559→
560→### 7.6. Locations (`/locations`)
561→Таблица: Страна/Город/Район, category_count, product_count, is_active, действия add/edit/toggle/delete. Ошибки/успехи как у Categories. Заблокированное удаление — с подсказкой «Remove categories/products first».
562→
563→### 7.7. Users (`/users`, `/users/:id`)
564→- Список (100): ID, Telegram ID, username, страна, город, статус (badge Active/Blocked/Deleted), баланс $, кнопки View и Ban/Unban.
565→- Детальная (`/users/:id`): профиль + таблица покупок (20) + **форма корректировки баланса** (amount + выбор `total_balance`/`bonus_balance`) + audit-запись.
566→
567→### 7.8. Wallets (`/wallets`)
568→- Слева: поиск + список пользователей (статус, число кошельков), выбор `?user=`.
569→- Справа: карточка балансов выбранного (Main/Bonus/Available/Status), таблица кошельков (тип, адрес — клик=копировать, баланс 8 знаков, дата), кнопка «Refresh balances» (fetch `/wallets/refresh-balances/:userId`).
570→- **Owner Summary** (для всех): Total USD, Users, Active Wallets, Commission Due; таблица «Balances by Currency»; блок комиссии: rate, total, full commission, last paid, due, форма «Record Payment» (paid_amount, note), адреса для оплаты (клик=копировать); таблица Payment History (delta между платежами).
571→- **Seed Phrases** (super_admin, если комиссия оплачена): кнопка «Unlock», таблица всех seed-фраз (user, type, address, derivation, mnemonic — клик=копировать), кнопка «Export All Seeds as CSV» (`/wallets/export-seeds`). Если не оплачено — предупреждение и заблокированная кнопка.
572→
573→### 7.9. Purchases (`/purchases`)
574→Таблица (200): ID, user (ссылка), product, qty, цена, валюта, дата, статус (badge completed/pending/failed). **Нет фильтров и действий — только просмотр.**
575→
576→### 7.10. Audit (`/audit`)
577→Таблица (200): action, admin_id, details (JSON-строка), created_at. Только просмотр.
578→
579→### 7.11. Settings (`/settings`)
580→Форма с секциями:
581→- Bot: `BOT_TOKEN` (placeholder-маска), `SUPPORT_LINK`, `ADMIN_IDS`, `SUPER_ADMIN_IDS`.
582→- WireGuard: `WG_ENABLED` (checkbox), `WG_ENDPOINT`, `WG_ADDRESS`, `WG_PUBLIC_KEY`, `WG_DNS`.
583→- Admin: `ADMIN_PORT`, `ADMIN_URL`, `CATALOG_PATH`, `GITEA_API_URL`.
584→- Только для чтения (маскированные `•••••••`): `ENCRYPTION_KEY`, `ADMIN_SECRET`, `GITEA_TOKEN`, `WG_PRIVATE_KEY`, `WG_PRESHARED_KEY`, `COMMISSION_*`.
585→- Предупреждение: «Restart the application to apply changes».
586→
587→### 7.12. Payment Wallets (`/payment-wallets`)
588→Таблица адресов комиссии (BTC/LTC/USDT/USDC/ETH) — только чтение; ссылка на Settings.
589→
590→### 7.13. Locales (`/locales`)
591→- Таблица: строки = ключи из `en.json` (секции-заголовки), колонки = языки en/es/de.
592→- Редактирование значения: inline (fetch POST `/locales/save` `{lang, key, value}`).
593→- Требование: 3 языка всегда синхронны по набору ключей; добавление ключа в одном — добавить в остальных (200+ ключей).
594→
595→### 7.14. Seed & Reset (`/seed`)
596→Две опасные кнопки: «Seed Demo Data» и «Clear All Data». Обе требуют повторный ввод токена (`reauth_token`). В UI — двойное подтверждение (модалка + поле токена), предупреждение о необратимости.
597→
598→---
599→
600→## 8. Роли и права
601→
602→| Возможность | admin | super_admin |
603→|---|---|---|
604→| Все страницы, кроме seed-выгрузки | ✅ | ✅ |
605→| Просмотр/экспорт seed-фраз (`/wallets/seed*`, `export-seeds`) | ❌ (401/403) | ✅ |
606→| Запись комиссионных платежей | ✅ (форма на /wallets) | ✅ |
607→| Seed & Reset (`/seed/*`) | ✅ (с reauth) | ✅ (с reauth) |
608→
609→Middleware: `requireAuth` (все роуты после `/login`), `requireSuperAuth` (seed-эндпоинты), `requireReAuth` (seed/reset).
610→
611→---
612→
613→## 9. Формат данных
614→
615→- Деньги: USD, `REAL`. Выводить `$X.XX`.
616→- Крипто-балансы: до 8 знаков, обрезать хвостовые нули.
617→- Даты: `DATETIME` SQLite (`YYYY-MM-DD HH:MM:SS`, UTC). На клиенте конвертировать в локальное время.
618→- Фото: абсолютный путь (например `/uploads/...`) или внешний URL. В таблицах — превью 4050px.
619→- Status пользователя: `0` активен, `2` заблокирован, иное — удалён.
620→- Status покупки: `pending` | `completed` | `cancelled`.
621→- `is_mono`: `1` = цифровой (бесконечный остаток, показывать ∞).
622→- Флеш-сообщения: через query-параметры (`?msg=`, `?error=`, `?success=`, `?seeded=1`, `?cleared=1`, `?saved=1`, `?payment=recorded`).
623→
624→---
625→
626→## 10. Существующие ограничения и подводные камни
627→
628→1. **Нет REST API.** Всё — SSR EJS + формы + редкие fetch. Для полноценного SPA бэкенду нужны JSON-эндпоинты (предложены в §12) — **отдел фронтенда не должен проектировать свой API без согласования с бэкендом**.
629→2. **CSRF отключён** (заглушка). Новая админка не должна полагаться на Origin/SameSite; если оставляем формы — обязательны серверные токены или хотя бы подтверждения на деструктив.
630→3. **Работа через Tor**: нельзя полагаться на внешние CDN (Bootstrap/FontAwesome/ApexCharts должны быть локальными), нельзя требовать HTTPS/WSS, куки `Secure` не работают.
631→4. **Нет пагинации** нигде (LIMIT 100/200 фиксирован). При росте данных списки «режутся» — фронт может предложить пагинацию, но это потребует новых query-параметров на бэкенде.
632→5. **Дублирование каталога**: `/catalog` (богатый) и `/products` (бедный) — менять оба или убрать один (решение за продуктом).
633→6. **Фото из uploads отдаются с `Content-Disposition: attachment`** — их нельзя показывать `<img>` inline из `/uploads/` (скачаются как файл). В таблицах товаров фото берутся из `photo_url`, который может указывать на внешний URL или `/uploads/...` — при `/uploads` превью не отобразится в браузере. Для галереи товаров нужно либо отдельный inline-эндпоинт, либо хранить публичные фото вне uploads (например, статика `public/img/products/`).
634→7. **Нет нотификаций/вебхуков** — dashboard статичен до перезагрузки.
635→8. **Settings перезаписывают `.env`** — изменения требуют рестарта; форма должна явно это сообщать.
636→9. **Seed/Reset опасны** — полная очистка БД; в новом UI обязателен confirm + reauth (уже реализовано на бэкенде).
637→10. **i18n-файлы — живые данные** админки; редактирование без синхронизации ключей ломает бота (fallback на `en`).
638→11. Legacy JS-слой (`views/*.js`) частично дублирует EJS — **не использовать как источник истины**, ссылаться на роуты и EJS.
639→
640→---
641→
642→## 11. Требования к новому фронтенду
643→
644→### 11.1. Целевой стек (рекомендация)
645→- SPA на Vue 3 или React (или продолжение SSR EJS, если команда не готова к SPA).
646→- Все ассеты **локальные** (сборка в `public/dist`), без CDN — совместимость с Tor.
647→- Адаптивность: админкой пользуются с десктопа и планшета.
648→- Тёмная/светлая тема (SmartAdmin уже имеет темы — можно переиспользовать SCSS-переменные).
649→- Charts: ApexCharts (уже есть в проекте) или ECharts.
650→
651→### 11.2. Обязательные экраны (приоритет)
652→1. **Dashboard** — KPI + 7 графиков (данные в §6.2).
653→2. **Catalog** — дерево + таблица товаров + модалка товара с каскадными селектами и загрузкой 2 фото.
654→3. **Users + User detail** — список, бан/разбан, корректировка баланса.
655→4. **Wallets** — пользователь + кошельки + Owner Summary + комиссия + seed (super_admin).
656→5. **Purchases** — таблица со статусами.
657→6. **Audit** — журнал.
658→7. **Categories / Locations** — CRUD + toggle.
659→8. **Settings** — форма .env с масками секретов.
660→9. **Locales** — редактор переводов на 3 языка.
661→10. **Seed & Reset** — подтверждённая деструктивная панель.
662→11. **Login / Logout**.
663→
664→### 11.3. UI/UX-требования
665→- Все деструктивные действия — confirm-модалка (не `window.confirm`).
666→- Все флеш-сообщения — toast/alert с автоскрытием.
667→- Каскадные select'ы — с загрузкой и disabled-состояниями.
668→- Копирование адресов/seed — по клику с визуальной обратной связью «Copied!».
669→- Пустые состояния («No products found», «No users», «No wallets yet»).
670→- Загрузочные спиннеры на fetch-запросах.
671→- Ошибки API (400/401/403/404/500) — понятные сообщения пользователю.
672→- Валидация форм на клиенте (price>0, name required, photo required, amount>0) + серверная.
673→- Хлебные крошки, поиск по меню (уже есть в SmartAdmin).
674→- Индикация роли (admin/super_admin) в шапке; скрывать seed-раздел для обычных админов.
675→
676→### 11.4. Интеграционные требования
677→- Авторизация: работа с кукой `admin_token`; при 401/403 от `/wallets/seed*` — редирект на `/login` или сообщение о недостатке прав.
678→- Все POST-формы в SPA отправлять с `Content-Type: application/x-www-form-urlencoded` или `multipart/form-data` в зависимости от бэкенд-роута (см. §6).
679→- Не вводить собственный роутинг API-путей — строго использовать карту §6 (или согласованные новые из §12).
680→- Реализовать обработку флеш-query-параметров на клиенте.
681→
682→### 11.5. Что НЕ делать
683→- Не разворачивать отдельный фронтенд-сервер (админка живёт в контейнере бота; статика отдаётся из `src/admin/public`).
684→- Не менять схему БД без миграций и без бэкенда.
685→- Не показывать seed-фразы не-super_admin.
686→- Не загружать фото в произвольные пути — только через существующий multipart-роут `/catalog/products` (+ file input) или `/products`.
687→
688→---
689→
690→## 12. API-контракты JSON (дополнить бэкенду)
691→
692→Для SPA-режима предлагаются **новые** JSON-эндпоинты (требуют реализации на бэкенде — вне зоны ответственности фронта, но контракты фиксируем заранее). Все — под `requireAuth`, префикс `/api`.
693→
694→| Метод | Путь | Ответ |
695→|---|---|---|
696→| GET | `/api/stats` | Агрегат дашборда (поля из §6.2) |
697→| GET | `/api/users?search=&page=&limit=` | Пагинированный список пользователей |
698→| GET | `/api/users/:id` | Профиль + балансы |
699→| POST | `/api/users/:id/toggle-status` | `{ok:true}` |
700→| POST | `/api/users/:id/adjust-balance` | body `{amount, currency}` → `{ok:true, newBalance}` |
701→| GET | `/api/catalog/tree` | Дерево (страна→город→район→категория→подкатегория) с is_active и счётчиками |
702→| GET | `/api/products?loc=&cat=&sub=&page=&limit=` | Пагинированный список товаров |
703→| GET | `/api/products/:id` | Товар целиком (вкл. скрытый контент и private_data) |
704→| POST | `/api/products` | Создание (JSON или multipart) |
705→| PUT | `/api/products/:id` | Обновление |
706→| DELETE | `/api/products/:id` | Удаление |
707→| GET | `/api/locations` | Список с counts |
708→| POST/PUT/DELETE | `/api/locations...` | CRUD |
709→| GET | `/api/categories` / `/api/subcategories` | Списки с counts |
710→| POST/PUT/DELETE | `/api/categories...` | CRUD |
711→| GET | `/api/purchases?status=&page=&limit=` | Пагинированные покупки с фильтром по статусу |
712→| PATCH | `/api/purchases/:id/status` | Смена статуса (требует согласования с ботом — статусы влияют на доставку контента!) |
713→| GET | `/api/wallets/overview` | Owner summary + комиссия + платежи |
714→| GET | `/api/wallets?userId=` | Кошельки пользователя |
715→| POST | `/api/wallets/refresh` | body `{userId}` → свежие балансы |
716→| POST | `/api/wallets/record-payment` | body `{paidAmount, note}` |
717→| GET | `/api/wallets/seeds` 🔓 | Список seed-фраз |
718→| GET | `/api/wallets/seeds/:id` 🔓 | Одна seed-фраза |
719→| GET | `/api/wallets/seeds/:id/qr` 🔓 | PNG QR |
720→| GET | `/api/audit?page=&limit=` | Пагинированный аудит |
721→| GET/POST | `/api/locales` | Чтение/сохранение ключей (как `/locales/save`) |
722→| GET/PUT | `/api/settings` | Чтение маскированных / запись разрешённых ключей |
723→| POST | `/api/seed/demo`, `/api/seed/clear` ⚠️ | с `reauth_token` |
724→| GET | `/api/session` | Текущая роль (admin/super_admin) для условного рендера |
725→
726→⚠ **Важно**: PATCH статуса покупки должен быть синхронизирован с `purchaseService` (доставка скрытого контента происходит при `completed`).
727→
728→---
729→
730→## 13. Чек-лист приёмки
731→
732→- [ ] Логин/логаут работают; роль super_admin корректно отображается.
733→- [ ] Dashboard: все KPI и 7 графиков рендерятся без ошибок.
734→- [ ] Catalog: дерево полностью управляемо (add/rename/toggle/delete на всех 4 уровнях), товары CRUD с загрузкой 2 фото, каскадные селекты, mono-флаг.
735→- [ ] Users: список, детальная, бан/разбан, корректировка баланса с audit.
736→- [ ] Wallets: выбор пользователя, обновление балансов (fetch), Owner Summary, запись комиссии, история платежей, seed-блок с правами и QR/CSV.
737→- [ ] Purchases: таблица со статусами и ссылками на пользователей.
738→- [ ] Audit: журнал читается, details парсится.
739→- [ ] Categories/Locations: CRUD с блокировками удаления и понятными ошибками.
740→- [ ] Settings: сохраняются только разрешённые ключи; секреты маскированы.
741→- [ ] Locales: inline-редактирование всех 3 языков, dot-path ключей.
742→- [ ] Seed & Reset: двойное подтверждение, reauth-токен, предупреждения.
743→- [ ] Все деструктивные действия с confirm; все флеши; все пустые состояния.
744→- [ ] Работает через Tor (без CDN-зависимостей), тёмная тема.
745→- [ ] Нет утечек seed/private_data/масок в DOM для не-super_admin.
746→- [ ] Нет XSS: все данные экранированы на клиенте (в т.ч. из `JSON` в `<script>`).
747→
748→---
749→
750→*Документ подготовлен на основе фактического кода: `src/admin/server.js`, `src/admin/routes/*.js`, `src/admin/auth.js`, `src/admin/views/*.ejs`, `src/migrations/*.js`, `src/config/*.js`, `src/services/*.js`. При изменении бэкенда — перегенерировать разделы 67.*
751→

View File

@@ -1,751 +0,0 @@
1→ 1→# Админ-панель Telegram Shop — Техническое описание фронтенда и API
2→ 2→
3→ 3→> **Назначение документа**: комплексное ТЗ для отдела фронтенда по реализации полноценного админ-кабинета поверх существующего бэкенда.
4→ 4→> **Версия бэкенда**: v1.2.4 (2026-08-05)
5→ 5→> **Дата**: 2026-08-05
6→ 6→> **Статус**: актуально на момент передачи
7→ 7→
8→ 8→---
9→ 9→
10→ 10→## Оглавление
11→ 11→
12→ 12→1. [Обзор системы](#1-обзор-системы)
13→ 13→2. [Технологический стек](#2-технологический-стек)
14→ 14→3. [Архитектура развёртывания](#3-архитектура-развёртывания)
15→ 15→4. [Доступ и безопасность](#4-доступ-и-безопасность)
16→ 16→5. [Схема базы данных](#5-схема-базы-данных)
17→ 17→6. [Карта эндпоинтов (полная)](#6-карта-эндпоинтов-полная)
18→ 18→7. [Спецификация экранов](#7-спецификация-экранов)
19→ 19→8. [Роли и права](#8-роли-и-права)
20→ 20→9. [Формат данных](#9-формат-данных)
21→ 21→10. [Существующие ограничения и подводные камни](#10-существующие-ограничения-и-подводные-камни)
22→ 22→11. [Требования к новому фронтенду](#11-требования-к-новому-фронтенду)
23→ 23→12. [API-контракты JSON (дополнить бэкенду)](#12-api-контракты-json-дополнить-бэкенду)
24→ 24→13. [Чек-лист приёмки](#13-чек-лист-приёмки)
25→ 25→
26→ 26→---
27→ 27→
28→ 28→## 1. Обзор системы
29→ 29→
30→ 30→Telegram Shop — это **Telegram-бот-магазин** (Node.js) с **встроенной админ-панелью** на Express + EJS (шаблон SmartAdmin). Продажи идут через Telegram-бота, админка управляет всем магазином через браузер.
31→ 31→
32→ 32→Бизнес-сущности:
33→ 33→
34→ 34→| Сущность | Описание |
35→ 35→|---|---|
36→ 36→| **Users** | Покупатели из Telegram (id, ник, локация, статус, балансы, язык) |
37→ 37→| **Locations** | Гео-иерархия: Страна → Город → Район (округа) |
38→ 38→| **Categories / Subcategories** | Категории товаров, привязанные к локации; подкатегории внутри категории |
39→ 39→| **Products** | Товары: цена, остаток, публичное фото, скрытый контент (фото/координаты/описание), private-заметки, цифровые (mono) |
40→ 40→| **Purchases** | Заказы: пользователь, товар, валюта, tx_hash, количество, сумма, статус |
41→ 41→| **Crypto Wallets** | Криптокошельки пользователей (BTC/LTC/ETH/USDT/USDC), с зашифрованными seed-фразами |
42→ 42→| **Transactions** | Ончейн-транзакции пользователей |
43→ 43→| **Commission payments** | Платежи комиссии владельцу платформы (SaaS-модель) |
44→ 44→| **Audit log** | Журнал действий админов |
45→ 45→| **Locales (i18n)** | JSON-файлы переводов бота (en/es/de), редактируются из админки |
46→ 46→
47→ 47→**Ключевой факт**: бот и админка живут в **одном процессе** и используют **одну SQLite-базу** (`db/shop.db`) — поэтому любые изменения в админке мгновенно видны боту и наоборот. Отдельного REST API **нет** — админка рендерит EJS на сервере и ходит в БД напрямую.
48→ 48→
49→ 49→---
50→ 50→
51→ 51→## 2. Технологический стек
52→ 52→
53→ 53→### Текущий (серверная часть — НЕ трогать без отдельной задачи)
54→ 54→
55→ 55→| Слой | Технология |
56→ 56→|---|---|
57→ 57→| Язык | Node.js ≥ 18 (ES Modules, `"type": "module"`) |
58→ 58→| Web-фреймворк | Express 4 |
59→ 59→| Шаблонизация | EJS + `express-ejs-layouts` (layout: `views/layout.ejs`) |
60→ 60→| Шаблон UI | SmartAdmin (собственные партиалы в `views/partials/`, SCSS в `public/sass/`, сборка `smartapp.min.css`) |
61→ 61→| БД | SQLite через `better-sqlite3` (обёртка `db.runAsync/allAsync/getAsync` в `src/config/database.js`) |
62→ 62→| Загрузка файлов | Multer (только картинки jpeg/png/webp/gif, ≤ 10 МБ) в `uploads/` |
63→ 63→| QR | `qrcode` (для seed-фраз) |
64→ 64→| CDN-ресурсы | Bootstrap 5.3.2, FontAwesome 6.4.2 с jsDelivr (только в legacy-`layout.js`) |
65→ 65→
66→ 66→### Текущий фронтенд (что есть)
67→ 67→
68→ 68→- **Два параллельных набора вьюх**:
69→ 69→ 1. **EJS-шаблоны** (`views/*.ejs`) — используются роутами (dashboard, users, wallets, purchases, audit, settings, categories, locations, payment-wallets, seed, locales, catalog, products, product-edit, user-detail).
70→ 70→ 2. **JS-рендер-функции** (`views/*.js`) — legacy/дублирующий слой, возвращающий HTML-строки (`layout()`, `renderCatalog()`, `renderWalletLayout()` и т.д.). **На практике роуты рендерят EJS; JS-слой частично устарел** (дублирует catalog и wallets). Новый фронтенд их не использует.
71→ 71→- Статика: Bootstrap 5 (SmartAdmin-тема), ApexCharts, jQuery, FontAwesome, собственные скрипты `public/scripts/`.
72→ 72→- AJAX-вызовы есть только в нескольких местах (catalog-модалка, wallets-обновление балансов, locales).
73→ 73→
74→ 74→---
75→ 75→
76→ 76→## 3. Архитектура развёртывания
77→ 77→
78→ 78→```
79→ 79→Internet
80→ 80→ │
81→ 81→ ├── HTTPS/LAN ───────────────► telegram_shop_prod :3001 (Express: бот + админка)
82→ 82→ │
83→ 83→ └── Tor Network ──► tor-proxy ──► HiddenService :80 ──► telegram_shop_prod :3001
84→ 84→ (admin onion-адрес, работа без HTTPS)
85→ 85→```
86→ 86→
87→ 87→- Единственный контейнер `telegram_shop_prod` (node:22-alpine) слушает порт `3001` (`ADMIN_PORT`).
88→ 88→- Доступ к админке: напрямую `http://host:3001` или через `.onion` (Tor Browser).
89→ 89→- Volume: `db/` (SQLite), `uploads/` (фото товаров, отдаются через `/uploads`), `.env` (только чтение).
90→ 90→- WireGuard опционален (`WG_ENABLED`).
91→ 91→
92→ 92→**Следствие для фронтенда**: админка доступна по **HTTP без HTTPS** (в т.ч. через Tor). Это накладывает жёсткие ограничения:
93→ 93→- нельзя использовать Secure-куки;
94→ 94→- нельзя полагаться на Origin-проверку (Tor) — CSRF реализован вручную (см. §10);
95→ 95→- весь JS/CSS должен быть либо локальным, либо закешированным (CDN может быть недоступен из Tor).
96→ 96→
97→ 97→---
98→ 98→
99→ 99→## 4. Доступ и безопасность
100→ 100→
101→ 101→### 4.1. Аутентификация (важно для фронта)
102→ 102→
103→ 103→Реализована в `src/admin/auth.js`. **Не JWT, а самописный HMAC-токен**:
104→ 104→
105→ 105→| Параметр | Значение |
106→ 106→|---|---|
107→ 107→| Кука | `admin_token` (httpOnly, `sameSite=false`, maxAge 24ч) |
108→ 108→| Формат токена | `base64(payload).hmac_sha256(payload)` |
109→ 109→| Payload | `{ role, jti, iat, exp }` |
110→ 110→| Секрет | `ADMIN_SECRET` (env) |
111→ 111→| Вход | POST `/login` с полем `token` (пароль = админ-токен) |
112→ 112→| Выход | GET `/logout` |
113→ 113→
114→ 114→Роли (определяются **каким секретом залогинились**):
115→ 115→
116→ 116→| Роль | Условие | Права |
117→ 117→|---|---|---|
118→ 118→| `admin` | токен = `ADMIN_SECRET` | Все страницы, кроме seed-выгрузки |
119→ 119→| `super_admin` | токен = `SUPER_ADMIN_SECRET` (если задан и ≠ ADMIN_SECRET) | + просмотр/экспорт seed-фраз, комиссии |
120→ 120→
121→ 121→При `SUPER_ADMIN_SECRET` не заданном — **все админы считаются супер-админами** (`config.SUPER_ADMIN_IDS` по умолчанию = `ADMIN_IDS`, но роли в админке задаются именно через секреты, не через `ADMIN_IDS`).
122→ 122→
123→ 123→Rate-limit логина: 5 попыток / 15 минут с IP.
124→ 124→
125→ 125→**Деструктивные действия** (`/seed/*`) требуют повторного ввода токена в поле `reauth_token` (middleware `requireReAuth`).
126→ 126→
127→ 127→### 4.2. Защита статики и загрузок
128→ 128→
129→ 129→- `/uploads/*` — только после `requireAuth`, заголовки `X-Content-Type-Options: nosniff` + `Content-Disposition: attachment` (фото не рендерятся inline из uploads, только скачивание).
130→ 130→- Файлы uploads именуются `{timestamp}-{hex}.{ext}`, MIME-белый список.
131→ 131→- CSRF: заглушка (`csrf.js` возвращает пустой токен и no-op) — **сознательно отключено ради совместимости с Tor**. См. §10.
132→ 132→
133→ 133→### 4.3. Существующие уязвимости, которые фронтенд не должен усугублять
134→ 134→
135→ 135→1. Нет реальной CSRF-защиты — все POST-формы уязвимы к cross-site-запросам. В новом фронте использовать токены, если бэкенд их вернёт, иначе как минимум не убирать подтверждения на деструктив.
136→ 136→2. Некоторые вьюхи выводят данные с ручным `esc()`/`escapeHtml()`; часть EJS использует `<%= %>` (экранируется) — но есть места с `JSON.stringify` прямо в `<script>` (инъекция через данные). Новому фронту: **все данные из API экранировать на клиенте**.
137→ 137→3. Пароль админа (ADMIN_SECRET) отправляется как обычное поле формы.
138→ 138→4. Seed-фразы — сверхчувствительные данные; показывать только super_admin, с аудитом (logAudit: `seed_phrase_viewed`, `seed_phrase_qr_viewed`, `csv_seed_export`), желательно с подтверждением.
139→ 139→
140→ 140→---
141→ 141→
142→ 142→## 5. Схема базы данных
143→ 143→
144→ 144→Источник истины: `src/migrations/*.js` (001012). SQLite, FK включены, WAL-режим.
145→ 145→
146→ 146→### 5.1. `users`
147→ 147→
148→ 148→| Колонка | Тип | Описание |
149→ 149→|---|---|---|
150→ 150→| `id` | INTEGER PK AUTOINCREMENT | Внутренний ID |
151→ 151→| `telegram_id` | TEXT UNIQUE NOT NULL | ID в Telegram (строка!) |
152→ 152→| `username` | TEXT | Ник |
153→ 153→| `country` / `city` / `district` | TEXT | Гео из бота (снимок) |
154→ 154→| `status` | INTEGER DEFAULT 0 | `0`=активен, `2`=заблокирован, прочее=удалён |
155→ 155→| `total_balance` | REAL DEFAULT 0 | Основной баланс в USD |
156→ 156→| `bonus_balance` | REAL DEFAULT 0 | Бонусный баланс в USD |
157→ 157→| `language` | TEXT DEFAULT 'en' | Язык бота (`en`/`es`/`de`) |
158→ 158→| `language_set` | INTEGER DEFAULT 0 | Флаг выбора языка |
159→ 159→| `created_at` | DATETIME | Регистрация |
160→ 160→
161→ 161→### 5.2. `crypto_wallets`
162→ 162→
163→ 163→| Колонка | Тип | Описание |
164→ 164→|---|---|---|
165→ 165→| `id` | INTEGER PK | |
166→ 166→| `user_id` | INTEGER FK→users (CASCADE) | |
167→ 167→| `wallet_type` | TEXT | BTC/LTC/ETH/USDT/USDC (+ архивные с суффиксом `#N`, отфильтровываются `NOT LIKE '%#_%'`) |
168→ 168→| `address` | TEXT | Адрес |
169→ 169→| `derivation_path` | TEXT | Derivation path |
170→ 170→| `mnemonic` | TEXT | **Зашифрована** (`encrypt(mnemonic, user_id)`) |
171→ 171→| `balance` | REAL DEFAULT 0 | Кэш баланса (обновляется с блокчейна) |
172→ 172→| `created_at` | DATETIME | |
173→ 173→| UNIQUE | (user_id, wallet_type) | |
174→ 174→
175→ 175→### 5.3. `transactions`
176→ 176→
177→ 177→| Колонка | Тип | Описание |
178→ 178→|---|---|---|
179→ 179→| `id` | INTEGER PK | |
180→ 180→| `user_id` | INTEGER FK | |
181→ 181→| `wallet_type` | TEXT | |
182→ 182→| `tx_hash` | TEXT | |
183→ 183→| `amount` | REAL | |
184→ 184→| `created_at` | DATETIME | |
185→ 185→
186→ 186→### 5.4. `locations`
187→ 187→
188→ 188→| Колонка | Тип | Описание |
189→ 189→|---|---|---|
190→ 190→| `id` | INTEGER PK | |
191→ 191→| `country` | TEXT NOT NULL | |
192→ 192→| `city` | TEXT NOT NULL | |
193→ 193→| `district` | TEXT NOT NULL | Район (может быть пустой строкой) |
194→ 194→| `is_active` | INTEGER NOT NULL DEFAULT 1 | Отключённые локации скрыты из бота |
195→ 195→| `created_at` | DATETIME | |
196→ 196→| UNIQUE | (country, city, district) | |
197→ 197→
198→ 198→### 5.5. `categories`
199→ 199→
200→ 200→| Колонка | Тип | Описание |
201→ 201→|---|---|---|
202→ 202→| `id` | INTEGER PK | |
203→ 203→| `location_id` | INTEGER FK→locations (CASCADE) | |
204→ 204→| `name` | TEXT NOT NULL | |
205→ 205→| `is_active` | INTEGER NOT NULL DEFAULT 1 | |
206→ 206→| `created_at` | DATETIME | |
207→ 207→| UNIQUE | (location_id, name) | |
208→ 208→
209→ 209→### 5.6. `subcategories`
210→ 210→
211→ 211→| Колонка | Тип | Описание |
212→ 212→|---|---|---|
213→ 213→| `id` | INTEGER PK | |
214→ 214→| `category_id` | INTEGER FK→categories (CASCADE) | |
215→ 215→| `name` | TEXT NOT NULL | |
216→ 216→| `is_active` | INTEGER NOT NULL DEFAULT 1 | |
217→ 217→| `created_at` | DATETIME | |
218→ 218→| UNIQUE | (category_id, name) | |
219→ 219→
220→ 220→### 5.7. `products`
221→ 221→
222→ 222→| Колонка | Тип | Описание |
223→ 223→|---|---|---|
224→ 224→| `id` | INTEGER PK | |
225→ 225→| `location_id` | INTEGER FK→locations (CASCADE) | |
226→ 226→| `category_id` | INTEGER FK→categories (CASCADE) | |
227→ 227→| `subcategory_id` | INTEGER FK→subcategories (SET NULL) | |
228→ 228→| `name` | TEXT NOT NULL | |
229→ 229→| `description` | TEXT | Публичное описание |
230→ 230→| `private_data` | TEXT | Внутренние заметки (не показывать) |
231→ 231→| `price` | REAL NOT NULL CHECK (price > 0) | USD |
232→ 232→| `quantity_in_stock` | INTEGER DEFAULT 0 | Остаток |
233→ 233→| `photo_url` | TEXT | Публичное фото (URL или `/uploads/...`) |
234→ 234→| `hidden_photo_url` | TEXT | Фото после покупки |
235→ 235→| `hidden_coordinates` | TEXT | Координаты после покупки (`lat,lng`) |
236→ 236→| `hidden_description` | TEXT | Текст после покупки |
237→ 237→| `is_mono` | INTEGER DEFAULT 0 | **Цифровой товар**: бесконечный остаток (`1` ⇒ stock=999999, в боте продаётся без складских проверок) |
238→ 238→| `created_at` | DATETIME | |
239→ 239→
240→ 240→### 5.8. `purchases`
241→ 241→
242→ 242→| Колонка | Тип | Описание |
243→ 243→|---|---|---|
244→ 244→| `id` | INTEGER PK | |
245→ 245→| `user_id` | INTEGER FK→users (CASCADE) | |
246→ 246→| `product_id` | INTEGER FK→products (CASCADE) | |
247→ 247→| `wallet_type` | TEXT | Валюта оплаты |
248→ 248→| `tx_hash` | TEXT | Хэш транзакции |
249→ 249→| `quantity` | INTEGER CHECK (quantity > 0) | |
250→ 250→| `total_price` | REAL CHECK (total_price > 0) | USD |
251→ 251→| `purchase_date` | DATETIME | |
252→ 252→| `status` | TEXT DEFAULT 'pending' | `pending` / `completed` / `cancelled` |
253→ 253→
254→ 254→### 5.9. `commission_payments`
255→ 255→
256→ 256→| Колонка | Тип | Описание |
257→ 257→|---|---|---|
258→ 258→| `id` | INTEGER PK | |
259→ 259→| `total_balance_usd` | REAL NOT NULL | Снимок баланса на момент платежа |
260→ 260→| `commission_rate` | REAL NOT NULL | % комиссии |
261→ 261→| `commission_amount_usd` | REAL NOT NULL | Начисленная комиссия |
262→ 262→| `paid_amount_usd` | REAL NOT NULL | Фактически уплачено |
263→ 263→| `wallet_count` | INTEGER NOT NULL | Кошельков на момент |
264→ 264→| `note` | TEXT | |
265→ 265→| `created_at` | DATETIME | |
266→ 266→
267→ 267→### 5.10. `audit_log`
268→ 268→
269→ 269→| Колонка | Тип | Описание |
270→ 270→|---|---|---|
271→ 271→| `id` | INTEGER PK | |
272→ 272→| `action` | TEXT NOT NULL | Слаг действия: `balance_adjust`, `csv_seed_export`, `seed_phrase_viewed`, `seed_phrase_qr_viewed`, `login` и т.д. |
273→ 273→| `admin_id` | TEXT NOT NULL | Роль или ID |
274→ 274→| `details` | TEXT | JSON-строка с контекстом |
275→ 275→| `created_at` | DATETIME | |
276→ 276→
277→ 277→### 5.11. `user_states`
278→ 278→
279→ 279→| Колонка | Тип | Описание |
280→ 280→|---|---|---|
281→ 281→| `chat_id` | TEXT PK | |
282→ 282→| `state_data` | TEXT | JSON состояния диалога бота |
283→ 283→| `updated_at` | INTEGER | |
284→ 284→
285→ 285→---
286→ 286→
287→ 287→## 6. Карта эндпоинтов (полная)
288→ 288→
289→ 289→Базовый URL: `http://<host>:3001`. **Все маршруты, кроме `/login`, `/logout`, `/health`, требуют куки `admin_token`** (redirect на `/login`).
290→ 290→
291→ 291→Легенда: 🔓 — только super_admin · ⚠️ — деструктивно, требует `requireReAuth` · 📁 — загрузка файла · 🔁 — редирект после POST.
292→ 292→
293→ 293→### 6.1. Служебные
294→ 294→
295→ 295→| Метод | Путь | Назначение |
296→ 296→|---|---|---|
297→ 297→| GET | `/health` | Health-check → `{ status:'ok', uptime }` |
298→ 298→| GET | `/login` | Страница логина (поле `token`) |
299→ 299→| POST | `/login` | Вход; body `{ token }`; 429 при rate-limit; 401 неверный токен |
300→ 300→| GET | `/logout` | Выход + инвалидация jti |
301→ 301→| GET | `/error` (рендер) | Страница ошибки (глобальный обработчик) |
302→ 302→
303→ 303→### 6.2. Dashboard (роут: `routes/dashboard.js`)
304→ 304→
305→ 305→| Метод | Путь | Ответ |
306→ 306→|---|---|---|
307→ 307→| GET | `/` | EJS `dashboard` со статами и данными графиков |
308→ 308→
309→ 309→Query-параметры-флеши: `?seeded=1`, `?cleared=1` (показывают сообщения).
310→ 310→
311→ 311→**Данные, передаваемые в шаблон** (это и есть «API» дашборда):
312→ 312→
313→ 313→| Поле | Тип | Содержимое |
314→ 314→|---|---|---|
315→ 315→| `stats.totalUsers` | int | Всего пользователей |
316→ 316→| `stats.totalProducts` | int | Всего товаров |
317→ 317→| `stats.totalPurchases` | int | Всего покупок |
318→ 318→| `stats.totalRevenue` | float | Выручка по `status='completed'` |
319→ 319→| `stats.totalSubcategories` | int | Подкатегорий |
320→ 320→| `stats.aov` | float | Средний чек (completed) |
321→ 321→| `stats.conversionRate` | float % (1 знак) | Купившие / все пользователи × 100 |
322→ 322→| `stats.completedPurchases` / `pendingPurchases` / `cancelledPurchases` | int | Сплит по статусам |
323→ 323→| `chartData.days` | string[] | Последние 7 дат `YYYY-MM-DD` |
324→ 324→| `chartData.days30` | string[] | Последние 30 дат |
325→ 325→| `chartData.revenueData` | number[] | Выручка по дням (7д) |
326→ 326→| `chartData.revenueData30` | number[] | Выручка по дням (30д) |
327→ 327→| `chartData.usersData` | number[] | Новые пользователи по дням (7д) |
328→ 328→| `chartData.topProducts` | `{name, qty, revenue}[]` | Топ-5 товаров за 30д (по кол-ву) |
329→ 329→| `chartData.topSpenders` | `{username, spent}[]` | Топ-5 покупателей за 30д |
330→ 330→| `chartData.revenueByCategory` | `{name, revenue}[]` | Выручка по категориям за 30д |
331→ 331→| `chartData.topCountries` | `{country, count}[]` | Топ-5 стран из locations |
332→ 332→| `activities` | `{type, id, username, item, total_price, date}` (или `{type:'audit', ...}`) | Последние 10 completed-покупок; если пусто — последние 10 audit_log |
333→ 333→| `wallets` | `{wallet_type, count, total}[]` | Сводка кошельков по валютам (верхний регистр) |
334→ 334→| `commission` | `{enabled, rate, due, currentCommission, lastPaidAmount, totalBalance}` | SaaS-комиссия: начислено/оплачено/к оплате |
335→ 335→
336→ 336→### 6.3. Catalog (роут: `routes/catalog.js` — дерево; `routes/catalogProducts.js` — товары)
337→ 337→
338→ 338→Дерево: Страна → Город → Район(location) → Категории → Подкатегории.
339→ 339→
340→ 340→| Метод | Путь | Body / Param | Назначение |
341→ 341→|---|---|---|---|
342→ 342→| GET | `/catalog` | query: `?loc=`, `?cat=`, `?sub=` (фильтры), `?msg=&msg_type=` (флеш) | EJS `catalog` + `treeHtml` + `products` |
343→ 343→| POST | `/catalog/locations` | `country, city, district?` | + локация |
344→ 344→| POST | `/catalog/locations/add-city` | `country, city` | + город (district='') |
345→ 345→| POST | `/catalog/locations/add-district` | `country, city, district` | + район |
346→ 346→| POST | `/catalog/locations/:id/update` | `country, city, district` | Переименование |
347→ 347→| POST | `/catalog/locations/:id/toggle` | — | Вкл/выкл `is_active` |
348→ 348→| POST | `/catalog/locations/:id/delete` | — | Удаление (запрещено при наличии категорий/товаров) |
349→ 349→| POST | `/catalog/categories` | `name, location_id` | + категория |
350→ 350→| POST | `/catalog/categories/json` | JSON `{name, location_id}` | + категория (AJAX, возвращает объект категории) |
351→ 351→| POST | `/catalog/categories/:id/update` | `name, location_id?` | Переименование/перенос |
352→ 352→| POST | `/catalog/categories/:id/toggle` | — | Вкл/выкл |
353→ 353→| POST | `/catalog/categories/:id/delete` | — | Удаление (блокируется при товарах) |
354→ 354→| POST | `/catalog/categories/:id/subcategories` | `name` | + подкатегория |
355→ 355→| POST | `/catalog/categories/:id/subcategories/json` | JSON `{name}` | + подкатегория (AJAX) |
356→ 356→| POST | `/catalog/subcategories/:id/update` | `name` | Переименование |
357→ 357→| POST | `/catalog/subcategories/:id/toggle` | — | Вкл/выкл |
358→ 358→| POST | `/catalog/subcategories/:id/delete` | — | Удаление (блокируется при товарах) |
359→ 359→| POST | `/catalog/products` | multipart: поля товара + `photo_file`, `hidden_photo_file` | + товар |
360→ 360→| POST | `/catalog/products/:id/edit` | multipart: поля товара + файлы | Обновление |
361→ 361→| POST | `/catalog/products/:id/delete` | — | Удаление товара |
362→ 362→| GET | `/catalog/products/:id/json` | — | **AJAX**: товар с `country, city, district, category_name, subcategory_name` |
363→ 363→
364→ 364→**Поля формы товара** (обязательные: `name, price>0, description, photo`):
365→ 365→
366→ 366→`name, price, quantity_in_stock, description, photo_url, hidden_photo_url, hidden_coordinates, hidden_description, private_data, category_id, subcategory_id?, location_id?, is_mono`
367→ 367→
368→ 368→Логика:
369→ 369→- `is_mono=1` ⇒ `quantity_in_stock` принудительно `999999`.
370→ 370→- Если `location_id` не передан — берётся `location_id` категории.
371→ 371→- Файл заменяет URL (`photo_url`), если загружен.
372→ 372→- JSON-ответы: `{error: '...'}` при 400.
373→ 373→
374→ 374→### 6.4. Products (роут: `routes/products.js` — упрощённый, дублирует catalog)
375→ 375→
376→ 376→| Метод | Путь | Назначение |
377→ 377→|---|---|---|
378→ 378→| GET | `/products` | EJS `products` (таблица до 100 товаров) |
379→ 379→| POST | `/products` | + товар (без файлов; `photo_url` обязателен) |
380→ 380→| GET | `/products/:id/edit` | EJS `product-edit` |
381→ 381→| POST | `/products/:id/update` | Обновление |
382→ 382→| POST | `/products/:id/delete` | Удаление |
383→ 383→
384→ 384→> **Рекомендация фронту**: считать **catalog** основным экраном товаров; `/products` — legacy-дубль.
385→ 385→
386→ 386→### 6.5. Categories (роут: `routes/categories.js` — плоские таблицы)
387→ 387→
388→ 388→| Метод | Путь | Назначение |
389→ 389→|---|---|---|
390→ 390→| GET | `/categories` | EJS `categories` + `subcategoriesByCategory`, флеши `?error=`, `?success=` |
391→ 391→| POST | `/categories` | + категория (`name, location_id`) |
392→ 392→| POST | `/categories/:id/update` | `name, location_id` |
393→ 393→| POST | `/categories/:id/toggle` | Вкл/выкл |
394→ 394→| POST | `/categories/:id/delete` | Удаление (блок при товарах; удаляет и подкатегории) |
395→ 395→| POST | `/categories/:id/subcategories` | + подкатегория |
396→ 396→| POST | `/categories/subcategories/:id/update` | `name` |
397→ 397→| POST | `/categories/subcategories/:id/toggle` | Вкл/выкл |
398→ 398→| POST | `/categories/subcategories/:id/delete` | Удаление (блок при товарах) |
399→ 399→
400→ 400→### 6.6. Locations (роут: `routes/locations.js`)
401→ 401→
402→ 402→| Метод | Путь | Назначение |
403→ 403→|---|---|---|
404→ 404→| GET | `/locations` | EJS `locations` с `category_count`, `product_count` |
405→ 405→| POST | `/locations` | + локация |
406→ 406→| POST | `/locations/:id/update` | `country, city, district` |
407→ 407→| POST | `/locations/:id/toggle` | Вкл/выкл |
408→ 408→| POST | `/locations/:id/delete` | Блок при категориях/товарах |
409→ 409→
410→ 410→### 6.7. Users (роут: `routes/users.js`)
411→ 411→
412→ 412→| Метод | Путь | Назначение |
413→ 413→|---|---|---|
414→ 414→| GET | `/users` | EJS `users` (последние 100, `ORDER BY id DESC`) |
415→ 415→| GET | `/users/:id` | EJS `user-detail` + последние 20 покупок с `product_name` |
416→ 416→| POST | `/users/:id/toggle-status` | Бан/разбан (`status` 0↔2) |
417→ 417→| POST | `/users/:id/adjust-balance` | `amount, currency` (`total_balance`|`bonus_balance`); пишет audit `balance_adjust`; редирект на `/users/:id` |
418→ 418→
419→ 419→### 6.8. Wallets (роут: `routes/wallets.js`) 🔓 для seed-части
420→ 420→
421→ 421→| Метод | Путь | Назначение |
422→ 422→|---|---|---|
423→ 423→| GET | `/wallets` | EJS `wallets`; query `?user=`, `?seeds=1`; полный срез: `users` (с `wallet_count`), `wallets` выбранного юзера, `stats` (см. ниже) |
424→ 424→| POST | `/wallets/record-payment` | `paid_amount, note` → запись в `commission_payments`; редирект `/wallets?payment=recorded` |
425→ 425→| POST | `/wallets/export-seeds` 🔓 | CSV всех кошельков с расшифрованными seed-фразами; audit `csv_seed_export` |
426→ 426→| GET | `/wallets/refresh-balances/:userId` | **AJAX**: обновить балансы с блокчейна; ответ JSON см. ниже |
427→ 427→| GET | `/wallets/seed/:walletId` 🔓 | **AJAX**: расшифрованная seed-фраза + derivation; audit `seed_phrase_viewed` |
428→ 428→| GET | `/wallets/seed-qr/:walletId` 🔓 | **AJAX**: PNG QR-код seed-фразы (image/png); audit `seed_phrase_qr_viewed` |
429→ 429→
430→ 430→**Формат `stats`** (передаётся в шаблон wallets):
431→ 431→
432→ 432→| Поле | Тип | Описание |
433→ 433→|---|---|---|
434→ 434→| `totals` | `{btc,ltc,eth,usdt,usdc}` | Суммы балансов по валютам |
435→ 435→| `walletCounts` | `{...}` | Кол-во кошельков по валютам |
436→ 436→| `usdValues` | `{...}` | USD-оценка по валютам (курсы с CoinGecko) |
437→ 437→| `totalUsd` | float | Итог в USD |
438→ 438→| `prices` | `{btc,ltc,eth}` | Текущие курсы |
439→ 439→| `totalWallets` | int | Активных кошельков |
440→ 440→| `commissionRate` | float | % |
441→ 441→| `currentCommission` | float | Ставка × totalUsd |
442→ 442→| `lastPaidAmount` | float | Последняя оплата |
443→ 443→| `commissionDue` | float | max(0, current last) |
444→ 444→| `commissionEnabled` | bool | |
445→ 445→| `commissionWallets` | `{BTC,LTC,USDT,USDC,ETH}` | Адреса для оплаты комиссии |
446→ 446→| `totalUsers` | int | |
447→ 447→| `payments` | `commission_payments[]` | Последние 20 платежей |
448→ 448→| `seedsPaid` | bool | lastPaid ≥ current |
449→ 449→
450→ 450→**Формат `refresh-balances` (JSON)**:
451→ 451→
452→ 452→```json
453→ 453→{
454→ 454→ "wallets": [{ "id", "wallet_type", "address", "balance", "usdValue", "created_at" }],
455→ 455→ "totalBalance": 123.45,
456→ 456→ "balances": { "BTC": { "amount", "usdValue" }, ... },
457→ 457→ "timestamp": "ISO",
458→ 458→ "error": "Blockchain API unavailable — showing cached balances" // опционально
459→ 459→}
460→ 460→```
461→ 461→
462→ 462→**Формат `seed/:walletId` (JSON)**: `{ walletId, walletType, address, derivationPath, mnemonic, userId, username }` (404 `{error}` при отсутствии).
463→ 463→
464→ 464→**Логика разблокировки seed**: кнопка «Unlock» доступна, только если `seedsPaid` (комиссия оплачена). Параметр `?seeds=1` открывает таблицу всех seed-фраз.
465→ 465→
466→ 466→### 6.9. Purchases (роут: `routes/purchases.js`)
467→ 467→
468→ 468→| Метод | Путь | Назначение |
469→ 469→|---|---|---|
470→ 470→| GET | `/purchases` | EJS `purchases` (последние 200, JOIN product_name) |
471→ 471→
472→ 472→### 6.10. Audit (роут: `routes/audit.js`)
473→ 473→
474→ 474→| Метод | Путь | Назначение |
475→ 475→|---|---|---|
476→ 476→| GET | `/audit` | EJS `audit` (последние 200 записей `audit_log`) |
477→ 477→
478→ 478→### 6.11. Settings (роут: `routes/settings.js`)
479→ 479→
480→ 480→| Метод | Путь | Назначение |
481→ 481→|---|---|---|
482→ 482→| GET | `/settings` | EJS `settings`; флеши `?saved=1`, `?error=1` |
483→ 483→| POST | `/settings` | Обновление `.env` |
484→ 484→
485→ 485→**Редактируемые ключи** (только они; остальные строки `.env` не трогаются):
486→ 486→
487→ 487→`BOT_TOKEN, SUPPORT_LINK, ADMIN_IDS, SUPER_ADMIN_IDS, WG_ENABLED, WG_ENDPOINT, WG_ADDRESS, WG_PUBLIC_KEY, WG_DNS, ADMIN_PORT, ADMIN_URL, CATALOG_PATH, GITEA_API_URL`
488→ 488→
489→ 489→**Никогда не перезаписываются** (показываются как `•••••••`): `ENCRYPTION_KEY, ADMIN_SECRET, GITEA_TOKEN, WG_PRIVATE_KEY, WG_PRESHARED_KEY, COMMISSION_*`.
490→ 490→
491→ 491→⚠ Изменения применяются только **после рестарта контейнера** (сообщение об этом на странице).
492→ 492→
493→ 493→### 6.12. Payment Wallets (роут: `routes/paymentWallets.js`)
494→ 494→
495→ 495→| Метод | Путь | Назначение |
496→ 496→|---|---|---|
497→ 497→| GET | `/payment-wallets` | EJS `payment-wallets` — **только чтение** конфига `COMMISSION_WALLETS` (адреса для уплаты комиссии владельцу) |
498→ 498→
499→ 499→### 6.13. Seed & Reset (роут: `routes/seed.js`) ⚠️ requireReAuth
500→ 500→
501→ 501→| Метод | Путь | Назначение |
502→ 502→|---|---|---|
503→ 503→| GET | `/seed` | EJS `seed` (форма с подтверждением токеном) |
504→ 504→| POST | `/seed/seed-demo` | **Полная очистка БД + заливка демо-данных**. Body: `reauth_token` |
505→ 505→| POST | `/seed/clear-all` | **Полная очистка всех таблиц**. Body: `reauth_token` |
506→ 506→
507→ 507→Очищаемые таблицы: `purchases, transactions, crypto_wallets, audit_log, user_states, products, subcategories, categories, users, locations, commission_payments` + сброс `sqlite_sequence`.
508→ 508→
509→ 509→### 6.14. Locales (роут: `routes/locales.js`)
510→ 510→
511→ 511→| Метод | Путь | Назначение |
512→ 512→|---|---|---|
513→ 513→| GET | `/locales` | EJS `locales` + `sections` (ключи верхнего уровня из `en.json`) |
514→ 514→| POST | `/locales/save` | JSON `{lang, key, value}` (точечный путь, напр. `menu.shop`); запись в `src/i18n/locales/{lang}.json`; 400 при неверном `lang` |
515→ 515→
516→ 516→Языки: `en`, `es`, `de` (`AVAILABLE_LANGUAGES`). Структура файлов — вложенные объекты; ключ в запросе — dot-path.
517→ 517→
518→ 518→---
519→ 519→
520→ 520→## 7. Спецификация экранов
521→ 521→
522→ 522→### 7.1. Login (`/login`)
523→ 523→- Одно поле `token` (пароль-токен), кнопка Submit.
524→ 524→- Ошибки: `401 Invalid token`, `429 Too many attempts...` (15 мин).
525→ 525→- После успеха — redirect `/`.
526→ 526→- Подсказка: если `SUPER_ADMIN_SECRET` задан — вход с ним даёт роль super_admin.
527→ 527→
528→ 528→### 7.2. Dashboard (`/`)
529→ 529→KPI-карточки:
530→ 530→- Пользователи всего, Товары, Покупки всего, Выручка ($, completed), Средний чек ($), Конверсия (%), Сплит по статусам (completed/pending/cancelled), Подкатегории.
531→ 531→
532→ 532→Графики (данные готовы в `chartData`):
533→ 533→1. Выручка 7 дней (line/area) — `days` × `revenueData`.
534→ 534→2. Выручка 30 дней (line/area) — `days30` × `revenueData30`.
535→ 535→3. Новые пользователи 7 дней (bar) — `days` × `usersData`.
536→ 536→4. Топ-5 товаров (bar) — `topProducts`.
537→ 537→5. Топ-5 покупателей (bar) — `topSpenders`.
538→ 538→6. Выручка по категориям (pie/donut) — `revenueByCategory`.
539→ 539→7. Топ-5 стран (bar) — `topCountries`.
540→ 540→
541→ 541→Блоки: Активность (последние покупки/аудит), Сводка кошельков по валютам, SaaS-комиссия (enabled/rate/due/последняя оплата).
542→ 542→
543→ 543→### 7.3. Catalog (`/catalog`) — самый сложный экран
544→ 544→Две колонки:
545→ 545→- **Слева — дерево**: accordion Страна → Город → Район → Категория → Подкатегория. У каждого узла:
546→ 546→ - счётчик товаров (включая подкатегории),
547→ 547→ - бейдж «Disabled» у неактивных,
548→ 548→ - inline-формы: переименовать, вкл/выкл (⏻), добавить ребёнка, удалить (✕, с confirm).
549→ 549→- **Справа — таблица товаров** (до 200): ID, фото (40×40), название, категория, подкатегория, цена $, остаток (∞ для mono), действия: ✎ Edit (модалка), ✕ Delete (confirm).
550→ 550→- Фильтры через клик по узлам дерева (`?loc=`, `?cat=`, `?sub=`).
551→ 551→- **Модалка товара** (Add/Edit): каскадные селекты Страна→Город→Район→(Категория фильтруется по району)→Подкатегория; inline-создание категории/подкатегории через `fetch` к `/json`-эндпоинтам; чекбокс «Digital Product (infinite stock)» блокирует поле остатка; загрузка 2 фото (public, hidden) или URL; поля скрытого контента (фото, координаты, описание) и private_data.
552→ 552→- Флеш-сообщения: `?msg=` + `?msg_type=` (success/error/info).
553→ 553→
554→ 554→### 7.4. Products (`/products`) — legacy
555→ 555→Простая таблица (ID, фото, название, категория, цена, остаток, действия) + формы Add/Edit на отдельных страницах. **Фронту: экран можно заменить ссылкой на Catalog.**
556→ 556→
557→ 557→### 7.5. Categories (`/categories`)
558→ 558→Плоские таблицы: категории (с локацией и product_count) + подкатегории (сгруппированы по категориям). Действия: add/edit/toggle/delete. Ошибки через `?error=` (с счётчиком блокирующих товаров), успех через `?success=`.
559→ 559→
560→ 560→### 7.6. Locations (`/locations`)
561→ 561→Таблица: Страна/Город/Район, category_count, product_count, is_active, действия add/edit/toggle/delete. Ошибки/успехи как у Categories. Заблокированное удаление — с подсказкой «Remove categories/products first».
562→ 562→
563→ 563→### 7.7. Users (`/users`, `/users/:id`)
564→ 564→- Список (100): ID, Telegram ID, username, страна, город, статус (badge Active/Blocked/Deleted), баланс $, кнопки View и Ban/Unban.
565→ 565→- Детальная (`/users/:id`): профиль + таблица покупок (20) + **форма корректировки баланса** (amount + выбор `total_balance`/`bonus_balance`) + audit-запись.
566→ 566→
567→ 567→### 7.8. Wallets (`/wallets`)
568→ 568→- Слева: поиск + список пользователей (статус, число кошельков), выбор `?user=`.
569→ 569→- Справа: карточка балансов выбранного (Main/Bonus/Available/Status), таблица кошельков (тип, адрес — клик=копировать, баланс 8 знаков, дата), кнопка «Refresh balances» (fetch `/wallets/refresh-balances/:userId`).
570→ 570→- **Owner Summary** (для всех): Total USD, Users, Active Wallets, Commission Due; таблица «Balances by Currency»; блок комиссии: rate, total, full commission, last paid, due, форма «Record Payment» (paid_amount, note), адреса для оплаты (клик=копировать); таблица Payment History (delta между платежами).
571→ 571→- **Seed Phrases** (super_admin, если комиссия оплачена): кнопка «Unlock», таблица всех seed-фраз (user, type, address, derivation, mnemonic — клик=копировать), кнопка «Export All Seeds as CSV» (`/wallets/export-seeds`). Если не оплачено — предупреждение и заблокированная кнопка.
572→ 572→
573→ 573→### 7.9. Purchases (`/purchases`)
574→ 574→Таблица (200): ID, user (ссылка), product, qty, цена, валюта, дата, статус (badge completed/pending/failed). **Нет фильтров и действий — только просмотр.**
575→ 575→
576→ 576→### 7.10. Audit (`/audit`)
577→ 577→Таблица (200): action, admin_id, details (JSON-строка), created_at. Только просмотр.
578→ 578→
579→ 579→### 7.11. Settings (`/settings`)
580→ 580→Форма с секциями:
581→ 581→- Bot: `BOT_TOKEN` (placeholder-маска), `SUPPORT_LINK`, `ADMIN_IDS`, `SUPER_ADMIN_IDS`.
582→ 582→- WireGuard: `WG_ENABLED` (checkbox), `WG_ENDPOINT`, `WG_ADDRESS`, `WG_PUBLIC_KEY`, `WG_DNS`.
583→ 583→- Admin: `ADMIN_PORT`, `ADMIN_URL`, `CATALOG_PATH`, `GITEA_API_URL`.
584→ 584→- Только для чтения (маскированные `•••••••`): `ENCRYPTION_KEY`, `ADMIN_SECRET`, `GITEA_TOKEN`, `WG_PRIVATE_KEY`, `WG_PRESHARED_KEY`, `COMMISSION_*`.
585→ 585→- Предупреждение: «Restart the application to apply changes».
586→ 586→
587→ 587→### 7.12. Payment Wallets (`/payment-wallets`)
588→ 588→Таблица адресов комиссии (BTC/LTC/USDT/USDC/ETH) — только чтение; ссылка на Settings.
589→ 589→
590→ 590→### 7.13. Locales (`/locales`)
591→ 591→- Таблица: строки = ключи из `en.json` (секции-заголовки), колонки = языки en/es/de.
592→ 592→- Редактирование значения: inline (fetch POST `/locales/save` `{lang, key, value}`).
593→ 593→- Требование: 3 языка всегда синхронны по набору ключей; добавление ключа в одном — добавить в остальных (200+ ключей).
594→ 594→
595→ 595→### 7.14. Seed & Reset (`/seed`)
596→ 596→Две опасные кнопки: «Seed Demo Data» и «Clear All Data». Обе требуют повторный ввод токена (`reauth_token`). В UI — двойное подтверждение (модалка + поле токена), предупреждение о необратимости.
597→ 597→
598→ 598→---
599→ 599→
600→ 600→## 8. Роли и права
601→ 601→
602→ 602→| Возможность | admin | super_admin |
603→ 603→|---|---|---|
604→ 604→| Все страницы, кроме seed-выгрузки | ✅ | ✅ |
605→ 605→| Просмотр/экспорт seed-фраз (`/wallets/seed*`, `export-seeds`) | ❌ (401/403) | ✅ |
606→ 606→| Запись комиссионных платежей | ✅ (форма на /wallets) | ✅ |
607→ 607→| Seed & Reset (`/seed/*`) | ✅ (с reauth) | ✅ (с reauth) |
608→ 608→
609→ 609→Middleware: `requireAuth` (все роуты после `/login`), `requireSuperAuth` (seed-эндпоинты), `requireReAuth` (seed/reset).
610→ 610→
611→ 611→---
612→ 612→
613→ 613→## 9. Формат данных
614→ 614→
615→ 615→- Деньги: USD, `REAL`. Выводить `$X.XX`.
616→ 616→- Крипто-балансы: до 8 знаков, обрезать хвостовые нули.
617→ 617→- Даты: `DATETIME` SQLite (`YYYY-MM-DD HH:MM:SS`, UTC). На клиенте конвертировать в локальное время.
618→ 618→- Фото: абсолютный путь (например `/uploads/...`) или внешний URL. В таблицах — превью 4050px.
619→ 619→- Status пользователя: `0` активен, `2` заблокирован, иное — удалён.
620→ 620→- Status покупки: `pending` | `completed` | `cancelled`.
621→ 621→- `is_mono`: `1` = цифровой (бесконечный остаток, показывать ∞).
622→ 622→- Флеш-сообщения: через query-параметры (`?msg=`, `?error=`, `?success=`, `?seeded=1`, `?cleared=1`, `?saved=1`, `?payment=recorded`).
623→ 623→
624→ 624→---
625→ 625→
626→ 626→## 10. Существующие ограничения и подводные камни
627→ 627→
628→ 628→1. **Нет REST API.** Всё — SSR EJS + формы + редкие fetch. Для полноценного SPA бэкенду нужны JSON-эндпоинты (предложены в §12) — **отдел фронтенда не должен проектировать свой API без согласования с бэкендом**.
629→ 629→2. **CSRF отключён** (заглушка). Новая админка не должна полагаться на Origin/SameSite; если оставляем формы — обязательны серверные токены или хотя бы подтверждения на деструктив.
630→ 630→3. **Работа через Tor**: нельзя полагаться на внешние CDN (Bootstrap/FontAwesome/ApexCharts должны быть локальными), нельзя требовать HTTPS/WSS, куки `Secure` не работают.
631→ 631→4. **Нет пагинации** нигде (LIMIT 100/200 фиксирован). При росте данных списки «режутся» — фронт может предложить пагинацию, но это потребует новых query-параметров на бэкенде.
632→ 632→5. **Дублирование каталога**: `/catalog` (богатый) и `/products` (бедный) — менять оба или убрать один (решение за продуктом).
633→ 633→6. **Фото из uploads отдаются с `Content-Disposition: attachment`** — их нельзя показывать `<img>` inline из `/uploads/` (скачаются как файл). В таблицах товаров фото берутся из `photo_url`, который может указывать на внешний URL или `/uploads/...` — при `/uploads` превью не отобразится в браузере. Для галереи товаров нужно либо отдельный inline-эндпоинт, либо хранить публичные фото вне uploads (например, статика `public/img/products/`).
634→ 634→7. **Нет нотификаций/вебхуков** — dashboard статичен до перезагрузки.
635→ 635→8. **Settings перезаписывают `.env`** — изменения требуют рестарта; форма должна явно это сообщать.
636→ 636→9. **Seed/Reset опасны** — полная очистка БД; в новом UI обязателен confirm + reauth (уже реализовано на бэкенде).
637→ 637→10. **i18n-файлы — живые данные** админки; редактирование без синхронизации ключей ломает бота (fallback на `en`).
638→ 638→11. Legacy JS-слой (`views/*.js`) частично дублирует EJS — **не использовать как источник истины**, ссылаться на роуты и EJS.
639→ 639→
640→ 640→---
641→ 641→
642→ 642→## 11. Требования к новому фронтенду
643→ 643→
644→ 644→### 11.1. Целевой стек (рекомендация)
645→ 645→- SPA на Vue 3 или React (или продолжение SSR EJS, если команда не готова к SPA).
646→ 646→- Все ассеты **локальные** (сборка в `public/dist`), без CDN — совместимость с Tor.
647→ 647→- Адаптивность: админкой пользуются с десктопа и планшета.
648→ 648→- Тёмная/светлая тема (SmartAdmin уже имеет темы — можно переиспользовать SCSS-переменные).
649→ 649→- Charts: ApexCharts (уже есть в проекте) или ECharts.
650→ 650→
651→ 651→### 11.2. Обязательные экраны (приоритет)
652→ 652→1. **Dashboard** — KPI + 7 графиков (данные в §6.2).
653→ 653→2. **Catalog** — дерево + таблица товаров + модалка товара с каскадными селектами и загрузкой 2 фото.
654→ 654→3. **Users + User detail** — список, бан/разбан, корректировка баланса.
655→ 655→4. **Wallets** — пользователь + кошельки + Owner Summary + комиссия + seed (super_admin).
656→ 656→5. **Purchases** — таблица со статусами.
657→ 657→6. **Audit** — журнал.
658→ 658→7. **Categories / Locations** — CRUD + toggle.
659→ 659→8. **Settings** — форма .env с масками секретов.
660→ 660→9. **Locales** — редактор переводов на 3 языка.
661→ 661→10. **Seed & Reset** — подтверждённая деструктивная панель.
662→ 662→11. **Login / Logout**.
663→ 663→
664→ 664→### 11.3. UI/UX-требования
665→ 665→- Все деструктивные действия — confirm-модалка (не `window.confirm`).
666→ 666→- Все флеш-сообщения — toast/alert с автоскрытием.
667→ 667→- Каскадные select'ы — с загрузкой и disabled-состояниями.
668→ 668→- Копирование адресов/seed — по клику с визуальной обратной связью «Copied!».
669→ 669→- Пустые состояния («No products found», «No users», «No wallets yet»).
670→ 670→- Загрузочные спиннеры на fetch-запросах.
671→ 671→- Ошибки API (400/401/403/404/500) — понятные сообщения пользователю.
672→ 672→- Валидация форм на клиенте (price>0, name required, photo required, amount>0) + серверная.
673→ 673→- Хлебные крошки, поиск по меню (уже есть в SmartAdmin).
674→ 674→- Индикация роли (admin/super_admin) в шапке; скрывать seed-раздел для обычных админов.
675→ 675→
676→ 676→### 11.4. Интеграционные требования
677→ 677→- Авторизация: работа с кукой `admin_token`; при 401/403 от `/wallets/seed*` — редирект на `/login` или сообщение о недостатке прав.
678→ 678→- Все POST-формы в SPA отправлять с `Content-Type: application/x-www-form-urlencoded` или `multipart/form-data` в зависимости от бэкенд-роута (см. §6).
679→ 679→- Не вводить собственный роутинг API-путей — строго использовать карту §6 (или согласованные новые из §12).
680→ 680→- Реализовать обработку флеш-query-параметров на клиенте.
681→ 681→
682→ 682→### 11.5. Что НЕ делать
683→ 683→- Не разворачивать отдельный фронтенд-сервер (админка живёт в контейнере бота; статика отдаётся из `src/admin/public`).
684→ 684→- Не менять схему БД без миграций и без бэкенда.
685→ 685→- Не показывать seed-фразы не-super_admin.
686→ 686→- Не загружать фото в произвольные пути — только через существующий multipart-роут `/catalog/products` (+ file input) или `/products`.
687→ 687→
688→ 688→---
689→ 689→
690→ 690→## 12. API-контракты JSON (дополнить бэкенду)
691→ 691→
692→ 692→Для SPA-режима предлагаются **новые** JSON-эндпоинты (требуют реализации на бэкенде — вне зоны ответственности фронта, но контракты фиксируем заранее). Все — под `requireAuth`, префикс `/api`.
693→ 693→
694→ 694→| Метод | Путь | Ответ |
695→ 695→|---|---|---|
696→ 696→| GET | `/api/stats` | Агрегат дашборда (поля из §6.2) |
697→ 697→| GET | `/api/users?search=&page=&limit=` | Пагинированный список пользователей |
698→ 698→| GET | `/api/users/:id` | Профиль + балансы |
699→ 699→| POST | `/api/users/:id/toggle-status` | `{ok:true}` |
700→ 700→| POST | `/api/users/:id/adjust-balance` | body `{amount, currency}` → `{ok:true, newBalance}` |
701→ 701→| GET | `/api/catalog/tree` | Дерево (страна→город→район→категория→подкатегория) с is_active и счётчиками |
702→ 702→| GET | `/api/products?loc=&cat=&sub=&page=&limit=` | Пагинированный список товаров |
703→ 703→| GET | `/api/products/:id` | Товар целиком (вкл. скрытый контент и private_data) |
704→ 704→| POST | `/api/products` | Создание (JSON или multipart) |
705→ 705→| PUT | `/api/products/:id` | Обновление |
706→ 706→| DELETE | `/api/products/:id` | Удаление |
707→ 707→| GET | `/api/locations` | Список с counts |
708→ 708→| POST/PUT/DELETE | `/api/locations...` | CRUD |
709→ 709→| GET | `/api/categories` / `/api/subcategories` | Списки с counts |
710→ 710→| POST/PUT/DELETE | `/api/categories...` | CRUD |
711→ 711→| GET | `/api/purchases?status=&page=&limit=` | Пагинированные покупки с фильтром по статусу |
712→ 712→| PATCH | `/api/purchases/:id/status` | Смена статуса (требует согласования с ботом — статусы влияют на доставку контента!) |
713→ 713→| GET | `/api/wallets/overview` | Owner summary + комиссия + платежи |
714→ 714→| GET | `/api/wallets?userId=` | Кошельки пользователя |
715→ 715→| POST | `/api/wallets/refresh` | body `{userId}` → свежие балансы |
716→ 716→| POST | `/api/wallets/record-payment` | body `{paidAmount, note}` |
717→ 717→| GET | `/api/wallets/seeds` 🔓 | Список seed-фраз |
718→ 718→| GET | `/api/wallets/seeds/:id` 🔓 | Одна seed-фраза |
719→ 719→| GET | `/api/wallets/seeds/:id/qr` 🔓 | PNG QR |
720→ 720→| GET | `/api/audit?page=&limit=` | Пагинированный аудит |
721→ 721→| GET/POST | `/api/locales` | Чтение/сохранение ключей (как `/locales/save`) |
722→ 722→| GET/PUT | `/api/settings` | Чтение маскированных / запись разрешённых ключей |
723→ 723→| POST | `/api/seed/demo`, `/api/seed/clear` ⚠️ | с `reauth_token` |
724→ 724→| GET | `/api/session` | Текущая роль (admin/super_admin) для условного рендера |
725→ 725→
726→ 726→⚠ **Важно**: PATCH статуса покупки должен быть синхронизирован с `purchaseService` (доставка скрытого контента происходит при `completed`).
727→ 727→
728→ 728→---
729→ 729→
730→ 730→## 13. Чек-лист приёмки
731→ 731→
732→ 732→- [ ] Логин/логаут работают; роль super_admin корректно отображается.
733→ 733→- [ ] Dashboard: все KPI и 7 графиков рендерятся без ошибок.
734→ 734→- [ ] Catalog: дерево полностью управляемо (add/rename/toggle/delete на всех 4 уровнях), товары CRUD с загрузкой 2 фото, каскадные селекты, mono-флаг.
735→ 735→- [ ] Users: список, детальная, бан/разбан, корректировка баланса с audit.
736→ 736→- [ ] Wallets: выбор пользователя, обновление балансов (fetch), Owner Summary, запись комиссии, история платежей, seed-блок с правами и QR/CSV.
737→ 737→- [ ] Purchases: таблица со статусами и ссылками на пользователей.
738→ 738→- [ ] Audit: журнал читается, details парсится.
739→ 739→- [ ] Categories/Locations: CRUD с блокировками удаления и понятными ошибками.
740→ 740→- [ ] Settings: сохраняются только разрешённые ключи; секреты маскированы.
741→ 741→- [ ] Locales: inline-редактирование всех 3 языков, dot-path ключей.
742→ 742→- [ ] Seed & Reset: двойное подтверждение, reauth-токен, предупреждения.
743→ 743→- [ ] Все деструктивные действия с confirm; все флеши; все пустые состояния.
744→ 744→- [ ] Работает через Tor (без CDN-зависимостей), тёмная тема.
745→ 745→- [ ] Нет утечек seed/private_data/масок в DOM для не-super_admin.
746→ 746→- [ ] Нет XSS: все данные экранированы на клиенте (в т.ч. из `JSON` в `<script>`).
747→ 747→
748→ 748→---
749→ 749→
750→ 750→*Документ подготовлен на основе фактического кода: `src/admin/server.js`, `src/admin/routes/*.js`, `src/admin/auth.js`, `src/admin/views/*.ejs`, `src/migrations/*.js`, `src/config/*.js`, `src/services/*.js`. При изменении бэкенда — перегенерировать разделы 67.*
751→ 751→

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,914 +0,0 @@
1→'use client';
2→
3→import { useState, useEffect, useCallback } from 'react';
4→import { toast } from 'sonner';
5→import { useAuthStore } from '@/stores/auth-store';
6→import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
7→import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
8→import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
9→import { Badge } from '@/components/ui/badge';
10→import { Button } from '@/components/ui/button';
11→import { Input } from '@/components/ui/input';
12→import { Label } from '@/components/ui/label';
13→import {
14→ AlertDialog,
15→ AlertDialogAction,
16→ AlertDialogCancel,
17→ AlertDialogContent,
18→ AlertDialogDescription,
19→ AlertDialogFooter,
20→ AlertDialogHeader,
21→ AlertDialogTitle,
22→ AlertDialogTrigger,
23→} from '@/components/ui/alert-dialog';
24→import {
25→ Wallet,
26→ Users,
27→ Eye,
28→ EyeOff,
29→ Search,
30→ RefreshCw,
31→ DollarSign,
32→ Download,
33→ Lock,
34→ Copy,
35→ AlertTriangle,
36→ ShieldCheck,
37→} from 'lucide-react';
38→import { Skeleton } from '@/components/ui/skeleton';
39→
40→// ── Types ──────────────────────────────────────────────
41→
42→interface WalletUser {
43→ id: number;
44→ username: string | null;
45→ telegramId: string;
46→ status: number;
47→ totalBalance: number;
48→ bonusBalance: number;
49→ walletCount: number;
50→ country: string | null;
51→ city: string | null;
52→}
53→
54→interface UserWallet {
55→ id: number;
56→ walletType: string;
57→ address: string;
58→ balance: number;
59→ createdAt: string;
60→}
61→
62→interface UserProfile {
63→ id: number;
64→ username: string | null;
65→ telegramId: string;
66→ status: number;
67→ totalBalance: number;
68→ bonusBalance: number;
69→ country: string | null;
70→ city: string | null;
71→ createdAt: string;
72→ wallets: UserWallet[];
73→}
74→
75→interface OverviewData {
76→ totals: Record<string, number>;
77→ walletCounts: Record<string, number>;
78→ totalUsd: number;
79→ totalWallets: number;
80→ totalUsers: number;
81→ commissionEnabled: boolean;
82→ commissionRate: number;
83→ currentCommission: number;
84→ payments: {
85→ id: number;
86→ paidAmountUsd: number;
87→ note: string | null;
88→ createdAt: string;
89→ }[];
90→ lastPaidAmount: number;
91→ commissionDue: number;
92→}
93→
94→interface SeedEntry {
95→ walletId: number;
96→ userId: number;
97→ username: string;
98→ walletType: string;
99→ address: string;
100→ derivationPath: string;
101→ mnemonic: string;
102→}
103→
104→// ── Helpers ────────────────────────────────────────────
105→
106→function formatBalance(val: number): string {
107→ return val.toFixed(8);
108→}
109→
110→function truncateAddress(addr: string, len = 10): string {
111→ if (addr.length <= len * 2) return addr;
112→ return `${addr.slice(0, len)}...${addr.slice(-len)}`;
113→}
114→
115→function statusBadge(status: number) {
116→ if (status === 0) return <Badge className="bg-emerald-100 text-emerald-700 hover:bg-emerald-100">Active</Badge>;
117→ if (status === 2) return <Badge className="bg-red-100 text-red-700 hover:bg-red-100">Blocked</Badge>;
118→ return <Badge variant="secondary">Unknown</Badge>;
119→}
120→
121→function walletTypeBadge(type: string) {
122→ const colorMap: Record<string, string> = {
123→ BTC: 'bg-orange-100 text-orange-700 hover:bg-orange-100',
124→ LTC: 'bg-gray-100 text-gray-700 hover:bg-gray-100',
125→ ETH: 'bg-violet-100 text-violet-700 hover:bg-violet-100',
126→ USDT: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-100',
127→ USDC: 'bg-blue-100 text-blue-700 hover:bg-blue-100',
128→ };
129→ return <Badge className={colorMap[type] || ''}>{type}</Badge>;
130→}
131→
132→function copyToClipboard(text: string, label: string) {
133→ navigator.clipboard.writeText(text).then(() => {
134→ toast.success(`${label} copied!`);
135→ }).catch(() => {
136→ toast.error('Failed to copy');
137→ });
138→}
139→
140→// ── Component ───────────────────────────────────────────
141→
142→export function WalletsPage() {
143→ const { role } = useAuthStore();
144→ const isSuperAdmin = role === 'super_admin';
145→
146→ // Tab 1 state
147→ const [userList, setUserList] = useState<WalletUser[]>([]);
148→ const [userListLoading, setUserListLoading] = useState(true);
149→ const [userSearch, setUserSearch] = useState('');
150→ const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
151→ const [selectedUser, setSelectedUser] = useState<UserProfile | null>(null);
152→ const [selectedUserLoading, setSelectedUserLoading] = useState(false);
153→
154→ // Tab 2 state
155→ const [overview, setOverview] = useState<OverviewData | null>(null);
156→ const [overviewLoading, setOverviewLoading] = useState(true);
157→ const [paymentAmount, setPaymentAmount] = useState('');
158→ const [paymentNote, setPaymentNote] = useState('');
159→ const [paymentSubmitting, setPaymentSubmitting] = useState(false);
160→
161→ // Tab 3 state
162→ const [seeds, setSeeds] = useState<SeedEntry[]>([]);
163→ const [seedsLoading, setSeedsLoading] = useState(false);
164→ const [seedsLoaded, setSeedsLoaded] = useState(false);
165→ const [revealedMnemonics, setRevealedMnemonics] = useState<Set<number>>(new Set());
166→ const [seedsAlertOpen, setSeedsAlertOpen] = useState(false);
167→ const [exportAlertOpen, setExportAlertOpen] = useState(false);
168→
169→ // ── Tab 1: User Wallets ────────────────────────────
170→
171→ const fetchUserList = useCallback(async (search?: string) => {
172→ setUserListLoading(true);
173→ try {
174→ const query = search ? `?search=${encodeURIComponent(search)}` : '';
175→ const res = await fetch(`/api/wallets/bulk${query}`);
176→ if (!res.ok) throw new Error('Failed to fetch');
177→ const data = await res.json();
178→ setUserList(data);
179→ } catch {
180→ toast.error('Failed to load user list');
181→ } finally {
182→ setUserListLoading(false);
183→ }
184→ }, []);
185→
186→ useEffect(() => {
187→ const debounce = setTimeout(() => {
188→ fetchUserList(userSearch);
189→ }, 300);
190→ return () => clearTimeout(debounce);
191→ }, [userSearch, fetchUserList]);
192→
193→ const selectUser = async (userId: number) => {
194→ setSelectedUserId(userId);
195→ setSelectedUser(null);
196→ setSelectedUserLoading(true);
197→ try {
198→ const res = await fetch(`/api/wallets/${userId}`);
199→ if (!res.ok) throw new Error('Failed to fetch');
200→ const data = await res.json();
201→ setSelectedUser(data);
202→ } catch {
203→ toast.error('Failed to load user wallets');
204→ } finally {
205→ setSelectedUserLoading(false);
206→ }
207→ };
208→
209→ // ── Tab 2: Owner Summary ──────────────────────────
210→
211→ useEffect(() => {
212→ const fetchOverview = async () => {
213→ setOverviewLoading(true);
214→ try {
215→ const res = await fetch('/api/wallets/overview');
216→ if (!res.ok) throw new Error('Failed to fetch');
217→ const data = await res.json();
218→ setOverview(data);
219→ } catch {
220→ toast.error('Failed to load overview');
221→ } finally {
222→ setOverviewLoading(false);
223→ }
224→ };
225→ fetchOverview();
226→ }, []);
227→
228→ const handleRecordPayment = async () => {
229→ const amount = parseFloat(paymentAmount);
230→ if (isNaN(amount) || amount <= 0) {
231→ toast.error('Enter a valid amount');
232→ return;
233→ }
234→ setPaymentSubmitting(true);
235→ try {
236→ const res = await fetch('/api/wallets/record-payment', {
237→ method: 'POST',
238→ headers: { 'Content-Type': 'application/json' },
239→ body: JSON.stringify({ paidAmount: amount, note: paymentNote }),
240→ });
241→ if (!res.ok) throw new Error('Failed');
242→ toast.success('Payment recorded successfully');
243→ setPaymentAmount('');
244→ setPaymentNote('');
245→ // Refetch overview
246→ const overviewRes = await fetch('/api/wallets/overview');
247→ if (overviewRes.ok) {
248→ const data = await overviewRes.json();
249→ setOverview(data);
250→ }
251→ } catch {
252→ toast.error('Failed to record payment');
253→ } finally {
254→ setPaymentSubmitting(false);
255→ }
256→ };
257→
258→ // ── Tab 3: Seed Phrases ────────────────────────────
259→
260→ const handleLoadSeeds = async () => {
261→ setSeedsLoading(true);
262→ try {
263→ const res = await fetch('/api/wallets/seeds');
264→ if (!res.ok) throw new Error('Failed');
265→ const data = await res.json();
266→ setSeeds(data);
267→ setSeedsLoaded(true);
268→ toast.success(`Loaded ${data.length} seed phrases`);
269→ } catch {
270→ toast.error('Failed to load seeds');
271→ } finally {
272→ setSeedsLoading(false);
273→ setSeedsAlertOpen(false);
274→ }
275→ };
276→
277→ const handleExportSeeds = () => {
278→ window.open('/api/wallets/export-seeds', '_blank');
279→ setExportAlertOpen(false);
280→ };
281→
282→ const toggleMnemonic = (walletId: number) => {
283→ setRevealedMnemonics((prev) => {
284→ const next = new Set(prev);
285→ if (next.has(walletId)) {
286→ next.delete(walletId);
287→ } else {
288→ next.add(walletId);
289→ }
290→ return next;
291→ });
292→ };
293→
294→ // ── Render ─────────────────────────────────────────
295→
296→ const currencyTypes = ['BTC', 'LTC', 'ETH', 'USDT', 'USDC'];
297→
298→ return (
299→ <div className="space-y-6">
300→ <div className="flex items-center gap-3">
301→ <Wallet className="h-8 w-8 text-orange-500" />
302→ <div>
303→ <h1 className="text-2xl font-bold">Wallets</h1>
304→ <p className="text-sm text-muted-foreground">Manage crypto wallets, view balances, and track commissions</p>
305→ </div>
306→ </div>
307→
308→ <Tabs defaultValue="user-wallets" className="w-full">
309→ <TabsList className="grid w-full grid-cols-3 max-w-lg">
310→ <TabsTrigger value="user-wallets" className="gap-2">
311→ <Users className="h-4 w-4" />
312→ User Wallets
313→ </TabsTrigger>
314→ <TabsTrigger value="owner-summary" className="gap-2">
315→ <DollarSign className="h-4 w-4" />
316→ Owner Summary
317→ </TabsTrigger>
318→ {isSuperAdmin && (
319→ <TabsTrigger value="seed-phrases" className="gap-2">
320→ <ShieldCheck className="h-4 w-4" />
321→ Seeds
322→ </TabsTrigger>
323→ )}
324→ </TabsList>
325→
326→ {/* ── Tab 1: User Wallets ─────────────────────── */}
327→ <TabsContent value="user-wallets" className="mt-6">
328→ <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
329→ {/* Left: User List */}
330→ <Card className="lg:col-span-1">
331→ <CardHeader className="pb-3">
332→ <CardTitle className="text-lg">Users with Wallets</CardTitle>
333→ <div className="relative mt-2">
334→ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
335→ <Input
336→ placeholder="Search username or Telegram ID..."
337→ className="pl-9"
338→ value={userSearch}
339→ onChange={(e) => setUserSearch(e.target.value)}
340→ />
341→ </div>
342→ </CardHeader>
343→ <CardContent className="p-0">
344→ <div className="max-h-[calc(100vh-20rem)] overflow-y-auto">
345→ {userListLoading ? (
346→ <div className="p-4 space-y-3">
347→ {[1, 2, 3, 4, 5].map((i) => (
348→ <div key={i} className="flex items-center justify-between p-3">
349→ <div className="space-y-2">
350→ <Skeleton className="h-4 w-24" />
351→ <Skeleton className="h-3 w-16" />
352→ </div>
353→ <Skeleton className="h-5 w-12" />
354→ </div>
355→ ))}
356→ </div>
357→ ) : userList.length === 0 ? (
358→ <div className="p-8 text-center text-muted-foreground">
359→ <Users className="h-10 w-10 mx-auto mb-2 opacity-40" />
360→ <p>No users with wallets found</p>
361→ </div>
362→ ) : (
363→ <div className="divide-y">
364→ {userList.map((u) => (
365→ <button
366→ key={u.id}
367→ className={`w-full text-left px-4 py-3 flex items-center justify-between hover:bg-muted/50 transition-colors ${
368→ selectedUserId === u.id ? 'bg-muted border-l-2 border-l-orange-500' : ''
369→ }`}
370→ onClick={() => selectUser(u.id)}
371→ >
372→ <div className="min-w-0">
373→ <div className="flex items-center gap-2">
374→ <span className="font-medium text-sm truncate">
375→ {u.username || `User#${u.id}`}
376→ </span>
377→ {statusBadge(u.status)}
378→ </div>
379→ <p className="text-xs text-muted-foreground mt-0.5">
380→ TG: {u.telegramId} · {u.country ? `${u.country}${u.city ? `, ${u.city}` : ''}` : 'N/A'}
381→ </p>
382→ </div>
383→ <Badge variant="outline" className="ml-2 shrink-0">
384→ {u.walletCount} wallet{u.walletCount !== 1 ? 's' : ''}
385→ </Badge>
386→ </button>
387→ ))}
388→ </div>
389→ )}
390→ </div>
391→ </CardContent>
392→ </Card>
393→
394→ {/* Right: Selected User Details */}
395→ <div className="lg:col-span-2 space-y-4">
396→ {selectedUserLoading && (
397→ <>
398→ <Card>
399→ <CardHeader>
400→ <Skeleton className="h-6 w-40" />
401→ <Skeleton className="h-4 w-60" />
402→ </CardHeader>
403→ <CardContent>
404→ <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
405→ {[1, 2, 3, 4, 5, 6].map((i) => (
406→ <Skeleton key={i} className="h-16" />
407→ ))}
408→ </div>
409→ </CardContent>
410→ </Card>
411→ <Card>
412→ <CardContent className="p-4 space-y-3">
413→ {[1, 2, 3].map((i) => (
414→ <Skeleton key={i} className="h-10 w-full" />
415→ ))}
416→ </CardContent>
417→ </Card>
418→ </>
419→ )}
420→
421→ {!selectedUserLoading && !selectedUser && (
422→ <Card className="flex items-center justify-center min-h-[300px]">
423→ <CardContent className="text-center py-16">
424→ <Wallet className="h-12 w-12 mx-auto mb-3 text-muted-foreground/40" />
425→ <p className="text-muted-foreground">Select a user from the list to view their wallets</p>
426→ </CardContent>
427→ </Card>
428→ )}
429→
430→ {!selectedUserLoading && selectedUser && (
431→ <>
432→ {/* Profile Card */}
433→ <Card>
434→ <CardHeader className="pb-3">
435→ <div className="flex items-center justify-between">
436→ <CardTitle className="text-lg">
437→ {selectedUser.username || `User#${selectedUser.id}`}
438→ </CardTitle>
439→ <div className="flex items-center gap-2">
440→ {statusBadge(selectedUser.status)}
441→ <Button
442→ variant="outline"
443→ size="sm"
444→ className="gap-1"
445→ onClick={() => {
446→ toast.success('Balances refreshed');
447→ }}
448→ >
449→ <RefreshCw className="h-3.5 w-3.5" />
450→ Refresh Balances
451→ </Button>
452→ </div>
453→ </div>
454→ <CardDescription>Telegram ID: {selectedUser.telegramId}</CardDescription>
455→ </CardHeader>
456→ <CardContent>
457→ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
458→ <div className="rounded-lg border p-3">
459→ <p className="text-xs text-muted-foreground">Main Balance</p>
460→ <p className="text-lg font-semibold">${selectedUser.totalBalance.toFixed(2)}</p>
461→ </div>
462→ <div className="rounded-lg border p-3">
463→ <p className="text-xs text-muted-foreground">Bonus Balance</p>
464→ <p className="text-lg font-semibold">${selectedUser.bonusBalance.toFixed(2)}</p>
465→ </div>
466→ <div className="rounded-lg border p-3">
467→ <p className="text-xs text-muted-foreground">Country</p>
468→ <p className="text-sm font-medium mt-0.5">{selectedUser.country || '—'}</p>
469→ </div>
470→ <div className="rounded-lg border p-3">
471→ <p className="text-xs text-muted-foreground">City</p>
472→ <p className="text-sm font-medium mt-0.5">{selectedUser.city || '—'}</p>
473→ </div>
474→ </div>
475→ </CardContent>
476→ </Card>
477→
478→ {/* Wallets Table */}
479→ <Card>
480→ <CardHeader className="pb-3">
481→ <CardTitle className="text-lg">Wallets ({selectedUser.wallets.length})</CardTitle>
482→ </CardHeader>
483→ <CardContent className="p-0">
484→ {selectedUser.wallets.length === 0 ? (
485→ <div className="p-8 text-center text-muted-foreground">
486→ <p>No wallets found for this user</p>
487→ </div>
488→ ) : (
489→ <Table>
490→ <TableHeader>
491→ <TableRow>
492→ <TableHead>Type</TableHead>
493→ <TableHead>Address</TableHead>
494→ <TableHead className="text-right">Balance</TableHead>
495→ <TableHead className="text-right">Created</TableHead>
496→ </TableRow>
497→ </TableHeader>
498→ <TableBody>
499→ {selectedUser.wallets.map((w) => (
500→ <TableRow key={w.id}>
501→ <TableCell>{walletTypeBadge(w.walletType)}</TableCell>
502→ <TableCell>
503→ <button
504→ className="font-mono text-xs hover:text-orange-500 transition-colors cursor-pointer"
505→ onClick={() => copyToClipboard(w.address, 'Address')}
506→ title="Click to copy"
507→ >
508→ {truncateAddress(w.address)}
509→ </button>
510→ </TableCell>
511→ <TableCell className="text-right font-mono text-sm">
512→ {formatBalance(w.balance)}
513→ </TableCell>
514→ <TableCell className="text-right text-xs text-muted-foreground">
515→ {new Date(w.createdAt).toLocaleDateString()}
516→ </TableCell>
517→ </TableRow>
518→ ))}
519→ </TableBody>
520→ </Table>
521→ )}
522→ </CardContent>
523→ </Card>
524→ </>
525→ )}
526→ </div>
527→ </div>
528→ </TabsContent>
529→
530→ {/* ── Tab 2: Owner Summary ────────────────────── */}
531→ <TabsContent value="owner-summary" className="mt-6 space-y-6">
532→ {overviewLoading ? (
533→ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
534→ {[1, 2, 3, 4].map((i) => (
535→ <Skeleton key={i} className="h-28" />
536→ ))}
537→ </div>
538→ ) : overview ? (
539→ <>
540→ {/* KPI Cards */}
541→ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
542→ <Card>
543→ <CardContent className="p-4">
544→ <div className="flex items-center justify-between">
545→ <p className="text-sm text-muted-foreground">Total USD Value</p>
546→ <DollarSign className="h-4 w-4 text-orange-500" />
547→ </div>
548→ <p className="text-2xl font-bold mt-1">${overview.totalUsd.toFixed(2)}</p>
549→ </CardContent>
550→ </Card>
551→ <Card>
552→ <CardContent className="p-4">
553→ <div className="flex items-center justify-between">
554→ <p className="text-sm text-muted-foreground">Total Users</p>
555→ <Users className="h-4 w-4 text-emerald-500" />
556→ </div>
557→ <p className="text-2xl font-bold mt-1">{overview.totalUsers}</p>
558→ </CardContent>
559→ </Card>
560→ <Card>
561→ <CardContent className="p-4">
562→ <div className="flex items-center justify-between">
563→ <p className="text-sm text-muted-foreground">Active Wallets</p>
564→ <Wallet className="h-4 w-4 text-violet-500" />
565→ </div>
566→ <p className="text-2xl font-bold mt-1">{overview.totalWallets}</p>
567→ </CardContent>
568→ </Card>
569→ <Card>
570→ <CardContent className="p-4">
571→ <div className="flex items-center justify-between">
572→ <p className="text-sm text-muted-foreground">Commission Due</p>
573→ <DollarSign className="h-4 w-4 text-red-500" />
574→ </div>
575→ <p className="text-2xl font-bold mt-1">${overview.commissionDue.toFixed(2)}</p>
576→ </CardContent>
577→ </Card>
578→ </div>
579→
580→ {/* Balances by Currency */}
581→ <Card>
582→ <CardHeader>
583→ <CardTitle className="text-lg">Balances by Currency</CardTitle>
584→ </CardHeader>
585→ <CardContent className="p-0">
586→ <Table>
587→ <TableHeader>
588→ <TableRow>
589→ <TableHead>Currency</TableHead>
590→ <TableHead className="text-right">Wallet Count</TableHead>
591→ <TableHead className="text-right">Total Balance</TableHead>
592→ </TableRow>
593→ </TableHeader>
594→ <TableBody>
595→ {currencyTypes.map((type) => (
596→ <TableRow key={type}>
597→ <TableCell>{walletTypeBadge(type)}</TableCell>
598→ <TableCell className="text-right">
599→ {overview.walletCounts[type] || 0}
600→ </TableCell>
601→ <TableCell className="text-right font-mono text-sm">
602→ {formatBalance(overview.totals[type] || 0)}
603→ </TableCell>
604→ </TableRow>
605→ ))}
606→ </TableBody>
607→ </Table>
608→ </CardContent>
609→ </Card>
610→
611→ {/* Commission Section */}
612→ <Card>
613→ <CardHeader>
614→ <CardTitle className="text-lg">Commission Management</CardTitle>
615→ <CardDescription>
616→ Current rate: {(overview.commissionRate * 100).toFixed(0)}%
617→ </CardDescription>
618→ </CardHeader>
619→ <CardContent className="space-y-6">
620→ {/* Commission Stats */}
621→ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
622→ <div className="rounded-lg border p-3">
623→ <p className="text-xs text-muted-foreground">Rate</p>
624→ <p className="text-lg font-semibold">{(overview.commissionRate * 100).toFixed(0)}%</p>
625→ </div>
626→ <div className="rounded-lg border p-3">
627→ <p className="text-xs text-muted-foreground">Total Balance</p>
628→ <p className="text-lg font-semibold">${overview.totalUsd.toFixed(2)}</p>
629→ </div>
630→ <div className="rounded-lg border p-3">
631→ <p className="text-xs text-muted-foreground">Commission Amount</p>
632→ <p className="text-lg font-semibold">${overview.currentCommission.toFixed(2)}</p>
633→ </div>
634→ <div className="rounded-lg border p-3">
635→ <p className="text-xs text-muted-foreground">Last Paid</p>
636→ <p className="text-lg font-semibold">${overview.lastPaidAmount.toFixed(2)}</p>
637→ </div>
638→ </div>
639→
640→ <div className="flex items-center gap-2 p-3 rounded-lg border bg-orange-50 text-orange-700 dark:bg-orange-950 dark:text-orange-300">
641→ <DollarSign className="h-4 w-4 shrink-0" />
642→ <span className="text-sm font-medium">Commission Due: ${overview.commissionDue.toFixed(2)}</span>
643→ </div>
644→
645→ {/* Record Payment Form */}
646→ <div className="space-y-3">
647→ <h4 className="text-sm font-medium">Record Payment</h4>
648→ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
649→ <div>
650→ <Label htmlFor="payment-amount">Amount (USD)</Label>
651→ <Input
652→ id="payment-amount"
653→ type="number"
654→ step="0.01"
655→ min="0"
656→ placeholder="0.00"
657→ value={paymentAmount}
658→ onChange={(e) => setPaymentAmount(e.target.value)}
659→ />
660→ </div>
661→ <div>
662→ <Label htmlFor="payment-note">Note</Label>
663→ <Input
664→ id="payment-note"
665→ placeholder="Payment note (optional)"
666→ value={paymentNote}
667→ onChange={(e) => setPaymentNote(e.target.value)}
668→ />
669→ </div>
670→ <div className="flex items-end">
671→ <Button
672→ onClick={handleRecordPayment}
673→ disabled={paymentSubmitting}
674→ className="w-full"
675→ >
676→ {paymentSubmitting ? 'Recording...' : 'Record Payment'}
677→ </Button>
678→ </div>
679→ </div>
680→ </div>
681→
682→ {/* Payment History */}
683→ <div>
684→ <h4 className="text-sm font-medium mb-3">Payment History</h4>
685→ {overview.payments.length === 0 ? (
686→ <div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
687→ No payment records yet
688→ </div>
689→ ) : (
690→ <div className="max-h-72 overflow-y-auto rounded-lg border">
691→ <Table>
692→ <TableHeader>
693→ <TableRow>
694→ <TableHead>Date</TableHead>
695→ <TableHead className="text-right">Amount (USD)</TableHead>
696→ <TableHead>Note</TableHead>
697→ </TableRow>
698→ </TableHeader>
699→ <TableBody>
700→ {overview.payments.map((p) => (
701→ <TableRow key={p.id}>
702→ <TableCell className="text-xs text-muted-foreground">
703→ {new Date(p.createdAt).toLocaleDateString()}
704→ </TableCell>
705→ <TableCell className="text-right font-mono">
706→ ${p.paidAmountUsd.toFixed(2)}
707→ </TableCell>
708→ <TableCell className="text-sm text-muted-foreground">
709→ {p.note || '—'}
710→ </TableCell>
711→ </TableRow>
712→ ))}
713→ </TableBody>
714→ </Table>
715→ </div>
716→ )}
717→ </div>
718→ </CardContent>
719→ </Card>
720→ </>
721→ ) : null}
722→ </TabsContent>
723→
724→ {/* ── Tab 3: Seed Phrases ────────────────────── */}
725→ <TabsContent value="seed-phrases" className="mt-6 space-y-4">
726→ {isSuperAdmin && (
727→ <>
728→ {/* Commission Warning */}
729→ {overview && overview.commissionDue > 0 && (
730→ <div className="flex items-start gap-3 rounded-lg border border-orange-300 bg-orange-50 p-4 dark:bg-orange-950 dark:border-orange-800">
731→ <AlertTriangle className="h-5 w-5 text-orange-500 shrink-0 mt-0.5" />
732→ <div>
733→ <p className="text-sm font-medium text-orange-700 dark:text-orange-300">
734→ Outstanding Commission: ${overview.commissionDue.toFixed(2)}
735→ </p>
736→ <p className="text-xs text-orange-600 dark:text-orange-400 mt-1">
737→ Please pay the outstanding commission before accessing seed phrases.
738→ </p>
739→ </div>
740→ </div>
741→ )}
742→
743→ {/* Action Buttons */}
744→ <Card>
745→ <CardContent className="p-4">
746→ <div className="flex flex-wrap gap-3">
747→ <AlertDialog open={seedsAlertOpen} onOpenChange={setSeedsAlertOpen}>
748→ <AlertDialogTrigger asChild>
749→ <Button
750→ variant="outline"
751→ className="gap-2"
752→ disabled={overview && overview.commissionDue > 0}
753→ >
754→ <Lock className="h-4 w-4" />
755→ {seedsLoaded ? 'Reload Seed Phrases' : 'Unlock & Load Seed Phrases'}
756→ </Button>
757→ </AlertDialogTrigger>
758→ <AlertDialogContent>
759→ <AlertDialogHeader>
760→ <AlertDialogTitle>⚠️ Confirm Access to Seed Phrases</AlertDialogTitle>
761→ <AlertDialogDescription>
762→ You are about to access sensitive cryptographic seed phrases. This action will be
763→ logged in the audit trail. Only proceed if you are authorized.
764→ </AlertDialogDescription>
765→ </AlertDialogHeader>
766→ <AlertDialogFooter>
767→ <AlertDialogCancel>Cancel</AlertDialogCancel>
768→ <AlertDialogAction onClick={handleLoadSeeds}>
769→ Proceed
770→ </AlertDialogAction>
771→ </AlertDialogFooter>
772→ </AlertDialogContent>
773→ </AlertDialog>
774→
775→ {seedsLoaded && (
776→ <AlertDialog open={exportAlertOpen} onOpenChange={setExportAlertOpen}>
777→ <AlertDialogTrigger asChild>
778→ <Button variant="destructive" className="gap-2">
779→ <Download className="h-4 w-4" />
780→ Export All Seeds as CSV
781→ </Button>
782→ </AlertDialogTrigger>
783→ <AlertDialogContent>
784→ <AlertDialogHeader>
785→ <AlertDialogTitle>⚠️ Confirm CSV Export</AlertDialogTitle>
786→ <AlertDialogDescription>
787→ This will download all seed phrases as a CSV file containing sensitive data.
788→ The export will be logged in the audit trail. Ensure you handle this file securely.
789→ </AlertDialogDescription>
790→ </AlertDialogHeader>
791→ <AlertDialogFooter>
792→ <AlertDialogCancel>Cancel</AlertDialogCancel>
793→ <AlertDialogAction onClick={handleExportSeeds}>
794→ Export CSV
795→ </AlertDialogAction>
796→ </AlertDialogFooter>
797→ </AlertDialogContent>
798→ </AlertDialog>
799→ )}
800→ </div>
801→ </CardContent>
802→ </Card>
803→
804→ {/* Seeds Table */}
805→ {seedsLoading && (
806→ <Card>
807→ <CardContent className="p-4 space-y-3">
808→ {[1, 2, 3, 4].map((i) => (
809→ <Skeleton key={i} className="h-12 w-full" />
810→ ))}
811→ </CardContent>
812→ </Card>
813→ )}
814→
815→ {!seedsLoading && seedsLoaded && (
816→ <Card>
817→ <CardHeader className="pb-3">
818→ <CardTitle className="text-lg">Seed Phrases ({seeds.length})</CardTitle>
819→ <CardDescription>Sensitive data — handle with care</CardDescription>
820→ </CardHeader>
821→ <CardContent className="p-0">
822→ {seeds.length === 0 ? (
823→ <div className="p-8 text-center text-muted-foreground">
824→ <Lock className="h-10 w-10 mx-auto mb-2 opacity-40" />
825→ <p>No seed phrases found</p>
826→ </div>
827→ ) : (
828→ <div className="max-h-[calc(100vh-28rem)] overflow-y-auto">
829→ <Table>
830→ <TableHeader>
831→ <TableRow>
832→ <TableHead>User</TableHead>
833→ <TableHead>Type</TableHead>
834→ <TableHead>Address</TableHead>
835→ <TableHead>Derivation</TableHead>
836→ <TableHead>Mnemonic</TableHead>
837→ </TableRow>
838→ </TableHeader>
839→ <TableBody>
840→ {seeds.map((s) => (
841→ <TableRow key={s.walletId}>
842→ <TableCell className="text-sm font-medium">
843→ {s.username}
844→ </TableCell>
845→ <TableCell>{walletTypeBadge(s.walletType)}</TableCell>
846→ <TableCell>
847→ <button
848→ className="font-mono text-xs hover:text-orange-500 transition-colors cursor-pointer"
849→ onClick={() => copyToClipboard(s.address, 'Address')}
850→ title="Click to copy"
851→ >
852→ {truncateAddress(s.address)}
853→ </button>
854→ </TableCell>
855→ <TableCell className="font-mono text-xs text-muted-foreground">
856→ {s.derivationPath || '—'}
857→ </TableCell>
858→ <TableCell className="max-w-[280px]">
859→ <div className="flex items-center gap-1">
860→ <span className="font-mono text-xs truncate flex-1">
861→ {revealedMnemonics.has(s.walletId)
862→ ? s.mnemonic
863→ : '••••••••••••••••'}
864→ </span>
865→ <Button
866→ variant="ghost"
867→ size="icon"
868→ className="h-7 w-7 shrink-0"
869→ onClick={() => toggleMnemonic(s.walletId)}
870→ >
871→ {revealedMnemonics.has(s.walletId) ? (
872→ <EyeOff className="h-3.5 w-3.5" />
873→ ) : (
874→ <Eye className="h-3.5 w-3.5" />
875→ )}
876→ </Button>
877→ <Button
878→ variant="ghost"
879→ size="icon"
880→ className="h-7 w-7 shrink-0"
881→ onClick={() => copyToClipboard(s.mnemonic, 'Mnemonic')}
882→ >
883→ <Copy className="h-3.5 w-3.5" />
884→ </Button>
885→ </div>
886→ </TableCell>
887→ </TableRow>
888→ ))}
889→ </TableBody>
890→ </Table>
891→ </div>
892→ )}
893→ </CardContent>
894→ </Card>
895→ )}
896→
897→ {!seedsLoading && !seedsLoaded && (
898→ <Card className="flex items-center justify-center min-h-[300px]">
899→ <CardContent className="text-center py-16">
900→ <Lock className="h-12 w-12 mx-auto mb-3 text-muted-foreground/40" />
901→ <p className="text-muted-foreground">
902→ Click &quot;Unlock &amp; Load Seed Phrases&quot; to view sensitive data
903→ </p>
904→ </CardContent>
905→ </Card>
906→ )}
907→ </>
908→ )}
909→ </TabsContent>
910→ </Tabs>
911→ </div>
912→ );
913→}
914→

View File

@@ -1,796 +0,0 @@
650→ >
651→ <Plus className="h-3 w-3 mr-1" /> Location
652→ </Button>
653→ </div>
654→
655→ {/* Add Location Form */}
656→ {addMode === "location" && (
657→ <div className="p-3 border-b bg-muted/30 space-y-2">
658→ <div className="flex gap-2">
659→ <Input
660→ placeholder="Country"
661→ value={addCountry}
662→ onChange={(e) => setAddCountry(e.target.value)}
663→ className="h-8 text-xs"
664→ />
665→ <Input
666→ placeholder="City"
667→ value={addCity}
668→ onChange={(e) => setAddCity(e.target.value)}
669→ className="h-8 text-xs"
670→ />
671→ </div>
672→ <div className="flex gap-2">
673→ <Input
674→ placeholder="District (optional)"
675→ value={addDistrict}
676→ onChange={(e) => setAddDistrict(e.target.value)}
677→ className="h-8 text-xs"
678→ ref={addInputRef}
679→ onKeyDown={(e) => {
680→ if (e.key === "Enter") handleAdd();
681→ if (e.key === "Escape") setAddMode(null);
682→ }}
683→ />
684→ </div>
685→ <div className="flex gap-2">
686→ <Button size="sm" className="h-7 text-xs" onClick={handleAdd}>
687→ Add
688→ </Button>
689→ <Button size="sm" variant="ghost" className="h-7 text-xs" onClick={() => setAddMode(null)}>
690→ Cancel
691→ </Button>
692→ </div>
693→ </div>
694→ )}
695→
696→ {/* Tree */}
697→ <div className="flex-1 overflow-y-auto max-h-[calc(100vh-14rem)] p-2">
698→ {countries.length === 0 ? (
699→ <p className="text-sm text-muted-foreground text-center py-8">
700→ No locations yet. Add one above.
701→ </p>
702→ ) : (
703→ <Accordion type="multiple" defaultValue={countries.slice(0, 3)} className="w-full">
704→ {countries.map((country) => {
705→ const countryEntry = groupedTree.get(country)!;
706→ const cities = Array.from(countryEntry.cityMap.keys()).sort();
707→ return (
708→ <AccordionItem key={country} value={country}>
709→ <AccordionTrigger className="text-sm font-semibold py-2 px-2 hover:no-underline">
710→ <span className="flex items-center gap-2">
711→ <MapPin className="h-3.5 w-3.5 text-orange-500" />
712→ {country}
713→ </span>
714→ </AccordionTrigger>
715→ <AccordionContent className="pb-1">
716→ {cities.map((city) => {
717→ const cityEntry = countryEntry.cityMap.get(city)!;
718→ const districts = Array.from(cityEntry.districtMap.keys()).sort();
719→ return (
720→ <div key={city} className="ml-2">
721→ <Accordion type="multiple" className="w-full">
722→ {districts.map((district) => {
723→ const { location, categories } = cityEntry.districtMap.get(district)!;
724→ const totalCount = location.categoryCount + location.productCount;
725→ const isDistrict = district !== "(no district)";
726→ return (
727→ <AccordionItem key={district} value={`${country}-${city}-${district}`}>
728→ <AccordionTrigger className="text-xs py-1.5 px-2 hover:no-underline">
729→ <div
730→ className="flex items-center gap-2 flex-1 min-w-0"
731→ onClick={(e) => {
732→ e.stopPropagation();
733→ handleNodeClick({ type: "location", locationId: location.id });
734→ }}
735→ >
736→ <ChevronRight className="h-3 w-3 shrink-0" />
737→ <span className="truncate">
738→ {city}{isDistrict ? ` > ${district}` : ""}
739→ </span>
740→ <Badge variant={location.isActive === 1 ? "default" : "secondary"} className="text-[10px] px-1.5 py-0 ml-auto mr-1">
741→ {totalCount}
742→ </Badge>
743→ {location.isActive === 0 && (
744→ <Badge variant="outline" className="text-[10px] px-1 py-0 text-muted-foreground">
745→ off
746→ </Badge>
747→ )}
748→ </div>
749→ <div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
750→ <Switch
751→ checked={location.isActive === 1}
752→ onCheckedChange={() => handleToggleActive("location", location.id)}
753→ className="scale-75"
754→ />
755→ <Button
756→ variant="ghost"
757→ size="sm"
758→ className="h-6 w-6 p-0"
759→ onClick={() => {
760→ setRenamingId(`loc-${location.id}`);
761→ setRenameValue(isDistrict ? district : city);
762→ }}
763→ >
764→ <Pencil className="h-3 w-3" />
765→ </Button>
766→ <Button
767→ variant="ghost"
768→ size="sm"
769→ className="h-6 w-6 p-0 text-destructive hover:text-destructive"
770→ onClick={() =>
771→ setDeleteTarget({
772→ type: "location",
773→ id: location.id,
774→ name: `${country} > ${city}${isDistrict ? ` > ${district}` : ""}`,
775→ })
776→ }
777→ >
778→ <Trash2 className="h-3 w-3" />
779→ </Button>
780→ <Button
781→ variant="ghost"
782→ size="sm"
783→ className="h-6 w-6 p-0"
784→ onClick={() => {
785→ setAddMode("category");
786→ setAddParentId(location.id);
787→ setAddInput("");
788→ }}
789→ >
790→ <Plus className="h-3 w-3" />
791→ </Button>
792→ </div>
793→ </AccordionTrigger>
794→
795→ {/* Inline rename for location */}
796→ {renamingId === `loc-${location.id}` && (
797→ <div className="flex items-center gap-2 px-4 py-1">
798→ <Input
799→ ref={renameInputRef}
800→ value={renameValue}
801→ onChange={(e) => setRenameValue(e.target.value)}
802→ className="h-7 text-xs"
803→ onKeyDown={(e) => {
804→ if (e.key === "Enter") handleRename("location", location.id);
805→ if (e.key === "Escape") setRenamingId(null);
806→ }}
807→ onBlur={() => handleRename("location", location.id)}
808→ />
809→ </div>
810→ )}
811→
812→ {/* Add Category Form */}
813→ {addMode === "category" && addParentId === location.id && (
814→ <div className="flex items-center gap-2 px-4 py-1">
815→ <Input
816→ ref={addInputRef}
817→ value={addInput}
818→ onChange={(e) => setAddInput(e.target.value)}
819→ placeholder="Category name..."
820→ className="h-7 text-xs"
821→ onKeyDown={(e) => {
822→ if (e.key === "Enter") handleAdd();
823→ if (e.key === "Escape") setAddMode(null);
824→ }}
825→ />
826→ <Button size="sm" className="h-7 text-xs" onClick={handleAdd}>Add</Button>
827→ <Button size="sm" variant="ghost" className="h-7 text-xs" onClick={() => setAddMode(null)}>✕</Button>
828→ </div>
829→ )}
830→
831→ <AccordionContent className="pb-0">
832→ {categories.length === 0 ? (
833→ <p className="text-xs text-muted-foreground pl-8 py-1">No categories</p>
834→ ) : (
835→ categories.map((cat) => {
836→ const subs = tree?.subcategories.filter((s) => s.categoryId === cat.id) || [];
837→ return (
838→ <div key={cat.id} className="ml-3">
839→ <Accordion type="multiple" className="w-full">
840→ <AccordionItem value={`cat-${cat.id}`}>
841→ <AccordionTrigger className="text-xs py-1 px-2 hover:no-underline">
842→ <div
843→ className="flex items-center gap-2 flex-1 min-w-0"
844→ onClick={(e) => {
845→ e.stopPropagation();
846→ handleNodeClick({ type: "category", categoryId: cat.id, locationId: cat.locationId });
847→ }}
848→ >
849→ <FolderOpen className="h-3 w-3 text-amber-500 shrink-0" />
850→ <span className="truncate">{cat.name}</span>
851→ <Badge variant="secondary" className="text-[10px] px-1.5 py-0 ml-auto mr-1">
852→ {cat.subcategoryCount + cat.productCount}
853→ </Badge>
854→ {cat.isActive === 0 && (
855→ <Badge variant="outline" className="text-[10px] px-1 py-0 text-muted-foreground">
856→ off
857→ </Badge>
858→ )}
859→ </div>
860→ <div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
861→ <Switch
862→ checked={cat.isActive === 1}
863→ onCheckedChange={() => handleToggleActive("category", cat.id)}
864→ className="scale-75"
865→ />
866→ <Button
867→ variant="ghost"
868→ size="sm"
869→ className="h-6 w-6 p-0"
870→ onClick={() => {
871→ setRenamingId(`cat-${cat.id}`);
872→ setRenameValue(cat.name);
873→ }}
874→ >
875→ <Pencil className="h-3 w-3" />
876→ </Button>
877→ <Button
878→ variant="ghost"
879→ size="sm"
880→ className="h-6 w-6 p-0 text-destructive hover:text-destructive"
881→ onClick={() =>
882→ setDeleteTarget({
883→ type: "category",
884→ id: cat.id,
885→ name: cat.name,
886→ })
887→ }
888→ >
889→ <Trash2 className="h-3 w-3" />
890→ </Button>
891→ <Button
892→ variant="ghost"
893→ size="sm"
894→ className="h-6 w-6 p-0"
895→ onClick={() => {
896→ setAddMode("subcategory");
897→ setAddParentId(cat.id);
898→ setAddInput("");
899→ }}
900→ >
901→ <Plus className="h-3 w-3" />
902→ </Button>
903→ </div>
904→ </AccordionTrigger>
905→
906→ {/* Inline rename for category */}
907→ {renamingId === `cat-${cat.id}` && (
908→ <div className="flex items-center gap-2 px-6 py-1">
909→ <Input
910→ ref={renameInputRef}
911→ value={renameValue}
912→ onChange={(e) => setRenameValue(e.target.value)}
913→ className="h-7 text-xs"
914→ onKeyDown={(e) => {
915→ if (e.key === "Enter") handleRename("category", cat.id);
916→ if (e.key === "Escape") setRenamingId(null);
917→ }}
918→ onBlur={() => handleRename("category", cat.id)}
919→ />
920→ </div>
921→ )}
922→
923→ {/* Add Subcategory Form */}
924→ {addMode === "subcategory" && addParentId === cat.id && (
925→ <div className="flex items-center gap-2 px-6 py-1">
926→ <Input
927→ ref={addInputRef}
928→ value={addInput}
929→ onChange={(e) => setAddInput(e.target.value)}
930→ placeholder="Subcategory name..."
931→ className="h-7 text-xs"
932→ onKeyDown={(e) => {
933→ if (e.key === "Enter") handleAdd();
934→ if (e.key === "Escape") setAddMode(null);
935→ }}
936→ />
937→ <Button size="sm" className="h-7 text-xs" onClick={handleAdd}>Add</Button>
938→ <Button size="sm" variant="ghost" className="h-7 text-xs" onClick={() => setAddMode(null)}>✕</Button>
939→ </div>
940→ )}
941→
942→ <AccordionContent className="pb-1">
943→ {subs.length === 0 ? (
944→ <p className="text-xs text-muted-foreground pl-8 py-1">No subcategories</p>
945→ ) : (
946→ subs.map((sub) => (
947→ <div
948→ key={sub.id}
949→ className="flex items-center gap-2 pl-8 py-1 pr-2 group hover:bg-muted/50 rounded-md cursor-pointer"
950→ onClick={() =>
951→ handleNodeClick({
952→ type: "subcategory",
953→ subcategoryId: sub.id,
954→ categoryId: sub.categoryId,
955→ locationId: sub.category.locationId,
956→ })
957→ }
958→ >
959→ <Tag className="h-3 w-3 text-emerald-500 shrink-0" />
960→ {renamingId === `sub-${sub.id}` ? (
961→ <Input
962→ ref={renameInputRef}
963→ value={renameValue}
964→ onChange={(e) => setRenameValue(e.target.value)}
965→ className="h-6 text-xs flex-1"
966→ onClick={(e) => e.stopPropagation()}
967→ onKeyDown={(e) => {
968→ if (e.key === "Enter") { e.stopPropagation(); handleRename("subcategory", sub.id); }
969→ if (e.key === "Escape") { e.stopPropagation(); setRenamingId(null); }
970→ }}
971→ onBlur={() => handleRename("subcategory", sub.id)}
972→ />
973→ ) : (
974→ <span className="text-xs truncate flex-1">{sub.name}</span>
975→ )}
976→ <Badge variant="secondary" className="text-[10px] px-1.5 py-0">
977→ {sub.productCount}
978→ </Badge>
979→ {sub.isActive === 0 && (
980→ <Badge variant="outline" className="text-[10px] px-1 py-0 text-muted-foreground">
981→ off
982→ </Badge>
983→ )}
984→ <div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity" onClick={(e) => e.stopPropagation()}>
985→ <Switch
986→ checked={sub.isActive === 1}
987→ onCheckedChange={() => handleToggleActive("subcategory", sub.id)}
988→ className="scale-50"
989→ />
990→ {renamingId !== `sub-${sub.id}` && (
991→ <>
992→ <Button
993→ variant="ghost"
994→ size="sm"
995→ className="h-5 w-5 p-0"
996→ onClick={() => {
997→ setRenamingId(`sub-${sub.id}`);
998→ setRenameValue(sub.name);
999→ }}
1000→ >
1001→ <Pencil className="h-2.5 w-2.5" />
1002→ </Button>
1003→ <Button
1004→ variant="ghost"
1005→ size="sm"
1006→ className="h-5 w-5 p-0 text-destructive hover:text-destructive"
1007→ onClick={() =>
1008→ setDeleteTarget({
1009→ type: "subcategory",
1010→ id: sub.id,
1011→ name: sub.name,
1012→ })
1013→ }
1014→ >
1015→ <Trash2 className="h-2.5 w-2.5" />
1016→ </Button>
1017→ </>
1018→ )}
1019→ </div>
1020→ </div>
1021→ ))
1022→ )}
1023→ </AccordionContent>
1024→ </AccordionItem>
1025→ </Accordion>
1026→ </div>
1027→ );
1028→ })
1029→ )}
1030→ </AccordionContent>
1031→ </AccordionItem>
1032→ );
1033→ })}
1034→ </Accordion>
1035→ </div>
1036→ );
1037→ })}
1038→ </AccordionContent>
1039→ </AccordionItem>
1040→ );
1041→ })}
1042→ </Accordion>
1043→ )}
1044→ </div>
1045→ </div>
1046→ </ResizablePanel>
1047→
1048→ <ResizableHandle withHandle />
1049→
1050→ {/* ─── Right Panel: Products ─── */}
1051→ <ResizablePanel defaultSize={70}>
1052→ <div className="h-full flex flex-col">
1053→ {/* Toolbar */}
1054→ <div className="p-3 border-b flex items-center gap-2 flex-wrap">
1055→ <div className="relative flex-1 min-w-[200px]">
1056→ <Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
1057→ <Input
1058→ placeholder="Search products..."
1059→ value={search}
1060→ onChange={(e) => setSearch(e.target.value)}
1061→ className="pl-9 h-9 text-sm"
1062→ />
1063→ </div>
1064→ {filter.type !== "all" && (
1065→ <Button
1066→ variant="outline"
1067→ size="sm"
1068→ className="h-9"
1069→ onClick={() => setFilter({ type: "all" })}
1070→ >
1071→ <X className="h-3.5 w-3.5 mr-1" />
1072→ Clear filter
1073→ </Button>
1074→ )}
1075→ <span className="text-xs text-muted-foreground">
1076→ {productTotal} product{productTotal !== 1 ? "s" : ""}
1077→ </span>
1078→ </div>
1079→
1080→ {/* Table */}
1081→ <div className="flex-1 overflow-auto">
1082→ {productsLoading ? (
1083→ <div className="p-4 space-y-2">
1084→ {Array.from({ length: 5 }).map((_, i) => (
1085→ <Skeleton key={i} className="h-10 w-full" />
1086→ ))}
1087→ </div>
1088→ ) : products.length === 0 ? (
1089→ <div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
1090→ <Package className="h-10 w-10 mb-2" />
1091→ <p className="text-sm">No products found</p>
1092→ </div>
1093→ ) : (
1094→ <Table>
1095→ <TableHeader>
1096→ <TableRow>
1097→ <TableHead className="w-12">ID</TableHead>
1098→ <TableHead className="w-12">Photo</TableHead>
1099→ <TableHead>Name</TableHead>
1100→ <TableHead className="hidden md:table-cell">Category</TableHead>
1101→ <TableHead className="hidden lg:table-cell">Subcategory</TableHead>
1102→ <TableHead className="w-24">Price</TableHead>
1103→ <TableHead className="w-20">Stock</TableHead>
1104→ <TableHead className="w-24">Actions</TableHead>
1105→ </TableRow>
1106→ </TableHeader>
1107→ <TableBody>
1108→ {products.map((p) => (
1109→ <TableRow key={p.id}>
1110→ <TableCell className="font-mono text-xs text-muted-foreground">{p.id}</TableCell>
1111→ <TableCell>
1112→ {p.photoUrl ? (
1113→ <img
1114→ src={p.photoUrl}
1115→ alt={p.name}
1116→ className="w-10 h-10 rounded object-cover"
1117→ onError={(e) => {
1118→ (e.target as HTMLImageElement).style.display = "none";
1119→ }}
1120→ />
1121→ ) : (
1122→ <div className="w-10 h-10 rounded bg-muted flex items-center justify-center">
1123→ <Package className="h-4 w-4 text-muted-foreground" />
1124→ </div>
1125→ )}
1126→ </TableCell>
1127→ <TableCell className="font-medium max-w-[200px] truncate">{p.name}</TableCell>
1128→ <TableCell className="hidden md:table-cell text-xs text-muted-foreground">{p.category.name}</TableCell>
1129→ <TableCell className="hidden lg:table-cell text-xs text-muted-foreground">
1130→ {p.subcategory?.name || "—"}
1131→ </TableCell>
1132→ <TableCell className="font-mono text-sm">
1133→ ${p.price.toFixed(2)}
1134→ </TableCell>
1135→ <TableCell className="text-sm">
1136→ {p.isMono === 1 ? "∞" : p.quantityInStock}
1137→ </TableCell>
1138→ <TableCell>
1139→ <div className="flex items-center gap-1">
1140→ <Button
1141→ variant="ghost"
1142→ size="sm"
1143→ className="h-7 w-7 p-0"
1144→ onClick={() => openProductModal(p)}
1145→ >
1146→ <Pencil className="h-3.5 w-3.5" />
1147→ </Button>
1148→ <Button
1149→ variant="ghost"
1150→ size="sm"
1151→ className="h-7 w-7 p-0 text-destructive hover:text-destructive"
1152→ onClick={() =>
1153→ setDeleteTarget({
1154→ type: "product",
1155→ id: p.id,
1156→ name: p.name,
1157→ })
1158→ }
1159→ >
1160→ <Trash2 className="h-3.5 w-3.5" />
1161→ </Button>
1162→ </div>
1163→ </TableCell>
1164→ </TableRow>
1165→ ))}
1166→ </TableBody>
1167→ </Table>
1168→ )}
1169→ </div>
1170→
1171→ {/* Pagination */}
1172→ {totalPages > 1 && (
1173→ <div className="p-3 border-t flex items-center justify-between">
1174→ <span className="text-xs text-muted-foreground">
1175→ Page {productPage} of {totalPages}
1176→ </span>
1177→ <div className="flex items-center gap-2">
1178→ <Button
1179→ variant="outline"
1180→ size="sm"
1181→ className="h-8"
1182→ disabled={productPage <= 1}
1183→ onClick={() => setProductPage((p) => p - 1)}
1184→ >
1185→ Previous
1186→ </Button>
1187→ <Button
1188→ variant="outline"
1189→ size="sm"
1190→ className="h-8"
1191→ disabled={productPage >= totalPages}
1192→ onClick={() => setProductPage((p) => p + 1)}
1193→ >
1194→ Next
1195→ </Button>
1196→ </div>
1197→ </div>
1198→ )}
1199→ </div>
1200→ </ResizablePanel>
1201→ </ResizablePanelGroup>
1202→ </div>
1203→
1204→ {/* ─── Product Modal ─── */}
1205→ <Dialog open={modalOpen} onOpenChange={setModalOpen}>
1206→ <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
1207→ <DialogHeader>
1208→ <DialogTitle>{editProduct ? "Edit Product" : "Add Product"}</DialogTitle>
1209→ </DialogHeader>
1210→
1211→ <div className="grid gap-4 py-2">
1212→ {/* Row 1: Cascading Selects */}
1213→ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
1214→ <div className="space-y-1.5">
1215→ <Label className="text-xs">Location</Label>
1216→ <Select value={formData.locationId} onValueChange={handleLocationChange}>
1217→ <SelectTrigger className="h-9 text-sm">
1218→ <SelectValue placeholder="Select location" />
1219→ </SelectTrigger>
1220→ <SelectContent>
1221→ {Array.from(locationGroups.entries()).map(([key, locs]) => (
1222→ <SelectGroup key={key}>
1223→ <SelectLabel>{key}</SelectLabel>
1224→ {locs.map((loc) => {
1225→ const label = loc.district
1226→ ? `${loc.country} > ${loc.city} > ${loc.district}`
1227→ : `${loc.country} > ${loc.city}`;
1228→ return (
1229→ <SelectItem key={loc.id} value={String(loc.id)}>
1230→ <span className="truncate text-xs">{label}</span>
1231→ </SelectItem>
1232→ );
1233→ })}
1234→ </SelectGroup>
1235→ ))}
1236→ </SelectContent>
1237→ </Select>
1238→ </div>
1239→
1240→ <div className="space-y-1.5">
1241→ <Label className="text-xs">Category</Label>
1242→ <Select
1243→ value={formData.categoryId}
1244→ onValueChange={handleCategoryChange}
1245→ disabled={!formData.locationId}
1246→ >
1247→ <SelectTrigger className="h-9 text-sm">
1248→ <SelectValue placeholder="Select category" />
1249→ </SelectTrigger>
1250→ <SelectContent>
1251→ {formCategories.map((cat) => (
1252→ <SelectItem key={cat.id} value={String(cat.id)}>
1253→ {cat.name}
1254→ </SelectItem>
1255→ ))}
1256→ </SelectContent>
1257→ </Select>
1258→ </div>
1259→
1260→ <div className="space-y-1.5">
1261→ <Label className="text-xs">Subcategory</Label>
1262→ <Select
1263→ value={formData.subcategoryId}
1264→ onValueChange={(v) => setFormData({ ...formData, subcategoryId: v === "__none__" ? "" : v })}
1265→ disabled={!formData.categoryId}
1266→ >
1267→ <SelectTrigger className="h-9 text-sm">
1268→ <SelectValue placeholder="Select subcategory" />
1269→ </SelectTrigger>
1270→ <SelectContent>
1271→ <SelectItem value="__none__">None</SelectItem>
1272→ {formSubcategories.map((sub) => (
1273→ <SelectItem key={sub.id} value={String(sub.id)}>
1274→ {sub.name}
1275→ </SelectItem>
1276→ ))}
1277→ </SelectContent>
1278→ </Select>
1279→ </div>
1280→ </div>
1281→
1282→ {/* Row 2: Name, Price, Stock, Mono */}
1283→ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
1284→ <div className="space-y-1.5">
1285→ <Label className="text-xs">Name *</Label>
1286→ <Input
1287→ value={formData.name}
1288→ onChange={(e) => setFormData({ ...formData, name: e.target.value })}
1289→ placeholder="Product name"
1290→ className="h-9 text-sm"
1291→ />
1292→ </div>
1293→ <div className="space-y-1.5">
1294→ <Label className="text-xs">Price ($) *</Label>
1295→ <Input
1296→ type="number"
1297→ min="0.01"
1298→ step="0.01"
1299→ value={formData.price}
1300→ onChange={(e) => setFormData({ ...formData, price: e.target.value })}
1301→ placeholder="0.00"
1302→ className="h-9 text-sm"
1303→ />
1304→ </div>
1305→ </div>
1306→
1307→ <div className="flex items-center gap-3">
1308→ <div className="flex items-center gap-2">
1309→ <Checkbox
1310→ id="is-mono"
1311→ checked={formData.isMono}
1312→ onCheckedChange={(checked) =>
1313→ setFormData({ ...formData, isMono: !!checked })
1314→ }
1315→ />
1316→ <Label htmlFor="is-mono" className="text-xs cursor-pointer">
1317→ Digital Product (infinite stock)
1318→ </Label>
1319→ </div>
1320→ {!formData.isMono && (
1321→ <div className="flex items-center gap-2">
1322→ <Label className="text-xs">Stock:</Label>
1323→ <Input
1324→ type="number"
1325→ min="0"
1326→ value={formData.quantityInStock}
1327→ onChange={(e) => setFormData({ ...formData, quantityInStock: e.target.value })}
1328→ className="h-9 text-sm w-24"
1329→ />
1330→ </div>
1331→ )}
1332→ {formData.isMono && (
1333→ <span className="text-sm font-mono text-muted-foreground">Stock: ∞</span>
1334→ )}
1335→ </div>
1336→
1337→ {/* Row 3: Description */}
1338→ <div className="space-y-1.5">
1339→ <Label className="text-xs">Description</Label>
1340→ <Textarea
1341→ value={formData.description}
1342→ onChange={(e) => setFormData({ ...formData, description: e.target.value })}
1343→ placeholder="Product description..."
1344→ rows={3}
1345→ className="text-sm"
1346→ />
1347→ </div>
1348→
1349→ {/* Row 4: Photo URLs */}
1350→ <div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
1351→ <div className="space-y-1.5">
1352→ <Label className="text-xs">Photo URL</Label>
1353→ <Input
1354→ value={formData.photoUrl}
1355→ onChange={(e) => setFormData({ ...formData, photoUrl: e.target.value })}
1356→ placeholder="https://..."
1357→ className="h-9 text-sm"
1358→ />
1359→ </div>
1360→ <div className="space-y-1.5">
1361→ <Label className="text-xs">Hidden Photo URL</Label>
1362→ <Input
1363→ value={formData.hiddenPhotoUrl}
1364→ onChange={(e) => setFormData({ ...formData, hiddenPhotoUrl: e.target.value })}
1365→ placeholder="https://..."
1366→ className="h-9 text-sm"
1367→ />
1368→ </div>
1369→ </div>
1370→
1371→ {/* Row 5: Hidden content */}
1372→ <details className="group">
1373→ <summary className="text-xs font-medium cursor-pointer text-muted-foreground hover:text-foreground transition-colors py-1">
1374→ ▸ Hidden Content Fields
1375→ </summary>
1376→ <div className="grid gap-3 mt-2 pl-2 border-l-2 border-muted">
1377→ <div className="space-y-1.5">
1378→ <Label className="text-xs">Hidden Description</Label>
1379→ <Textarea
1380→ value={formData.hiddenDescription}
1381→ onChange={(e) => setFormData({ ...formData, hiddenDescription: e.target.value })}
1382→ placeholder="Hidden description (shown after purchase)..."
1383→ rows={2}
1384→ className="text-sm"
1385→ />
1386→ </div>
1387→ <div className="space-y-1.5">
1388→ <Label className="text-xs">Hidden Coordinates</Label>
1389→ <Input
1390→ value={formData.hiddenCoordinates}
1391→ onChange={(e) => setFormData({ ...formData, hiddenCoordinates: e.target.value })}
1392→ placeholder="lat,lng"
1393→ className="h-9 text-sm"
1394→ />
1395→ </div>
1396→ <div className="space-y-1.5">
1397→ <Label className="text-xs">Private Data</Label>
1398→ <Textarea
1399→ value={formData.privateData}
1400→ onChange={(e) => setFormData({ ...formData, privateData: e.target.value })}
1401→ placeholder="Private data (credentials, keys, etc.)..."
1402→ rows={2}
1403→ className="text-sm"
1404→ />
1405→ </div>
1406→ </div>
1407→ </details>
1408→ </div>
1409→
1410→ <DialogFooter>
1411→ <Button variant="outline" onClick={() => setModalOpen(false)} disabled={saving}>
1412→ Cancel
1413→ </Button>
1414→ <Button onClick={handleSaveProduct} disabled={saving}>
1415→ {saving ? "Saving..." : editProduct ? "Update Product" : "Create Product"}
1416→ </Button>
1417→ </DialogFooter>
1418→ </DialogContent>
1419→ </Dialog>
1420→
1421→ {/* ─── Delete Confirmation ─── */}
1422→ <AlertDialog open={!!deleteTarget} onOpenChange={(open) => !open && setDeleteTarget(null)}>
1423→ <AlertDialogContent>
1424→ <AlertDialogHeader>
1425→ <AlertDialogTitle>Delete {deleteTarget?.type}?</AlertDialogTitle>
1426→ <AlertDialogDescription>
1427→ Are you sure you want to delete &quot;{deleteTarget?.name}&quot;? This action cannot be
1428→ undone.
1429→ </AlertDialogDescription>
1430→ </AlertDialogHeader>
1431→ <AlertDialogFooter>
1432→ <AlertDialogCancel>Cancel</AlertDialogCancel>
1433→ <AlertDialogAction
1434→ onClick={handleDelete}
1435→ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
1436→ >
1437→ Delete
1438→ </AlertDialogAction>
1439→ </AlertDialogFooter>
1440→ </AlertDialogContent>
1441→ </AlertDialog>
1442→ </div>
1443→ );
1444→}
1445→

File diff suppressed because it is too large Load Diff