phase2(admin-views): AdminBilling/Incidents/System — реальные display-views
- AdminBillingView: 4 stats (MRR, Выручка, Просрочка, Возвраты) + v-data-table 7 колонок (Тенант с ИНН / Тариф / Баланс с error-color / пополнения / списания / MRR / Статус-chip) + поиск
- AdminIncidentsView: 3 stats + 5 фильтров статуса + v-list с incident_id (INC-YYYY-MMDD-NNNN) + severity/status/РКН-pending chips + дедлайн 24ч по 152-ФЗ
- AdminSystemView: read-only warning + поиск + v-list 7 system_settings (webhook_rate_limit, login_max_attempts, retention и т.д.) с type-chip и updated_at
- composables/mockAdmin.ts: AdminBillingTenantRow + AdminIncidentRow + AdminSystemSetting + mock-данные
- Router: /admin/{billing,incidents,system} → реальные views (не placeholder)
- Vitest +13 (179/179 за 11.98с)
- TODO: edit-flow для system_settings + backend /api/admin/* endpoints
- Регресс: lint+type+format OK; build 743ms; story:build 21/28 за 31.5с
- CLAUDE.md v1.42→v1.43, реестр v1.51→v1.52
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md — техконтекст Лидерры
|
||||
|
||||
**Версия:** 1.42 от 09.05.2026
|
||||
**Версия:** 1.43 от 09.05.2026
|
||||
**Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0.
|
||||
|
||||
> **Ребрендинг 08.05.2026:** «Лидпоток» → **«Лидерра.»** (с точкой). Палитра, лого и шрифты — из handoff Платона (v8 Forest). Применяется только к дизайну/имени/логотипу; функционал, состав страниц и правила — без изменений (источник — ТЗ v8.5/schema v8.5).
|
||||
@@ -224,6 +224,8 @@ trivy image liderra:latest
|
||||
|
||||
---
|
||||
|
||||
*CLAUDE.md v1.43 от 09.05.2026. Изменения v1.43: **Admin views (Биллинг / Инциденты / Система)**. Закрыт пункт #8 — заменены 3 placeholder'а на реальные display-views с mock-данными. **`AdminBillingView`**: 4-stats row (MRR / Выручка за месяц / Просрочка / Возвраты за 30 дн) + v-data-table 7 колонок (Тенант с ИНН / Тариф / Баланс ₽ с error-color при <0 / Пополнения за мес / Списания / MRR / Статус-chip). Search-фильтр по name/ИНН. **`AdminIncidentsView`**: 3-stats row (Открыто/Расследуется/РКН-уведомлений) + v-btn-toggle 5 фильтров по статусу + v-list инцидентов с incident_id (INC-YYYY-MMDD-NNNN), severity-chip + status-chip + специальный «РКН pending» chip для PDN-breach + дедлайн РКН (24 ч по 152-ФЗ). 5 категорий (PDN-breach / service_outage / security / billing / data_loss). **`AdminSystemView`**: read-only warning + поиск по ключу/описанию + v-list 7 system_settings (webhook_rate_limit_rps, login_max_attempts, password_min_length, retention_days, maintenance_mode и т.д.) с type-chip (int/string/bool/json) и updated_at. Edit-flow с двойным подтверждением + audit-log — отдельный коммит. **`composables/mockAdmin.ts`**: типы AdminBillingTenantRow/AdminIncidentRow/AdminSystemSetting + mock-данные. Маршруты `/admin/billing|incidents|system` теперь ведут на реальные view'ы (не AdminPlaceholderView). **Vitest +13** (всего **179/179 за 11.98 сек**): AdminBillingView 3 (mount + 4 stats + table contents); AdminIncidentsView 5 (mount + 3 stats + filter-toggle + PDN+РКН pending + incident_id format); AdminSystemView 5 (mount + read-only warning + key settings + type-chip + 7 rows). **TODO** (продолжение): #9 Impersonation flow (Ю-1). **Регресс зелёный:** lint+type+format OK; **vitest 179/179 за 11.98 сек** (+13 от 166); vite build 743 ms; story:build 21/28 за 31.5 сек. Реестр v1.51→v1.52.*
|
||||
|
||||
*CLAUDE.md v1.42 от 09.05.2026. Изменения v1.42: **Email-уведомление при 3 неудачных попытках входа (ТЗ §22.4.4 п.3)**. Закрыт пункт #5 — последний пункт ТЗ §22.4.4 анти-брутфорс. **`App\Mail\SuspiciousLoginNotification`** Mailable + `resources/views/emails/suspicious_login.blade.php` (HTML email с инструкциями: сменить пароль / включить 2FA / проверить сессии). **`AuthController::maybeNotifySuspiciousLogin`** triggers ровно при `count(auth_log.login_failed для user_id за час) === 3` — иначе на 4-5 неудачах будут спам-emails. Для unknown email user=null → ничего не отправляем. На dev `MAIL_MAILER=log` письмо в storage/logs. **Pest +4** в `tests/Feature/Auth/SuspiciousLoginNotificationTest.php` (всего **111/111 за 14.32 сек**, 401 assertions): после 3-й неудачи Mail::assertSent с правильными user/count/recipient; на 4-5 не дублируется (assertSent count=1); для unknown email НЕ отправляется; успех на 1-2 неудачах НЕ триггерит. PHPStan baseline регенерирован. **TODO** (продолжение): #7 browser-mode, #8 admin views, #9 impersonation. **Регресс зелёный:** Pint+Stan passed; **Pest 111/111 за 14.32 сек** (+4 от 107). Реестр v1.50→v1.51.*
|
||||
|
||||
*CLAUDE.md v1.41 от 09.05.2026. Изменения v1.41: **IP-lockout 10/час + auth_log записи (ТЗ §22.4.4 п.2)**. Закрыт пункт #4 — защита от перебора с одного IP. **AuthController::login** перед verify проверяет `isIpLockedOut(ip)` — count(*) FROM auth_log WHERE event='login_failed' AND ip_address=ip AND created_at >= NOW() - 1 hour. Если ≥10 → 429 + Retry-After: 3600. Это второй слой защиты поверх email-rate-limit (5/15мин из v1.36) — защищает от перебора email'ов с одного IP. **`logAuthEvent`** private helper пишет в auth_log через DB::table (Eloquent для этой таблицы нет). На каждый login_success / login_failed (3 ветки: invalid_password / unknown_email / account_locked). RLS USING без WITH CHECK — INSERT не фильтруется. hash-chain trigger (BEFORE INSERT) заполняет log_hash автоматически (OPEN-И-15 tamper-detection). **Pest +6** в `tests/Feature/Auth/IpLockoutTest.php` (всего **107/107 за 13.86 сек**, 380 assertions): login_success пишет с tenant_id; login_failed wrong-password пишет invalid_password; login_failed unknown email пишет unknown_email + user_id=null; 10 fail записей с одного IP за час → следующий login = 429; 9 fail записей (под порогом) → проходит; старые записи >1ч не блокируют. PHPStan baseline регенерирован. **TODO** (продолжение): #5 email-warn, #7 browser-mode, #8 admin views, #9 impersonation. **Регресс зелёный:** lint+type+format OK; **Pest 107/107 за 13.86 сек** (+6 от 101, 380 assertions). Реестр v1.49→v1.50.*
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
/**
|
||||
* Mock-данные для admin-views (Биллинг / Инциденты / Система).
|
||||
*
|
||||
* На MVP — frontend-only с предсказуемым набором данных. Backend подключение
|
||||
* через отдельные endpoints `/api/admin/*` — отдельный коммит.
|
||||
*/
|
||||
|
||||
// === BILLING ===
|
||||
|
||||
export interface AdminBillingTenantRow {
|
||||
id: number;
|
||||
name: string;
|
||||
inn: string;
|
||||
tariff: 'start' | 'basic' | 'pro' | 'enterprise';
|
||||
balance_rub: number;
|
||||
monthly_topups_rub: number;
|
||||
monthly_charges_rub: number;
|
||||
mrr_rub: number;
|
||||
last_payment_at: string | null;
|
||||
status: 'active' | 'overdue' | 'suspended';
|
||||
}
|
||||
|
||||
export interface AdminBillingSummary {
|
||||
total_mrr_rub: number;
|
||||
monthly_revenue_rub: number;
|
||||
overdue_count: number;
|
||||
refunds_count_30d: number;
|
||||
avg_balance_rub: number;
|
||||
}
|
||||
|
||||
export const ADMIN_BILLING_SUMMARY: AdminBillingSummary = {
|
||||
total_mrr_rub: 1_248_600,
|
||||
monthly_revenue_rub: 1_318_400,
|
||||
overdue_count: 5,
|
||||
refunds_count_30d: 3,
|
||||
avg_balance_rub: 12_840,
|
||||
};
|
||||
|
||||
export const ADMIN_BILLING_TENANTS: AdminBillingTenantRow[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'ООО «Окна Москва»',
|
||||
inn: '7707083893',
|
||||
tariff: 'pro',
|
||||
balance_rub: 14_250,
|
||||
monthly_topups_rub: 30_000,
|
||||
monthly_charges_rub: 25_400,
|
||||
mrr_rub: 4_990,
|
||||
last_payment_at: '2026-05-04T10:23:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'ИП Сидоров А.А.',
|
||||
inn: '500102310123',
|
||||
tariff: 'basic',
|
||||
balance_rub: -1_200,
|
||||
monthly_topups_rub: 5_000,
|
||||
monthly_charges_rub: 6_200,
|
||||
mrr_rub: 990,
|
||||
last_payment_at: '2026-04-28T14:10:00Z',
|
||||
status: 'overdue',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'ООО «Натяжные потолки СПб»',
|
||||
inn: '7806012345',
|
||||
tariff: 'pro',
|
||||
balance_rub: 8_900,
|
||||
monthly_topups_rub: 20_000,
|
||||
monthly_charges_rub: 18_400,
|
||||
mrr_rub: 4_990,
|
||||
last_payment_at: '2026-05-02T09:00:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'ООО «BigCorp Холдинг»',
|
||||
inn: '7710140020',
|
||||
tariff: 'enterprise',
|
||||
balance_rub: 89_000,
|
||||
monthly_topups_rub: 100_000,
|
||||
monthly_charges_rub: 76_400,
|
||||
mrr_rub: 14_990,
|
||||
last_payment_at: '2026-05-01T08:00:00Z',
|
||||
status: 'active',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'ИП Петров В.В.',
|
||||
inn: '770212098765',
|
||||
tariff: 'start',
|
||||
balance_rub: 0,
|
||||
monthly_topups_rub: 0,
|
||||
monthly_charges_rub: 0,
|
||||
mrr_rub: 0,
|
||||
last_payment_at: null,
|
||||
status: 'suspended',
|
||||
},
|
||||
];
|
||||
|
||||
// === INCIDENTS ===
|
||||
|
||||
export interface AdminIncidentRow {
|
||||
id: number;
|
||||
incident_id: string;
|
||||
title: string;
|
||||
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||
category: 'pdn_breach' | 'service_outage' | 'security' | 'billing' | 'data_loss';
|
||||
status: 'open' | 'investigating' | 'resolved' | 'closed';
|
||||
detected_at: string;
|
||||
affected_tenants: number;
|
||||
rkn_notified: boolean;
|
||||
rkn_deadline_at: string | null;
|
||||
}
|
||||
|
||||
export const ADMIN_INCIDENTS: AdminIncidentRow[] = [
|
||||
{
|
||||
id: 1,
|
||||
incident_id: 'INC-2026-0507-0034',
|
||||
title: 'API timeout: рост 502 ошибок на webhook-приёме',
|
||||
severity: 'high',
|
||||
category: 'service_outage',
|
||||
status: 'investigating',
|
||||
detected_at: '2026-05-09T12:34:00Z',
|
||||
affected_tenants: 12,
|
||||
rkn_notified: false,
|
||||
rkn_deadline_at: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
incident_id: 'INC-2026-0506-0028',
|
||||
title: 'Утечка ПДн через логи (REQ-3F8A2)',
|
||||
severity: 'critical',
|
||||
category: 'pdn_breach',
|
||||
status: 'open',
|
||||
detected_at: '2026-05-08T18:00:00Z',
|
||||
affected_tenants: 1,
|
||||
rkn_notified: false,
|
||||
rkn_deadline_at: '2026-05-09T18:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
incident_id: 'INC-2026-0501-0019',
|
||||
title: 'YooKassa: задержка вебхуков об оплате',
|
||||
severity: 'medium',
|
||||
category: 'billing',
|
||||
status: 'resolved',
|
||||
detected_at: '2026-05-01T10:15:00Z',
|
||||
affected_tenants: 5,
|
||||
rkn_notified: false,
|
||||
rkn_deadline_at: null,
|
||||
},
|
||||
];
|
||||
|
||||
// === SYSTEM SETTINGS ===
|
||||
|
||||
export interface AdminSystemSetting {
|
||||
key: string;
|
||||
value: string;
|
||||
type: 'int' | 'string' | 'bool' | 'json';
|
||||
description: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export const ADMIN_SYSTEM_SETTINGS: AdminSystemSetting[] = [
|
||||
{
|
||||
key: 'webhook_rate_limit_rps',
|
||||
value: '100',
|
||||
type: 'int',
|
||||
description: 'Лимит запросов в секунду на токен Webhook (ТЗ §10).',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
key: 'api_rate_limit_per_minute',
|
||||
value: '60',
|
||||
type: 'int',
|
||||
description: 'Лимит запросов API на ключ в минуту.',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
key: 'login_max_attempts',
|
||||
value: '5',
|
||||
type: 'int',
|
||||
description: 'Макс. неудачных попыток входа в окне 15 минут (ТЗ §22.4.4).',
|
||||
updated_at: '2026-05-09T00:00:00Z',
|
||||
},
|
||||
{
|
||||
key: 'password_min_length',
|
||||
value: '10',
|
||||
type: 'int',
|
||||
description: 'Минимальная длина пароля (ТЗ §22.4.1).',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
key: 'webhook_log_retention_days',
|
||||
value: '90',
|
||||
type: 'int',
|
||||
description: 'Сколько дней хранить raw_payload Webhook.',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
key: 'maintenance_mode',
|
||||
value: 'false',
|
||||
type: 'bool',
|
||||
description: 'Глобальный maintenance — webhook-приём и UI отключаются.',
|
||||
updated_at: '2026-05-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
key: 'fallback_supplier_id',
|
||||
value: '1',
|
||||
type: 'int',
|
||||
description: 'Резервный supplier_id для проектов без активного supplier (ProcessWebhookJob).',
|
||||
updated_at: '2026-05-08T00:00:00Z',
|
||||
},
|
||||
];
|
||||
@@ -110,37 +110,20 @@ const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/admin/billing',
|
||||
name: 'admin-billing',
|
||||
component: () => import('../views/admin/AdminPlaceholderView.vue'),
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
title: 'Биллинг',
|
||||
requiresAuth: true,
|
||||
description: 'Сводный биллинг по всем тенантам: пополнения, списания, MRR, retention, отчёты по выручке.',
|
||||
},
|
||||
component: () => import('../views/admin/AdminBillingView.vue'),
|
||||
meta: { layout: 'admin', title: 'Биллинг', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/incidents',
|
||||
name: 'admin-incidents',
|
||||
component: () => import('../views/admin/AdminPlaceholderView.vue'),
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
title: 'Инциденты',
|
||||
requiresAuth: true,
|
||||
description:
|
||||
'Журнал инцидентов SaaS-уровня (incidents_log по schema v8.7 §9). Уведомление РКН за 24 ч при утечке ПДн.',
|
||||
},
|
||||
component: () => import('../views/admin/AdminIncidentsView.vue'),
|
||||
meta: { layout: 'admin', title: 'Инциденты', requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/admin/system',
|
||||
name: 'admin-system',
|
||||
component: () => import('../views/admin/AdminPlaceholderView.vue'),
|
||||
meta: {
|
||||
layout: 'admin',
|
||||
title: 'Система',
|
||||
requiresAuth: true,
|
||||
description:
|
||||
'system_settings по schema v8.7 §10: лимиты квот, тарифные планы, фичефлаги, fallback supplier_id.',
|
||||
},
|
||||
component: () => import('../views/admin/AdminSystemView.vue'),
|
||||
meta: { layout: 'admin', title: 'Система', requiresAuth: true },
|
||||
},
|
||||
// Error pages: 403/500 явные + catch-all 404 (всегда последний).
|
||||
{
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Админка SaaS → Биллинг.
|
||||
*
|
||||
* Сводный биллинг по всем тенантам: выручка, MRR, retention, refunds.
|
||||
* Источник данных: aggregate balance_transactions / invoices / tariff_subscriptions.
|
||||
*
|
||||
* MVP — только display-вьюха с mock-данными. Backend `/api/admin/billing/*`
|
||||
* подключается отдельным коммитом.
|
||||
*/
|
||||
import { ADMIN_BILLING_SUMMARY, ADMIN_BILLING_TENANTS } from '../../composables/mockAdmin';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const search = ref('');
|
||||
|
||||
const headers = [
|
||||
{ title: 'Тенант', key: 'name', sortable: true },
|
||||
{ title: 'Тариф', key: 'tariff', sortable: true },
|
||||
{ title: 'Баланс', key: 'balance_rub', sortable: true, align: 'end' as const },
|
||||
{ title: 'Пополнения за мес', key: 'monthly_topups_rub', sortable: true, align: 'end' as const },
|
||||
{ title: 'Списания за мес', key: 'monthly_charges_rub', sortable: true, align: 'end' as const },
|
||||
{ title: 'MRR', key: 'mrr_rub', sortable: true, align: 'end' as const },
|
||||
{ title: 'Статус', key: 'status', sortable: true },
|
||||
];
|
||||
|
||||
const filteredRows = computed(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
if (!q) return ADMIN_BILLING_TENANTS;
|
||||
return ADMIN_BILLING_TENANTS.filter((r) => r.name.toLowerCase().includes(q) || r.inn.includes(q));
|
||||
});
|
||||
|
||||
const fmt = new Intl.NumberFormat('ru-RU');
|
||||
const formatRub = (v: number) => `${fmt.format(v)} ₽`;
|
||||
|
||||
const statusMap: Record<AdminBillingTenantRow['status'], { label: string; color: string }> = {
|
||||
active: { label: 'Активен', color: 'success' },
|
||||
overdue: { label: 'Просрочка', color: 'warning' },
|
||||
suspended: { label: 'Заблокирован', color: 'error' },
|
||||
};
|
||||
|
||||
const tariffMap: Record<AdminBillingTenantRow['tariff'], string> = {
|
||||
start: 'Старт',
|
||||
basic: 'Базовый',
|
||||
pro: 'Команда',
|
||||
enterprise: 'Enterprise',
|
||||
};
|
||||
|
||||
import type { AdminBillingTenantRow } from '../../composables/mockAdmin';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container fluid class="admin-billing pa-6">
|
||||
<header class="page-head mb-4">
|
||||
<h1 class="text-h4 page-title">Биллинг</h1>
|
||||
<p class="text-body-2 text-medium-emphasis ma-0">
|
||||
Сводка по всем тенантам: выручка, MRR, просрочки, возвраты.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Stats row -->
|
||||
<v-row class="mb-4" data-testid="billing-stats">
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card variant="outlined" class="pa-3">
|
||||
<div class="text-caption text-medium-emphasis">MRR</div>
|
||||
<div class="text-h6 font-mono tabular">{{ formatRub(ADMIN_BILLING_SUMMARY.total_mrr_rub) }}</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card variant="outlined" class="pa-3">
|
||||
<div class="text-caption text-medium-emphasis">Выручка за месяц</div>
|
||||
<div class="text-h6 font-mono tabular">
|
||||
{{ formatRub(ADMIN_BILLING_SUMMARY.monthly_revenue_rub) }}
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card variant="outlined" class="pa-3">
|
||||
<div class="text-caption text-medium-emphasis">Просрочка</div>
|
||||
<div class="text-h6 font-mono tabular text-warning">
|
||||
{{ ADMIN_BILLING_SUMMARY.overdue_count }}
|
||||
</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="6" md="3">
|
||||
<v-card variant="outlined" class="pa-3">
|
||||
<div class="text-caption text-medium-emphasis">Возвраты за 30 дн</div>
|
||||
<div class="text-h6 font-mono tabular">{{ ADMIN_BILLING_SUMMARY.refunds_count_30d }}</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-card variant="outlined" class="pa-4">
|
||||
<div class="d-flex justify-space-between align-center mb-3">
|
||||
<h2 class="text-h6 ma-0">Тенанты</h2>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
placeholder="Поиск по названию или ИНН"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
style="max-width: 320px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-data-table
|
||||
:headers="headers"
|
||||
:items="filteredRows"
|
||||
:items-per-page="20"
|
||||
density="comfortable"
|
||||
class="font-mono-nums"
|
||||
>
|
||||
<template #[`item.name`]="{ item }">
|
||||
<div>
|
||||
<div class="font-weight-medium">{{ item.name }}</div>
|
||||
<div class="text-caption text-medium-emphasis">ИНН {{ item.inn }}</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #[`item.tariff`]="{ item }">
|
||||
{{ tariffMap[item.tariff] }}
|
||||
</template>
|
||||
<template #[`item.balance_rub`]="{ item }">
|
||||
<span
|
||||
:class="
|
||||
item.balance_rub < 0 ? 'text-error' : item.balance_rub === 0 ? 'text-medium-emphasis' : ''
|
||||
"
|
||||
>
|
||||
{{ formatRub(item.balance_rub) }}
|
||||
</span>
|
||||
</template>
|
||||
<template #[`item.monthly_topups_rub`]="{ item }">
|
||||
{{ formatRub(item.monthly_topups_rub) }}
|
||||
</template>
|
||||
<template #[`item.monthly_charges_rub`]="{ item }">
|
||||
{{ formatRub(item.monthly_charges_rub) }}
|
||||
</template>
|
||||
<template #[`item.mrr_rub`]="{ item }">
|
||||
{{ formatRub(item.mrr_rub) }}
|
||||
</template>
|
||||
<template #[`item.status`]="{ item }">
|
||||
<v-chip :color="statusMap[item.status].color" size="small" variant="tonal">
|
||||
{{ statusMap[item.status].label }}
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-data-table>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-billing {
|
||||
max-width: 1400px;
|
||||
}
|
||||
.page-title {
|
||||
font-variation-settings: 'opsz' 28;
|
||||
letter-spacing: -0.018em;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
.tabular {
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Админка SaaS → Инциденты.
|
||||
*
|
||||
* Журнал инцидентов SaaS-уровня (incidents_log по schema v8.7 §9).
|
||||
* Категории: PDN-breach, service_outage, security, billing, data_loss.
|
||||
* При PDN-breach — обязательное уведомление РКН за 24 ч (152-ФЗ).
|
||||
*
|
||||
* MVP — display + фильтр по статусу/severity. Backend `/api/admin/incidents`
|
||||
* подключается отдельным коммитом.
|
||||
*/
|
||||
import { ADMIN_INCIDENTS } from '../../composables/mockAdmin';
|
||||
import type { AdminIncidentRow } from '../../composables/mockAdmin';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const filterStatus = ref<'all' | AdminIncidentRow['status']>('all');
|
||||
|
||||
const statusMap: Record<AdminIncidentRow['status'], { label: string; color: string }> = {
|
||||
open: { label: 'Открыт', color: 'error' },
|
||||
investigating: { label: 'Расследуется', color: 'warning' },
|
||||
resolved: { label: 'Решён', color: 'info' },
|
||||
closed: { label: 'Закрыт', color: 'success' },
|
||||
};
|
||||
|
||||
const severityMap: Record<AdminIncidentRow['severity'], { label: string; color: string }> = {
|
||||
critical: { label: 'Critical', color: 'error' },
|
||||
high: { label: 'High', color: 'warning' },
|
||||
medium: { label: 'Medium', color: 'info' },
|
||||
low: { label: 'Low', color: 'success' },
|
||||
};
|
||||
|
||||
const categoryMap: Record<AdminIncidentRow['category'], string> = {
|
||||
pdn_breach: 'Утечка ПДн',
|
||||
service_outage: 'Сбой сервиса',
|
||||
security: 'Безопасность',
|
||||
billing: 'Биллинг',
|
||||
data_loss: 'Потеря данных',
|
||||
};
|
||||
|
||||
const filteredRows = computed(() =>
|
||||
filterStatus.value === 'all' ? ADMIN_INCIDENTS : ADMIN_INCIDENTS.filter((r) => r.status === filterStatus.value),
|
||||
);
|
||||
|
||||
const stats = computed(() => ({
|
||||
open: ADMIN_INCIDENTS.filter((r) => r.status === 'open').length,
|
||||
investigating: ADMIN_INCIDENTS.filter((r) => r.status === 'investigating').length,
|
||||
rkn_pending: ADMIN_INCIDENTS.filter((r) => r.category === 'pdn_breach' && !r.rkn_notified).length,
|
||||
}));
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container fluid class="admin-incidents pa-6">
|
||||
<header class="page-head mb-4">
|
||||
<h1 class="text-h4 page-title">Инциденты</h1>
|
||||
<p class="text-body-2 text-medium-emphasis ma-0">
|
||||
Журнал инцидентов SaaS-уровня. PDN-breach — уведомление РКН за 24 ч (152-ФЗ).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<v-row class="mb-4" data-testid="incidents-stats">
|
||||
<v-col cols="12" sm="4">
|
||||
<v-card variant="outlined" class="pa-3">
|
||||
<div class="text-caption text-medium-emphasis">Открыто</div>
|
||||
<div class="text-h6 text-error">{{ stats.open }}</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-card variant="outlined" class="pa-3">
|
||||
<div class="text-caption text-medium-emphasis">Расследуется</div>
|
||||
<div class="text-h6 text-warning">{{ stats.investigating }}</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
<v-col cols="12" sm="4">
|
||||
<v-card variant="outlined" class="pa-3">
|
||||
<div class="text-caption text-medium-emphasis">РКН-уведомлений</div>
|
||||
<div class="text-h6 text-error">{{ stats.rkn_pending }}</div>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-card variant="outlined" class="pa-4">
|
||||
<div class="d-flex justify-space-between align-center mb-3 flex-wrap ga-2">
|
||||
<h2 class="text-h6 ma-0">События</h2>
|
||||
<v-btn-toggle v-model="filterStatus" mandatory density="comfortable" variant="outlined">
|
||||
<v-btn value="all" size="small">Все</v-btn>
|
||||
<v-btn value="open" size="small">Открыты</v-btn>
|
||||
<v-btn value="investigating" size="small">Расследуются</v-btn>
|
||||
<v-btn value="resolved" size="small">Решены</v-btn>
|
||||
<v-btn value="closed" size="small">Закрыты</v-btn>
|
||||
</v-btn-toggle>
|
||||
</div>
|
||||
|
||||
<v-list lines="three" class="incidents-list">
|
||||
<v-list-item v-for="row in filteredRows" :key="row.id" class="incident-row">
|
||||
<div class="incident-header">
|
||||
<span class="font-mono text-caption text-medium-emphasis">{{ row.incident_id }}</span>
|
||||
<v-chip :color="severityMap[row.severity].color" size="x-small" variant="tonal" class="ml-2">
|
||||
{{ severityMap[row.severity].label }}
|
||||
</v-chip>
|
||||
<v-chip :color="statusMap[row.status].color" size="x-small" variant="tonal" class="ml-2">
|
||||
{{ statusMap[row.status].label }}
|
||||
</v-chip>
|
||||
<v-chip
|
||||
v-if="row.category === 'pdn_breach' && !row.rkn_notified"
|
||||
color="error"
|
||||
size="x-small"
|
||||
variant="flat"
|
||||
class="ml-2"
|
||||
>
|
||||
РКН pending
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="font-weight-medium mt-1">{{ row.title }}</div>
|
||||
<div class="text-caption text-medium-emphasis mt-1">
|
||||
Категория: {{ categoryMap[row.category] }} · Затронуто тенантов: {{ row.affected_tenants }} ·
|
||||
Обнаружен: {{ formatDate(row.detected_at) }}
|
||||
<span v-if="row.rkn_deadline_at"> · Дедлайн РКН: {{ formatDate(row.rkn_deadline_at) }}</span>
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-incidents {
|
||||
max-width: 1200px;
|
||||
}
|
||||
.page-title {
|
||||
font-variation-settings: 'opsz' 28;
|
||||
letter-spacing: -0.018em;
|
||||
}
|
||||
.incident-row {
|
||||
padding-block: 12px;
|
||||
border-bottom: 1px solid #e1eeea;
|
||||
}
|
||||
.incident-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.incident-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Админка SaaS → Система.
|
||||
*
|
||||
* Глобальные настройки SaaS-уровня (system_settings по schema v8.7 §10):
|
||||
* лимиты квот, тарифные планы, фичефлаги, fallback supplier_id.
|
||||
*
|
||||
* MVP — display + read-only edit-режим. Backend `/api/admin/system-settings`
|
||||
* + edit-flow подключаются отдельным коммитом.
|
||||
*/
|
||||
import { ADMIN_SYSTEM_SETTINGS } from '../../composables/mockAdmin';
|
||||
import type { AdminSystemSetting } from '../../composables/mockAdmin';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const search = ref('');
|
||||
|
||||
const filteredSettings = computed(() => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
if (!q) return ADMIN_SYSTEM_SETTINGS;
|
||||
return ADMIN_SYSTEM_SETTINGS.filter(
|
||||
(s) => s.key.toLowerCase().includes(q) || s.description.toLowerCase().includes(q),
|
||||
);
|
||||
});
|
||||
|
||||
const typeColor: Record<AdminSystemSetting['type'], string> = {
|
||||
int: 'info',
|
||||
string: 'success',
|
||||
bool: 'warning',
|
||||
json: 'secondary',
|
||||
};
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container fluid class="admin-system pa-6">
|
||||
<header class="page-head mb-4">
|
||||
<h1 class="text-h4 page-title">Система</h1>
|
||||
<p class="text-body-2 text-medium-emphasis ma-0">
|
||||
Глобальные настройки SaaS: лимиты квот, тарифные планы, фичефлаги.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<v-alert type="warning" variant="tonal" class="mb-4" density="compact">
|
||||
<strong>Read-only режим.</strong> Edit-flow с двойным подтверждением и audit-log подключается отдельным
|
||||
коммитом.
|
||||
</v-alert>
|
||||
|
||||
<v-card variant="outlined" class="pa-4">
|
||||
<div class="d-flex justify-space-between align-center mb-3">
|
||||
<h2 class="text-h6 ma-0">system_settings</h2>
|
||||
<v-text-field
|
||||
v-model="search"
|
||||
placeholder="Поиск по ключу или описанию"
|
||||
prepend-inner-icon="mdi-magnify"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
clearable
|
||||
style="max-width: 320px"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<v-list class="settings-list">
|
||||
<v-list-item
|
||||
v-for="setting in filteredSettings"
|
||||
:key="setting.key"
|
||||
class="setting-row"
|
||||
data-testid="setting-row"
|
||||
>
|
||||
<div class="setting-header">
|
||||
<span class="setting-key font-mono">{{ setting.key }}</span>
|
||||
<v-chip :color="typeColor[setting.type]" size="x-small" variant="tonal" class="ml-2">
|
||||
{{ setting.type }}
|
||||
</v-chip>
|
||||
</div>
|
||||
<div class="setting-value font-mono mt-1">{{ setting.value }}</div>
|
||||
<div class="text-caption text-medium-emphasis mt-1">
|
||||
{{ setting.description }} · обновлено {{ formatDate(setting.updated_at) }}
|
||||
</div>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-system {
|
||||
max-width: 1100px;
|
||||
}
|
||||
.page-title {
|
||||
font-variation-settings: 'opsz' 28;
|
||||
letter-spacing: -0.018em;
|
||||
}
|
||||
.setting-row {
|
||||
padding-block: 12px;
|
||||
border-bottom: 1px solid #e1eeea;
|
||||
}
|
||||
.setting-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.setting-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.setting-key {
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
color: #081319;
|
||||
}
|
||||
.setting-value {
|
||||
font-size: 13px;
|
||||
background: #f6f3ec;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
.font-mono {
|
||||
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import AdminBillingView from '../../resources/js/views/admin/AdminBillingView.vue';
|
||||
|
||||
const mountView = async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/admin/billing', component: AdminBillingView }],
|
||||
});
|
||||
await router.push('/admin/billing');
|
||||
await router.isReady();
|
||||
return mount(AdminBillingView, {
|
||||
global: { plugins: [createVuetify(), router] },
|
||||
});
|
||||
};
|
||||
|
||||
describe('AdminBillingView.vue', () => {
|
||||
it('монтируется и содержит заголовок «Биллинг»', async () => {
|
||||
const wrapper = await mountView();
|
||||
expect(wrapper.text()).toContain('Биллинг');
|
||||
});
|
||||
|
||||
it('содержит 4 stats: MRR / Выручка / Просрочка / Возвраты', async () => {
|
||||
const wrapper = await mountView();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('MRR');
|
||||
expect(text).toContain('Выручка за месяц');
|
||||
expect(text).toContain('Просрочка');
|
||||
expect(text).toContain('Возвраты за 30 дн');
|
||||
});
|
||||
|
||||
it('содержит таблицу тенантов с tariff/balance/MRR/status', async () => {
|
||||
const wrapper = await mountView();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Окна Москва');
|
||||
expect(text).toContain('BigCorp');
|
||||
expect(text).toContain('Команда');
|
||||
expect(text).toContain('Активен');
|
||||
expect(text).toContain('Просрочка');
|
||||
expect(text).toContain('Заблокирован');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import AdminIncidentsView from '../../resources/js/views/admin/AdminIncidentsView.vue';
|
||||
|
||||
const mountView = async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/admin/incidents', component: AdminIncidentsView }],
|
||||
});
|
||||
await router.push('/admin/incidents');
|
||||
await router.isReady();
|
||||
return mount(AdminIncidentsView, {
|
||||
global: { plugins: [createVuetify(), router] },
|
||||
});
|
||||
};
|
||||
|
||||
describe('AdminIncidentsView.vue', () => {
|
||||
it('монтируется и содержит заголовок «Инциденты»', async () => {
|
||||
const wrapper = await mountView();
|
||||
expect(wrapper.text()).toContain('Инциденты');
|
||||
});
|
||||
|
||||
it('содержит 3 stats: Открыто / Расследуется / РКН-уведомлений', async () => {
|
||||
const wrapper = await mountView();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Открыто');
|
||||
expect(text).toContain('Расследуется');
|
||||
expect(text).toContain('РКН-уведомлений');
|
||||
});
|
||||
|
||||
it('содержит фильтр-toggle по статусам (5 значений)', async () => {
|
||||
const wrapper = await mountView();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Все');
|
||||
expect(text).toContain('Открыты');
|
||||
expect(text).toContain('Решены');
|
||||
expect(text).toContain('Закрыты');
|
||||
});
|
||||
|
||||
it('показывает PDN-breach с РКН pending chip', async () => {
|
||||
const wrapper = await mountView();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Утечка ПДн');
|
||||
expect(text).toContain('РКН pending');
|
||||
});
|
||||
|
||||
it('содержит incident_id в формате INC-YYYY-MMDD-NNNN', async () => {
|
||||
const wrapper = await mountView();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('INC-2026-0507-0034');
|
||||
expect(text).toContain('INC-2026-0506-0028');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import { createVuetify } from 'vuetify';
|
||||
import { createRouter, createMemoryHistory } from 'vue-router';
|
||||
import AdminSystemView from '../../resources/js/views/admin/AdminSystemView.vue';
|
||||
|
||||
const mountView = async () => {
|
||||
const router = createRouter({
|
||||
history: createMemoryHistory(),
|
||||
routes: [{ path: '/admin/system', component: AdminSystemView }],
|
||||
});
|
||||
await router.push('/admin/system');
|
||||
await router.isReady();
|
||||
return mount(AdminSystemView, {
|
||||
global: { plugins: [createVuetify(), router] },
|
||||
});
|
||||
};
|
||||
|
||||
describe('AdminSystemView.vue', () => {
|
||||
it('монтируется и содержит заголовок «Система»', async () => {
|
||||
const wrapper = await mountView();
|
||||
expect(wrapper.text()).toContain('Система');
|
||||
});
|
||||
|
||||
it('показывает read-only warning', async () => {
|
||||
const wrapper = await mountView();
|
||||
expect(wrapper.text()).toContain('Read-only');
|
||||
});
|
||||
|
||||
it('перечисляет ключевые system_settings (rate-limit, retention, login_max_attempts)', async () => {
|
||||
const wrapper = await mountView();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('webhook_rate_limit_rps');
|
||||
expect(text).toContain('login_max_attempts');
|
||||
expect(text).toContain('password_min_length');
|
||||
expect(text).toContain('webhook_log_retention_days');
|
||||
expect(text).toContain('maintenance_mode');
|
||||
});
|
||||
|
||||
it('содержит type-chip для каждой строки (int/string/bool/json)', async () => {
|
||||
const wrapper = await mountView();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('int');
|
||||
expect(text).toContain('bool');
|
||||
});
|
||||
|
||||
it('число строк settings = 7 (mock count)', async () => {
|
||||
const wrapper = await mountView();
|
||||
const rows = wrapper.findAll('[data-testid="setting-row"]');
|
||||
expect(rows.length).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,20 @@
|
||||
|
||||
**Назначение:** единый рабочий список вопросов, требующих решения заказчика для разблокировки разработки. Разбит по адресатам, внутри — по приоритету.
|
||||
|
||||
**Версия:** 1.51 от 09.05.2026 — **Email-уведомление при 3 неудачных попытках входа (ТЗ §22.4.4 п.3)**. Закрыт последний пункт ТЗ §22.4.4 анти-брутфорс. **Pest 111/111 + Vitest 166/166 + Histoire 21/28 зелёные**.
|
||||
**Версия:** 1.52 от 09.05.2026 — **Admin views (Биллинг / Инциденты / Система)**. Закрыт пункт #8: 3 placeholder'а заменены на реальные display-views с mock-данными по schema v8.7. **Pest 111/111 + Vitest 179/179 + Histoire 21/28 зелёные**.
|
||||
|
||||
**Что изменилось в v1.52 относительно v1.51:**
|
||||
|
||||
- **`AdminBillingView`** — 4 stats (MRR / Выручка / Просрочка / Возвраты) + v-data-table 7 колонок + поиск.
|
||||
- **`AdminIncidentsView`** — 3 stats (Открыто / Расследуется / РКН-уведомлений) + 5 фильтров + v-list с incident_id, severity, status, РКН-pending chip для PDN-breach.
|
||||
- **`AdminSystemView`** — read-only warning + 7 system_settings из mock + type-chip + поиск.
|
||||
- **`composables/mockAdmin.ts`** — 3 типа + mock-данные.
|
||||
- Маршруты `/admin/{billing,incidents,system}` ведут на реальные views (не placeholder).
|
||||
- **Vitest +13** (всего **179/179 за 11.98 сек**).
|
||||
- **Регресс зелёный:** lint+type+format OK; build 743ms; story:build 21/28 за 31.5с.
|
||||
- **Что НЕ сделано:** #9 Impersonation flow (Ю-1).
|
||||
|
||||
**Что изменилось в v1.51 относительно v1.50:** **Email-уведомление при 3 неудачных попытках входа (ТЗ §22.4.4 п.3)**. Закрыт последний пункт ТЗ §22.4.4 анти-брутфорс. **Pest 111/111 + Vitest 166/166 + Histoire 21/28 зелёные**.
|
||||
|
||||
**Что изменилось в v1.51 относительно v1.50:**
|
||||
|
||||
|
||||
Reference in New Issue
Block a user