chore: remove agent artifacts
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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` (001–012). 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. В таблицах — превью 40–50px.
|
||||
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`. При изменении бэкенда — перегенерировать разделы 6–7.*
|
||||
751→
|
||||
@@ -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` (001–012). 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. В таблицах — превью 40–50px.
|
||||
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`. При изменении бэкенда — перегенерировать разделы 6–7.*
|
||||
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
@@ -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 "Unlock & Load Seed Phrases" to view sensitive data
|
||||
903→ </p>
|
||||
904→ </CardContent>
|
||||
905→ </Card>
|
||||
906→ )}
|
||||
907→ </>
|
||||
908→ )}
|
||||
909→ </TabsContent>
|
||||
910→ </Tabs>
|
||||
911→ </div>
|
||||
912→ );
|
||||
913→}
|
||||
914→
|
||||
@@ -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 "{deleteTarget?.name}"? 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
Reference in New Issue
Block a user