From 0f6d6e31e1c6fee21ff02a445db9aba759056a7f Mon Sep 17 00:00:00 2001 From: VPN SaaS Dev Date: Sun, 17 May 2026 21:16:28 +0900 Subject: [PATCH] add admin control center ui and bot commands --- ADMIN.md | 155 ++++++++++++++++++ README.md | 6 + bot/api_client.py | 9 + bot/main.py | 80 +++++++++ web/admin.html | 241 +++++++++++++++++++++++++++ web/static/admin.js | 371 ++++++++++++++++++++++++++++++++++++++++++ web/static/styles.css | 174 ++++++++++++++++++++ 7 files changed, 1036 insertions(+) create mode 100644 ADMIN.md create mode 100644 web/admin.html create mode 100644 web/static/admin.js diff --git a/ADMIN.md b/ADMIN.md new file mode 100644 index 0000000..fc42566 --- /dev/null +++ b/ADMIN.md @@ -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 в БД. diff --git a/README.md b/README.md index df68495..ca992c8 100644 --- a/README.md +++ b/README.md @@ -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, номер, пробег и технические параметры, проходят подтверждение владельца. diff --git a/bot/api_client.py b/bot/api_client.py index 356fcb4..d0e7889 100644 --- a/bot/api_client.py +++ b/bot/api_client.py @@ -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, diff --git a/bot/main.py b/bot/main.py index 571b713..15f4e5b 100644 --- a/bot/main.py +++ b/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 — принять приглашение сотрудника;\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}" diff --git a/web/admin.html b/web/admin.html new file mode 100644 index 0000000..b7b8856 --- /dev/null +++ b/web/admin.html @@ -0,0 +1,241 @@ + + + + + + + Admin Control Center + + + + + +
+
+

CarPass

+

Админ-панель

+

Откройте страницу через Telegram, чтобы подтвердить права администратора.

+
+ + +
+
+
+ +
+
+
+

CarPass Admin

+

Control Center

+
+
+ +
+
+ +
+
+
+

Пилотный контур

+

Операционный обзор

+ Загружаю доступ и источники данных... +
+ Проверка +
+
+ + + +
+
+
+

Сервис

+

Dashboard

+
+
+
+
+
+

Последние события

+
+
+
+

Быстрые переходы

+ +
+
+
+ + + + + + + + + + + + + + + + + + + + +
+ + + + + + diff --git a/web/static/admin.js b/web/static/admin.js new file mode 100644 index 0000000..dac167d --- /dev/null +++ b/web/static/admin.js @@ -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 = `
${escapeHtml(text)}
`; + } + + function renderError(root, error) { + root.innerHTML = `
${escapeHtml(error.message || "Ошибка")}
`; + } + + function renderTable(root, rows, preferredColumns = []) { + if (!rows?.length) { + renderEmpty(root); + return; + } + const columns = preferredColumns.length ? preferredColumns : Object.keys(rows[0]); + root.innerHTML = ` + + + ${columns.map((column) => ``).join("")} + + + ${rows + .map( + (row) => ` + + ${columns.map((column) => ``).join("")} + + `, + ) + .join("")} + +
${escapeHtml(column)}
${valueOrDash(row[column])}
+ `; + } + + function badge(value) { + return `${escapeHtml(value || "-")}`; + } + + 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]) => `
${label}${data[key] ?? 0}
`) + .join(""); + const alerts = qs("#dashboardAlerts"); + alerts.innerHTML = data.latest_alerts?.length + ? data.latest_alerts + .map( + (item) => ` +
+
+ ${escapeHtml(item.title)} + ${badge(item.event_type)} ${formatDateTime(item.created_at)} +
+
+ `, + ) + .join("") + : `
Критичных событий нет
`; + qs("#quickLinks").innerHTML = quickLinks + .map(([section, label]) => ``) + .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) => ` +
+
+ ${escapeHtml(item.title)} + ${badge(item.event_type)} ${badge(item.severity)} ${badge(item.status)} ${formatDateTime(item.created_at)} +

${escapeHtml(item.body || "")}

+
+
+ + +
+
+ `, + ) + .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) => ` +
+
+ ${escapeHtml(item.display_name || item.legal_name || `СТО #${item.id}`)} + ${badge(item.verification_status)} ${escapeHtml(item.city || "-")} ${formatDateTime(item.created_at)} +
+
+ + + +
+
+ `, + ) + .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) => ``) + .join(""); + qs("#sortSelect").innerHTML = state.sorts + .map((sort) => ``) + .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); diff --git a/web/static/styles.css b/web/static/styles.css index 009e17d..c5ec287 100644 --- a/web/static/styles.css +++ b/web/static/styles.css @@ -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);