add admin control center ui and bot commands
This commit is contained in:
155
ADMIN.md
Normal file
155
ADMIN.md
Normal file
@@ -0,0 +1,155 @@
|
||||
# CarPass Admin Control Center
|
||||
|
||||
Admin Control Center дает администраторам закрытого пилота безопасный доступ к событиям сервиса, модерации СТО, просмотру данных и экспорту без прямого SQL.
|
||||
|
||||
## Доступ
|
||||
|
||||
Админка открывается в Mini App по `/admin.html` или командой бота `/admin`.
|
||||
|
||||
Роли:
|
||||
|
||||
- `super_admin`: полный доступ к пользователям, СТО, заявкам, заказ-нарядам, расходам, audit, export и системным настройкам.
|
||||
- `admin`: пользователи, СТО, модерация, заказ-наряды, базовая аналитика и экспорт без секретов.
|
||||
- `moderator`: заявки СТО, отзывы, блокировки и комментарии модерации.
|
||||
- `support`: поиск пользователя, авто, история действий и помощь без расширенных финансовых агрегатов.
|
||||
- `analyst`: агрегированная аналитика и обезличенные выгрузки без персональных данных.
|
||||
|
||||
Все чувствительные admin actions пишутся в `AuditLog`.
|
||||
|
||||
## Уведомления
|
||||
|
||||
Система создает `AdminNotification` в БД и best-effort отправляет Telegram-сообщение администраторам. Ошибка Telegram не ломает бизнес-flow.
|
||||
|
||||
Поддержанные события:
|
||||
|
||||
- новый пользователь;
|
||||
- первое авто пользователя;
|
||||
- новая заявка СТО;
|
||||
- изменение статуса заявки СТО;
|
||||
- одобрение, блокировка и разблокировка СТО;
|
||||
- security/system события через общий admin notification service.
|
||||
|
||||
Idempotency key защищает от дублей.
|
||||
|
||||
Env:
|
||||
|
||||
```env
|
||||
ADMIN_TELEGRAM_IDS=123,456
|
||||
ADMIN_NOTIFICATION_CHAT_ID=
|
||||
ADMIN_NOTIFY_NEW_USERS=true
|
||||
ADMIN_NOTIFY_STO_APPLICATIONS=true
|
||||
ADMIN_NOTIFY_SECURITY_EVENTS=true
|
||||
ADMIN_NOTIFY_SYSTEM_ERRORS=true
|
||||
```
|
||||
|
||||
## Admin API
|
||||
|
||||
Dashboard:
|
||||
|
||||
- `GET /api/admin/dashboard`
|
||||
|
||||
Notifications:
|
||||
|
||||
- `GET /api/admin/notifications`
|
||||
- `POST /api/admin/notifications/{id}/read`
|
||||
- `POST /api/admin/notifications/read-all`
|
||||
- `POST /api/admin/notifications/{id}/dismiss`
|
||||
|
||||
Data Explorer:
|
||||
|
||||
- `GET /api/admin/data/sources`
|
||||
- `POST /api/admin/data/query`
|
||||
- `POST /api/admin/data/export`
|
||||
|
||||
Users:
|
||||
|
||||
- `GET /api/admin/users`
|
||||
- `GET /api/admin/users/{id}`
|
||||
- `GET /api/admin/users/{id}/activity`
|
||||
- `POST /api/admin/users/{id}/note`
|
||||
- `POST /api/admin/users/{id}/block`
|
||||
- `POST /api/admin/users/{id}/unblock`
|
||||
|
||||
СТО:
|
||||
|
||||
- `GET /api/admin/sto`
|
||||
- `GET /api/admin/sto/{id}`
|
||||
- `GET /api/admin/sto-applications`
|
||||
- `POST /api/admin/sto-applications/{id}/approve`
|
||||
- `POST /api/admin/sto-applications/{id}/reject`
|
||||
- `POST /api/admin/sto-applications/{id}/request-changes`
|
||||
- `POST /api/admin/sto/{id}/suspend`
|
||||
- `POST /api/admin/sto/{id}/unsuspend`
|
||||
|
||||
Audit and exports:
|
||||
|
||||
- `GET /api/admin/audit-log`
|
||||
- `GET /api/admin/exports`
|
||||
- `GET /api/admin/exports/{id}`
|
||||
|
||||
## Data Explorer
|
||||
|
||||
Data Explorer работает только по whitelist источников и полей. Произвольный SQL из UI не принимается.
|
||||
|
||||
Источники:
|
||||
|
||||
- `users`
|
||||
- `vehicles`
|
||||
- `fuel_entries`
|
||||
- `service_entries`
|
||||
- `expense_entries`
|
||||
- `sto_profiles`
|
||||
- `sto_applications`
|
||||
- `sto_employees`
|
||||
- `vehicle_sto_links`
|
||||
- `appointments`
|
||||
- `work_orders`
|
||||
- `work_order_items`
|
||||
- `work_order_products`
|
||||
- `reviews`
|
||||
- `notifications`
|
||||
- `admin_notifications`
|
||||
- `audit_logs`
|
||||
- `imports_exports`
|
||||
|
||||
Поддержаны фильтры по дате, статусу, пользователю, Telegram ID, авто, СТО, городу, роли, категории, сумме, ошибкам и текстовому поиску. Каждый запрос ограничен `limit` до 500 строк и пишет audit log.
|
||||
|
||||
## Privacy
|
||||
|
||||
По умолчанию маскируются Telegram ID, VIN, госномер, телефон и регистрационные данные СТО.
|
||||
|
||||
Полный просмотр sensitive data:
|
||||
|
||||
- доступен только `admin` и `super_admin`;
|
||||
- требует `reason`;
|
||||
- пишет audit log;
|
||||
- не раскрывает bot token, env, internal token, secret fields.
|
||||
|
||||
`analyst` видит только обезличенные или замаскированные персональные данные.
|
||||
|
||||
## Модерация СТО
|
||||
|
||||
Очередь заявок доступна в `/admin.html?section=sto-applications`.
|
||||
|
||||
Действия:
|
||||
|
||||
- approve;
|
||||
- reject with reason;
|
||||
- request changes with reason;
|
||||
- suspend;
|
||||
- unsuspend.
|
||||
|
||||
При изменении статуса создаются audit log, admin notification и уведомление владельцу СТО.
|
||||
|
||||
## Bot Commands
|
||||
|
||||
Админские команды бота:
|
||||
|
||||
- `/admin`
|
||||
- `/admin_stats`
|
||||
- `/admin_users`
|
||||
- `/admin_sto`
|
||||
- `/admin_pending_sto`
|
||||
- `/admin_alerts`
|
||||
|
||||
API дополнительно проверяет роль пользователя, поэтому команда не дает доступа без admin-role в БД.
|
||||
@@ -65,6 +65,12 @@ CarPass создает рекомендации обслуживания из д
|
||||
|
||||
Telegram-уведомления являются основным каналом закрытого пилота. Browser push уже умеет сохранять подписки в Mini App и принимать push-события в service worker, но серверная Web Push-доставка помечена как beta и не считается критическим каналом пилота.
|
||||
|
||||
## Администрирование
|
||||
|
||||
Admin Control Center доступен по `/admin.html` и через команды бота `/admin`, `/admin_stats`, `/admin_users`, `/admin_sto`, `/admin_pending_sto`, `/admin_alerts`.
|
||||
|
||||
Админка включает dashboard сервиса, admin notifications, очередь заявок СТО, пользователей, автомобили, записи, заказ-наряды, audit log, экспорт и безопасный Data Explorer без произвольного SQL. Подробности по ролям, privacy, env и API описаны в [ADMIN.md](ADMIN.md).
|
||||
|
||||
## Безопасность данных
|
||||
|
||||
CarPass не раскрывает историю автомобиля по одному VIN или госномеру. СТО видит только разрешенный владельцем объем данных: базовую карточку, историю обслуживания или полный доступ. Любые чувствительные изменения, включая VIN, номер, пробег и технические параметры, проходят подтверждение владельца.
|
||||
|
||||
@@ -126,6 +126,15 @@ class ApiClient:
|
||||
async def pending_service_centers(self, telegram_id: int) -> list[dict[str, Any]]:
|
||||
return await self.request("GET", "/api/admin/service-centers/pending", telegram_id=telegram_id)
|
||||
|
||||
async def admin_dashboard(self, telegram_id: int) -> dict[str, Any]:
|
||||
return await self.request("GET", "/api/admin/dashboard", telegram_id=telegram_id)
|
||||
|
||||
async def admin_users(self, telegram_id: int) -> dict[str, Any]:
|
||||
return await self.request("GET", "/api/admin/users", telegram_id=telegram_id, params={"limit": 10})
|
||||
|
||||
async def admin_alerts(self, telegram_id: int) -> dict[str, Any]:
|
||||
return await self.request("GET", "/api/admin/notifications", telegram_id=telegram_id, params={"limit": 10})
|
||||
|
||||
async def moderate_service_center(
|
||||
self,
|
||||
telegram_id: int,
|
||||
|
||||
80
bot/main.py
80
bot/main.py
@@ -433,6 +433,7 @@ async def register_sto(message: Message, command: CommandObject) -> None:
|
||||
|
||||
|
||||
@dp.message(Command("admin_sto_pending"))
|
||||
@dp.message(Command("admin_pending_sto"))
|
||||
async def admin_sto_pending(message: Message) -> None:
|
||||
await upsert(message)
|
||||
try:
|
||||
@@ -459,6 +460,77 @@ async def admin_sto_pending(message: Message) -> None:
|
||||
await message.answer(text, reply_markup=admin_card_keyboard(center["id"]))
|
||||
|
||||
|
||||
@dp.message(Command("admin"))
|
||||
async def admin_home(message: Message) -> None:
|
||||
await upsert(message)
|
||||
try:
|
||||
await api.admin_dashboard(message.from_user.id)
|
||||
except httpx.HTTPStatusError as error:
|
||||
await message.answer(f"Админка недоступна: {error.response.text}")
|
||||
return
|
||||
await message.answer(
|
||||
"Admin Control Center: уведомления, пользователи, СТО, заявки, Data Explorer и Audit Log.",
|
||||
reply_markup=webapp_inline_keyboard("Открыть админку", "admin.html"),
|
||||
)
|
||||
|
||||
|
||||
@dp.message(Command("admin_stats"))
|
||||
async def admin_stats(message: Message) -> None:
|
||||
await upsert(message)
|
||||
try:
|
||||
dashboard = await api.admin_dashboard(message.from_user.id)
|
||||
except httpx.HTTPStatusError as error:
|
||||
await message.answer(f"Нет доступа к admin stats: {error.response.text}")
|
||||
return
|
||||
await message.answer(
|
||||
"\n".join(
|
||||
[
|
||||
"Admin stats",
|
||||
f"Users today: {dashboard['users_today']}",
|
||||
f"Users total: {dashboard['users_total']}",
|
||||
f"STO pending: {dashboard['pending_sto_applications']}",
|
||||
f"Appointments today: {dashboard['appointments_today']}",
|
||||
f"Work orders active: {dashboard['active_work_orders']}",
|
||||
f"Errors/security: {dashboard['system_errors']} / {dashboard['security_events']}",
|
||||
]
|
||||
),
|
||||
reply_markup=webapp_inline_keyboard("Admin dashboard", "admin.html"),
|
||||
)
|
||||
|
||||
|
||||
@dp.message(Command("admin_users"))
|
||||
async def admin_users(message: Message) -> None:
|
||||
await upsert(message)
|
||||
try:
|
||||
data = await api.admin_users(message.from_user.id)
|
||||
except httpx.HTTPStatusError as error:
|
||||
await message.answer(f"Нет доступа к admin users: {error.response.text}")
|
||||
return
|
||||
lines = ["Последние пользователи:"]
|
||||
for row in data.get("rows", [])[:10]:
|
||||
lines.append(f"#{row.get('id')} {row.get('username') or '-'} · {row.get('platform_role')} · {row.get('created_at')}")
|
||||
await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Users", "admin.html?section=users"))
|
||||
|
||||
|
||||
@dp.message(Command("admin_sto"))
|
||||
async def admin_sto(message: Message) -> None:
|
||||
await admin_sto_pending(message)
|
||||
|
||||
|
||||
@dp.message(Command("admin_alerts"))
|
||||
async def admin_alerts(message: Message) -> None:
|
||||
await upsert(message)
|
||||
try:
|
||||
data = await api.admin_alerts(message.from_user.id)
|
||||
except httpx.HTTPStatusError as error:
|
||||
await message.answer(f"Нет доступа к admin alerts: {error.response.text}")
|
||||
return
|
||||
lines = ["Admin alerts:"]
|
||||
for row in data.get("rows", [])[:10]:
|
||||
lines.append(f"#{row.get('id')} {row.get('severity')} · {row.get('title')} · {row.get('status')}")
|
||||
await message.answer("\n".join(lines), reply_markup=webapp_inline_keyboard("Alerts", "admin.html?section=notifications"))
|
||||
|
||||
|
||||
async def admin_action(message: Message, command: CommandObject, action: str) -> None:
|
||||
args = (command.args or "").split(maxsplit=1)
|
||||
if not args:
|
||||
@@ -577,7 +649,14 @@ async def admin_callback(callback: CallbackQuery) -> None:
|
||||
@dp.message(F.text == "Помощь")
|
||||
@dp.message(Command("help"))
|
||||
async def help_message(message: Message) -> None:
|
||||
user = await api.upsert_user(message.from_user)
|
||||
centers = await sto_workplace_centers(message.from_user.id)
|
||||
admin_help = (
|
||||
"Админ: /admin — панель, /admin_stats — метрики, /admin_users — последние пользователи, "
|
||||
"/admin_pending_sto — заявки СТО, /admin_alerts — события.\n"
|
||||
if user.get("platform_role") in {"admin", "super_admin", "moderator", "support", "analyst"}
|
||||
else ""
|
||||
)
|
||||
sto_workplace_help = (
|
||||
"• /sto_bookings или /sto_workplace — панель подтвержденного СТО;\n"
|
||||
"• /accept_sto_invite <token> — принять приглашение сотрудника;\n"
|
||||
@@ -601,6 +680,7 @@ async def help_message(message: Message) -> None:
|
||||
"• /sto — каталог проверенных СТО;\n"
|
||||
"• /appointments — мои записи в СТО;\n"
|
||||
f"{sto_workplace_help}"
|
||||
f"{admin_help}"
|
||||
"\n"
|
||||
"Владелец: добавь авто, выбери проверенное СТО, создай запись, согласуй заказ-наряд и смотри завершенные работы в истории автомобиля.\n"
|
||||
f"{sto_business_help}"
|
||||
|
||||
241
web/admin.html
Normal file
241
web/admin.html
Normal file
@@ -0,0 +1,241 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#16806a" />
|
||||
<title>Admin Control Center</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
</head>
|
||||
<body class="auth-required admin-page">
|
||||
<div class="auth-overlay" id="authOverlay">
|
||||
<div class="auth-panel">
|
||||
<p class="eyebrow">CarPass</p>
|
||||
<h1>Админ-панель</h1>
|
||||
<p id="authMessage">Откройте страницу через Telegram, чтобы подтвердить права администратора.</p>
|
||||
<div class="auth-actions">
|
||||
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" rel="noreferrer">Открыть в Telegram</a>
|
||||
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="shell admin-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">CarPass Admin</p>
|
||||
<h1>Control Center</h1>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button class="icon-btn" id="refreshBtn" title="Обновить" aria-label="Обновить">↻</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="passport-panel admin-hero">
|
||||
<div class="passport-head">
|
||||
<div>
|
||||
<p class="eyebrow">Пилотный контур</p>
|
||||
<h2>Операционный обзор</h2>
|
||||
<small id="adminMeta">Загружаю доступ и источники данных...</small>
|
||||
</div>
|
||||
<span class="trust-badge" id="adminRoleBadge">Проверка</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="admin-tabs" aria-label="Разделы админки">
|
||||
<button type="button" data-admin-tab="dashboard">Dashboard</button>
|
||||
<button type="button" data-admin-tab="notifications">Notifications</button>
|
||||
<button type="button" data-admin-tab="users">Users</button>
|
||||
<button type="button" data-admin-tab="sto">СТО</button>
|
||||
<button type="button" data-admin-tab="sto-applications">Заявки СТО</button>
|
||||
<button type="button" data-admin-tab="vehicles">Авто</button>
|
||||
<button type="button" data-admin-tab="appointments">Записи</button>
|
||||
<button type="button" data-admin-tab="work-orders">Заказ-наряды</button>
|
||||
<button type="button" data-admin-tab="data">Data Explorer</button>
|
||||
<button type="button" data-admin-tab="audit">Audit Log</button>
|
||||
<button type="button" data-admin-tab="exports">Exports</button>
|
||||
</nav>
|
||||
|
||||
<section id="panel-dashboard" class="admin-panel workspace">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Сервис</p>
|
||||
<h2>Dashboard</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats admin-stats" id="dashboardStats"></div>
|
||||
<div class="admin-grid">
|
||||
<section>
|
||||
<h3>Последние события</h3>
|
||||
<div id="dashboardAlerts" class="stack-list"></div>
|
||||
</section>
|
||||
<section>
|
||||
<h3>Быстрые переходы</h3>
|
||||
<div id="quickLinks" class="admin-link-grid"></div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="panel-notifications" class="admin-panel workspace hidden">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">События</p>
|
||||
<h2>Admin notifications</h2>
|
||||
</div>
|
||||
<button type="button" id="readAllBtn">Прочитать все</button>
|
||||
</div>
|
||||
<div id="notificationsList" class="stack-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-users" class="admin-panel workspace hidden">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Аккаунты</p>
|
||||
<h2>Users</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form class="admin-filter" data-list-filter="users">
|
||||
<input name="search" placeholder="Поиск по имени или username" />
|
||||
<button type="submit">Найти</button>
|
||||
</form>
|
||||
<div id="usersTable" class="admin-table-wrap"></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-sto" class="admin-panel workspace hidden">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Партнеры</p>
|
||||
<h2>СТО</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form class="admin-filter" data-list-filter="sto">
|
||||
<input name="city" placeholder="Город" />
|
||||
<select name="status">
|
||||
<option value="">Любой статус</option>
|
||||
<option value="pending">pending</option>
|
||||
<option value="approved">approved</option>
|
||||
<option value="needs_changes">needs_changes</option>
|
||||
<option value="rejected">rejected</option>
|
||||
<option value="suspended">suspended</option>
|
||||
</select>
|
||||
<button type="submit">Фильтр</button>
|
||||
</form>
|
||||
<div id="stoTable" class="admin-table-wrap"></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-sto-applications" class="admin-panel workspace hidden">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Модерация</p>
|
||||
<h2>Заявки СТО</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="applicationsList" class="stack-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-vehicles" class="admin-panel workspace hidden">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Гараж</p>
|
||||
<h2>Автомобили</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="vehiclesTable" class="admin-table-wrap"></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-appointments" class="admin-panel workspace hidden">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Календарь</p>
|
||||
<h2>Записи</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="appointmentsTable" class="admin-table-wrap"></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-work-orders" class="admin-panel workspace hidden">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Работы</p>
|
||||
<h2>Заказ-наряды</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="workOrdersTable" class="admin-table-wrap"></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-data" class="admin-panel workspace hidden">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Без SQL</p>
|
||||
<h2>Data Explorer</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form id="dataForm" class="grid-form admin-data-form">
|
||||
<label>Тип данных<select name="source" id="sourceSelect"></select></label>
|
||||
<label>Дата от<input name="date_from" type="date" /></label>
|
||||
<label>Дата до<input name="date_to" type="date" /></label>
|
||||
<label>Status<input name="status" /></label>
|
||||
<label>User ID<input name="user_id" type="number" /></label>
|
||||
<label>Telegram ID<input name="telegram_id" type="number" /></label>
|
||||
<label>Vehicle ID<input name="vehicle_id" type="number" /></label>
|
||||
<label>STO ID<input name="sto_id" type="number" /></label>
|
||||
<label>City<input name="city" /></label>
|
||||
<label>Role<input name="role" /></label>
|
||||
<label>Search<input name="search" /></label>
|
||||
<label>Sort
|
||||
<select name="sort" id="sortSelect"></select>
|
||||
</label>
|
||||
<label>Limit
|
||||
<select name="limit">
|
||||
<option>25</option>
|
||||
<option selected>50</option>
|
||||
<option>100</option>
|
||||
<option>500</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="check admin-check"><input name="include_sensitive" type="checkbox" /> Show sensitive</label>
|
||||
<label>Reason<input name="reason" placeholder="Обязательно для sensitive/export" /></label>
|
||||
<div class="admin-form-actions">
|
||||
<button type="submit">Запросить</button>
|
||||
<button type="button" class="ghost-btn" id="exportJsonBtn">JSON export</button>
|
||||
<button type="button" class="ghost-btn" id="exportCsvBtn">CSV export</button>
|
||||
</div>
|
||||
</form>
|
||||
<div id="dataResult" class="admin-table-wrap"></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-audit" class="admin-panel workspace hidden">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Контроль</p>
|
||||
<h2>Audit Log</h2>
|
||||
</div>
|
||||
</div>
|
||||
<form id="auditForm" class="admin-filter">
|
||||
<input name="action" placeholder="Action" />
|
||||
<input name="actor_id" type="number" placeholder="Actor ID" />
|
||||
<input name="entity_type" placeholder="Entity type" />
|
||||
<input name="entity_id" placeholder="Entity ID" />
|
||||
<button type="submit">Показать</button>
|
||||
</form>
|
||||
<div id="auditTable" class="admin-table-wrap"></div>
|
||||
</section>
|
||||
|
||||
<section id="panel-exports" class="admin-panel workspace hidden">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Выгрузки</p>
|
||||
<h2>Exports</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="exportsTable" class="admin-table-wrap"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/static/page_common.js"></script>
|
||||
<script src="/static/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
371
web/static/admin.js
Normal file
371
web/static/admin.js
Normal file
@@ -0,0 +1,371 @@
|
||||
const AdminPage = (() => {
|
||||
const { api, boot, toast, escapeHtml, formData, formatDateTime } = CarPassPage;
|
||||
const state = {
|
||||
active: "dashboard",
|
||||
sources: [],
|
||||
sorts: [],
|
||||
lastDataPayload: null,
|
||||
};
|
||||
|
||||
const panels = {
|
||||
dashboard: "#panel-dashboard",
|
||||
notifications: "#panel-notifications",
|
||||
users: "#panel-users",
|
||||
sto: "#panel-sto",
|
||||
"sto-applications": "#panel-sto-applications",
|
||||
vehicles: "#panel-vehicles",
|
||||
appointments: "#panel-appointments",
|
||||
"work-orders": "#panel-work-orders",
|
||||
data: "#panel-data",
|
||||
audit: "#panel-audit",
|
||||
exports: "#panel-exports",
|
||||
};
|
||||
|
||||
const quickLinks = [
|
||||
["notifications", "Notifications"],
|
||||
["users", "Users"],
|
||||
["sto-applications", "Заявки СТО"],
|
||||
["vehicles", "Авто"],
|
||||
["data", "Data Explorer"],
|
||||
["audit", "Audit Log"],
|
||||
];
|
||||
|
||||
function qs(selector) {
|
||||
return document.querySelector(selector);
|
||||
}
|
||||
|
||||
function valueOrDash(value) {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
if (typeof value === "string" && value.includes("T")) return formatDateTime(value);
|
||||
return escapeHtml(value);
|
||||
}
|
||||
|
||||
function setActive(section) {
|
||||
state.active = panels[section] ? section : "dashboard";
|
||||
Object.entries(panels).forEach(([name, selector]) => {
|
||||
qs(selector)?.classList.toggle("hidden", name !== state.active);
|
||||
});
|
||||
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.adminTab === state.active);
|
||||
});
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("section", state.active);
|
||||
window.history.replaceState({}, "", url);
|
||||
}
|
||||
|
||||
function renderEmpty(root, text = "Нет данных") {
|
||||
root.innerHTML = `<div class="tip-card">${escapeHtml(text)}</div>`;
|
||||
}
|
||||
|
||||
function renderError(root, error) {
|
||||
root.innerHTML = `<div class="tip-card error-state">${escapeHtml(error.message || "Ошибка")}</div>`;
|
||||
}
|
||||
|
||||
function renderTable(root, rows, preferredColumns = []) {
|
||||
if (!rows?.length) {
|
||||
renderEmpty(root);
|
||||
return;
|
||||
}
|
||||
const columns = preferredColumns.length ? preferredColumns : Object.keys(rows[0]);
|
||||
root.innerHTML = `
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>${columns.map((column) => `<th>${escapeHtml(column)}</th>`).join("")}</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows
|
||||
.map(
|
||||
(row) => `
|
||||
<tr>
|
||||
${columns.map((column) => `<td>${valueOrDash(row[column])}</td>`).join("")}
|
||||
</tr>
|
||||
`,
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
|
||||
function badge(value) {
|
||||
return `<span class="admin-badge">${escapeHtml(value || "-")}</span>`;
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
const data = await api("/admin/dashboard");
|
||||
const statLabels = [
|
||||
["users_today", "Новые сегодня"],
|
||||
["users_7d", "Новые 7 дней"],
|
||||
["users_total", "Всего пользователей"],
|
||||
["active_users", "Активные"],
|
||||
["vehicles_total", "Авто"],
|
||||
["pending_sto_applications", "Pending СТО"],
|
||||
["approved_sto", "Approved СТО"],
|
||||
["appointments_today", "Записи сегодня"],
|
||||
["active_work_orders", "Активные ЗН"],
|
||||
["completed_work_orders", "Завершенные ЗН"],
|
||||
["system_errors", "Ошибки"],
|
||||
["security_events", "Security"],
|
||||
];
|
||||
qs("#dashboardStats").innerHTML = statLabels
|
||||
.map(([key, label]) => `<div class="stat"><span>${label}</span><strong>${data[key] ?? 0}</strong></div>`)
|
||||
.join("");
|
||||
const alerts = qs("#dashboardAlerts");
|
||||
alerts.innerHTML = data.latest_alerts?.length
|
||||
? data.latest_alerts
|
||||
.map(
|
||||
(item) => `
|
||||
<article class="stack-item">
|
||||
<div>
|
||||
<strong>${escapeHtml(item.title)}</strong>
|
||||
<small>${badge(item.event_type)} ${formatDateTime(item.created_at)}</small>
|
||||
</div>
|
||||
</article>
|
||||
`,
|
||||
)
|
||||
.join("")
|
||||
: `<div class="tip-card">Критичных событий нет</div>`;
|
||||
qs("#quickLinks").innerHTML = quickLinks
|
||||
.map(([section, label]) => `<button type="button" data-admin-tab="${section}">${label}</button>`)
|
||||
.join("");
|
||||
bindTabButtons();
|
||||
}
|
||||
|
||||
async function loadNotifications() {
|
||||
const root = qs("#notificationsList");
|
||||
try {
|
||||
const data = await api("/admin/notifications?limit=100");
|
||||
if (!data.rows.length) return renderEmpty(root);
|
||||
root.innerHTML = data.rows
|
||||
.map(
|
||||
(item) => `
|
||||
<article class="stack-item">
|
||||
<div>
|
||||
<strong>${escapeHtml(item.title)}</strong>
|
||||
<small>${badge(item.event_type)} ${badge(item.severity)} ${badge(item.status)} ${formatDateTime(item.created_at)}</small>
|
||||
<p>${escapeHtml(item.body || "")}</p>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button type="button" data-read-notification="${item.id}">Read</button>
|
||||
<button type="button" class="ghost-btn" data-dismiss-notification="${item.id}">Dismiss</button>
|
||||
</div>
|
||||
</article>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
root.querySelectorAll("[data-read-notification]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
await api(`/admin/notifications/${button.dataset.readNotification}/read`, { method: "POST" });
|
||||
await loadNotifications();
|
||||
});
|
||||
});
|
||||
root.querySelectorAll("[data-dismiss-notification]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
await api(`/admin/notifications/${button.dataset.dismissNotification}/dismiss`, { method: "POST" });
|
||||
await loadNotifications();
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
renderError(root, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUsers(search = "") {
|
||||
const query = new URLSearchParams();
|
||||
if (search) query.set("search", search);
|
||||
const data = await api(`/admin/users?${query.toString()}`);
|
||||
renderTable(qs("#usersTable"), data.rows, ["id", "telegram_id", "username", "first_name", "platform_role", "created_at"]);
|
||||
}
|
||||
|
||||
async function loadSto(filters = {}) {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value) query.set(key, value);
|
||||
});
|
||||
const data = await api(`/admin/sto?${query.toString()}`);
|
||||
renderTable(qs("#stoTable"), data.rows, ["id", "display_name", "city", "phone", "verification_status", "owner_user_id", "created_at"]);
|
||||
}
|
||||
|
||||
async function loadApplications() {
|
||||
const root = qs("#applicationsList");
|
||||
try {
|
||||
const data = await api("/admin/sto-applications");
|
||||
if (!data.rows.length) return renderEmpty(root, "Очередь модерации пуста");
|
||||
root.innerHTML = data.rows
|
||||
.map(
|
||||
(item) => `
|
||||
<article class="stack-item">
|
||||
<div>
|
||||
<strong>${escapeHtml(item.display_name || item.legal_name || `СТО #${item.id}`)}</strong>
|
||||
<small>${badge(item.verification_status)} ${escapeHtml(item.city || "-")} ${formatDateTime(item.created_at)}</small>
|
||||
</div>
|
||||
<div class="row-actions">
|
||||
<button type="button" data-application-action="approve" data-application-id="${item.id}">Approve</button>
|
||||
<button type="button" class="ghost-btn" data-application-action="request-changes" data-application-id="${item.id}">Правки</button>
|
||||
<button type="button" class="danger-btn" data-application-action="reject" data-application-id="${item.id}">Reject</button>
|
||||
</div>
|
||||
</article>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
root.querySelectorAll("[data-application-action]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
const action = button.dataset.applicationAction;
|
||||
const reason = action === "approve" ? "Approved in admin panel" : window.prompt("Причина") || "";
|
||||
if (action !== "approve" && !reason) return;
|
||||
await api(`/admin/sto-applications/${button.dataset.applicationId}/${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason, comment: reason }),
|
||||
});
|
||||
toast("Статус заявки обновлен");
|
||||
await loadApplications();
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
renderError(root, error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSourceTable(source, rootSelector, columns) {
|
||||
const root = qs(rootSelector);
|
||||
try {
|
||||
const data = await api("/admin/data/query", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ source, limit: 100 }),
|
||||
});
|
||||
renderTable(root, data.rows, columns);
|
||||
} catch (error) {
|
||||
renderError(root, error);
|
||||
}
|
||||
}
|
||||
|
||||
function cleanPayload(payload) {
|
||||
const cleaned = {};
|
||||
Object.entries(payload).forEach(([key, value]) => {
|
||||
if (value === "" || value === null || value === undefined) return;
|
||||
if (["user_id", "telegram_id", "vehicle_id", "sto_id", "limit"].includes(key)) {
|
||||
cleaned[key] = Number(value);
|
||||
} else if (key === "include_sensitive") {
|
||||
cleaned[key] = value === "on";
|
||||
} else {
|
||||
cleaned[key] = value;
|
||||
}
|
||||
});
|
||||
if (!("include_sensitive" in cleaned)) cleaned.include_sensitive = false;
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
async function submitDataQuery(format = null) {
|
||||
const payload = cleanPayload(formData(qs("#dataForm")));
|
||||
state.lastDataPayload = payload;
|
||||
if (format) {
|
||||
const result = await api("/admin/data/export", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...payload, export_format: format }),
|
||||
});
|
||||
toast(`Export #${result.id} готов`);
|
||||
await loadExports();
|
||||
return;
|
||||
}
|
||||
const data = await api("/admin/data/query", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
renderTable(qs("#dataResult"), data.rows);
|
||||
}
|
||||
|
||||
async function loadAudit(params = {}) {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value) query.set(key, value);
|
||||
});
|
||||
const rows = await api(`/admin/audit-log?${query.toString()}`);
|
||||
renderTable(qs("#auditTable"), rows, ["id", "actor_user_id", "actor_role", "action", "target_type", "target_id", "created_at"]);
|
||||
}
|
||||
|
||||
async function loadExports() {
|
||||
const data = await api("/admin/exports");
|
||||
renderTable(qs("#exportsTable"), data.rows, ["id", "source", "export_format", "status", "row_count", "reason", "expires_at", "created_at"]);
|
||||
}
|
||||
|
||||
async function loadActiveSection() {
|
||||
if (state.active === "dashboard") return loadDashboard();
|
||||
if (state.active === "notifications") return loadNotifications();
|
||||
if (state.active === "users") return loadUsers();
|
||||
if (state.active === "sto") return loadSto();
|
||||
if (state.active === "sto-applications") return loadApplications();
|
||||
if (state.active === "vehicles") return loadSourceTable("vehicles", "#vehiclesTable", ["id", "owner_id", "name", "make", "model", "year", "vin", "plate_number", "current_odometer", "created_at"]);
|
||||
if (state.active === "appointments") return loadSourceTable("appointments", "#appointmentsTable", ["id", "service_center_id", "vehicle_id", "owner_user_id", "service_type", "status", "requested_start_at", "created_at"]);
|
||||
if (state.active === "work-orders") return loadSourceTable("work_orders", "#workOrdersTable", ["id", "service_center_id", "vehicle_id", "owner_user_id", "status", "final_total", "currency", "completed_at"]);
|
||||
if (state.active === "audit") return loadAudit();
|
||||
if (state.active === "exports") return loadExports();
|
||||
return null;
|
||||
}
|
||||
|
||||
function bindTabButtons() {
|
||||
document.querySelectorAll("[data-admin-tab]").forEach((button) => {
|
||||
button.addEventListener("click", async () => {
|
||||
setActive(button.dataset.adminTab);
|
||||
try {
|
||||
await loadActiveSection();
|
||||
} catch (error) {
|
||||
toast(error.message || "Ошибка", "error");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindForms() {
|
||||
qs("#refreshBtn")?.addEventListener("click", () => loadActiveSection().catch((error) => toast(error.message, "error")));
|
||||
qs("#readAllBtn")?.addEventListener("click", async () => {
|
||||
await api("/admin/notifications/read-all", { method: "POST" });
|
||||
await loadNotifications();
|
||||
});
|
||||
document.querySelector("[data-list-filter='users']")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await loadUsers(formData(event.currentTarget).search || "");
|
||||
});
|
||||
document.querySelector("[data-list-filter='sto']")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await loadSto(formData(event.currentTarget));
|
||||
});
|
||||
qs("#dataForm")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await submitDataQuery().catch((error) => toast(error.message, "error"));
|
||||
});
|
||||
qs("#exportJsonBtn")?.addEventListener("click", () => submitDataQuery("json").catch((error) => toast(error.message, "error")));
|
||||
qs("#exportCsvBtn")?.addEventListener("click", () => submitDataQuery("csv").catch((error) => toast(error.message, "error")));
|
||||
qs("#auditForm")?.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
await loadAudit(cleanPayload(formData(event.currentTarget)));
|
||||
});
|
||||
}
|
||||
|
||||
async function initSources() {
|
||||
const data = await api("/admin/data/sources");
|
||||
state.sources = data.sources || [];
|
||||
state.sorts = data.sorts || [];
|
||||
qs("#sourceSelect").innerHTML = state.sources
|
||||
.filter((source) => source.available && source.allowed)
|
||||
.map((source) => `<option value="${source.name}">${source.name}</option>`)
|
||||
.join("");
|
||||
qs("#sortSelect").innerHTML = state.sorts
|
||||
.map((sort) => `<option value="${sort}">${sort}</option>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function init() {
|
||||
qs("#adminRoleBadge").textContent = CarPassPage.state.user?.platform_role || "admin";
|
||||
qs("#adminMeta").textContent = `User #${CarPassPage.state.user?.id || "-"} · Telegram ${CarPassPage.state.user?.telegram_id || "-"}`;
|
||||
await initSources();
|
||||
bindTabButtons();
|
||||
bindForms();
|
||||
const urlSection = new URLSearchParams(window.location.search).get("section");
|
||||
setActive(urlSection || "dashboard");
|
||||
await loadActiveSection();
|
||||
}
|
||||
|
||||
return { init };
|
||||
})();
|
||||
|
||||
CarPassPage.boot(AdminPage.init);
|
||||
@@ -2069,6 +2069,180 @@ select {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-page {
|
||||
background:
|
||||
linear-gradient(180deg, #ffffff 0, #edf5f2 220px),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
width: min(1320px, 100%);
|
||||
}
|
||||
|
||||
.admin-hero {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 8;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 8px 0 12px;
|
||||
margin-bottom: 6px;
|
||||
overflow-x: auto;
|
||||
background: rgba(238, 243, 241, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.admin-tabs button,
|
||||
.admin-link-grid button {
|
||||
flex: 0 0 auto;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: #fff;
|
||||
color: var(--text);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.admin-tabs button.active {
|
||||
border-color: rgba(22, 128, 106, 0.48);
|
||||
background: #dff1eb;
|
||||
color: #0e5d4b;
|
||||
}
|
||||
|
||||
.admin-panel {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-stats {
|
||||
grid-template-columns: repeat(6, minmax(130px, 1fr));
|
||||
}
|
||||
|
||||
.admin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(260px, 0.75fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-grid h3 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.admin-link-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-filter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 8px;
|
||||
align-items: end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-filter button {
|
||||
min-width: 112px;
|
||||
}
|
||||
|
||||
.admin-table-wrap {
|
||||
width: 100%;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
min-width: 720px;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.admin-table th,
|
||||
.admin-table td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: #f5faf7;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.admin-table tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
display: inline-flex;
|
||||
min-height: 22px;
|
||||
align-items: center;
|
||||
padding: 2px 7px;
|
||||
border: 1px solid rgba(22, 128, 106, 0.18);
|
||||
border-radius: 8px;
|
||||
background: #eef7f3;
|
||||
color: #0e5d4b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-data-form {
|
||||
align-items: end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-check {
|
||||
min-height: 42px;
|
||||
align-content: center;
|
||||
}
|
||||
|
||||
.admin-form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: var(--danger);
|
||||
background: #fff4f2;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.admin-stats {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.admin-stats .stat {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.admin-grid,
|
||||
.admin-link-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.work-order-total strong {
|
||||
color: #fff;
|
||||
font-size: clamp(24px, 4vw, 34px);
|
||||
|
||||
Reference in New Issue
Block a user