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:
Дмитрий
2026-05-09 04:17:17 +03:00
parent 2e91469a07
commit f65b2ca8d8
10 changed files with 840 additions and 25 deletions
+3 -1
View File
@@ -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.*
+216
View File
@@ -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',
},
];
+6 -23
View File
@@ -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);
});
});
+14 -1
View File
@@ -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:**