Files
portal/app/resources/js/layouts/AdminLayout.vue
T
Дмитрий 6536c19c96 feat(дашборд): Этап A — сквозная вложенность Лиды до источника
Экран «Лиды» (/admin/leads): серверный список с фильтрами (дата/канал/поставщик/
статус/поиск) + пагинация (масштаб 10⁴+ лидов). Карточка лида (/admin/leads/{id}):
полная цепочка — ОТКУДА (поставщик B1/B2/B3 + канал + источник + регион) → КОМУ
(сделки клиентов через deals.source_crm_id = supplier_leads.vid). Дашборд: drill
Лиды +топ-10 последних + «Открыть все лиды →». Nav-пункт «Лиды». ПДн-телефон
маскируется (152-ФЗ). Тесты: backend 3 + FE 5 (38 FE всего зелёные).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 10:14:47 +03:00

232 lines
8.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* Layout админки SaaS — отдельный sidebar с пометкой ADMIN, 7 nav-пунктов,
* без user-chip как в обычной AppLayout.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_admin.html.
* Логика: SSO через Yandex 360 + break-glass `super_admin` (по ТЗ §22 / OPEN-И-13).
*
* Не входит в этот коммит:
* - Auth-guard на /admin/* — должен проверять `super_admin` role + 2FA.
* - Audit-log записей для всех action'ов admin (по schema v8.7 §10
* `saas_admin_audit_log`).
*/
import { useAuthStore } from '../stores/auth';
import { computed } from 'vue';
import { RouterView, useRoute, useRouter } from 'vue-router';
import DevIndexBadge from '../components/DevIndexBadge.vue';
import ImpersonationBanner from '../components/admin/ImpersonationBanner.vue';
interface NavItem {
title: string;
icon: string;
to: string;
count?: number;
}
const navItems: NavItem[] = [
{ title: 'Командный центр', icon: 'mdi-view-dashboard-outline', to: '/admin/dashboard' },
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants' },
{ title: 'Лиды', icon: 'mdi-target', to: '/admin/leads' },
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
{ title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' },
{ title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' },
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents' },
{ title: 'Impersonation', icon: 'mdi-account-switch', to: '/admin/impersonation' },
{ title: 'Система', icon: 'mdi-cog-outline', to: '/admin/system' },
{ title: 'Интеграция с поставщиком', icon: 'mdi-swap-horizontal', to: '/admin/supplier-integration' },
{ title: 'Проекты у поставщика', icon: 'mdi-format-list-checks', to: '/admin/supplier-projects' },
{ title: 'Обращения ПДн (152-ФЗ)', icon: 'mdi-shield-account-outline', to: '/admin/pd-subject-requests' },
];
const route = useRoute();
const router = useRouter();
const auth = useAuthStore();
/** DEV-режим: показываем баннер о застабленном auth-gate админки (B6). */
const isDevEnv = import.meta.env.DEV;
const userInitials = computed(() => {
const u = auth.user;
if (!u) return 'АО';
const first = u.first_name?.[0] ?? '';
const last = u.last_name?.[0] ?? '';
const initials = (first + last).toUpperCase();
return initials || u.email.slice(0, 2).toUpperCase();
});
const userShortName = computed(() => {
const u = auth.user;
if (!u) return 'Админ Оператор';
if (u.first_name && u.last_name) {
return `${u.first_name} ${u.last_name[0]}.`;
}
return u.first_name || u.email;
});
async function handleLogout() {
await auth.logout();
await router.push('/login');
}
const currentPageTitle = computed(() => {
return navItems.find((i) => route.path.startsWith(i.to))?.title ?? 'Админка';
});
</script>
<template>
<v-app>
<v-navigation-drawer color="#012019" theme="dark" :width="240" class="admin-drawer">
<div class="brand-block">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 48 48" width="22" height="22">
<path
d="M16 14 L16 34 L32 34"
stroke="#012019"
stroke-width="4.5"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<circle cx="32" cy="34" r="3.5" fill="#0F6E56" />
</svg>
</span>
<span class="brand-text">Лидерра<span class="brand-dot">.</span></span>
</div>
<div class="brand-sub">ADMIN</div>
<v-list nav density="comfortable" class="app-nav" role="navigation" aria-label="Админ навигация">
<v-list-item
v-for="item in navItems"
:key="item.to"
:to="item.to"
:prepend-icon="item.icon"
:active="route.path.startsWith(item.to)"
rounded="lg"
class="nav-item"
>
<v-list-item-title>{{ item.title }}</v-list-item-title>
<template v-if="item.count !== undefined" #append>
<span class="nav-count">{{ item.count }}</span>
</template>
</v-list-item>
</v-list>
</v-navigation-drawer>
<v-app-bar :elevation="0" color="surface" :height="56" class="admin-topbar">
<div class="crumb">
<span class="text-medium-emphasis">Админка</span>
<v-icon size="14" class="mx-1">mdi-chevron-right</v-icon>
<strong>{{ currentPageTitle }}</strong>
</div>
<v-spacer />
<v-menu offset="8">
<template #activator="{ props }">
<v-btn v-bind="props" variant="text" size="small" class="user-chip" aria-label="Меню админа">
<v-avatar size="28" color="error" class="mr-2">
<span class="text-caption">{{ userInitials }}</span>
</v-avatar>
<span class="text-body-2">{{ userShortName }}</span>
</v-btn>
</template>
<v-list density="compact" min-width="200">
<v-list-item v-if="auth.user" :title="auth.user.email" disabled />
<v-divider v-if="auth.user" />
<v-list-item :to="'/dashboard'" prepend-icon="mdi-arrow-left" title="Выйти из админки" />
<v-list-item prepend-icon="mdi-logout" title="Выйти" @click="handleLogout" />
</v-list>
</v-menu>
</v-app-bar>
<v-main class="admin-main">
<v-alert
v-if="isDevEnv"
type="warning"
variant="tonal"
density="compact"
class="ma-4"
data-testid="dev-auth-gap-banner"
>
DEV-режим: доступ к админке открыт без SSO-проверки middleware
<code>EnsureSaasAdmin</code> в dev пропускает все запросы. В production требуется вход через Yandex 360
+ роль <code>super_admin</code> (Б-1); неавторизованные запросы получают 503.
</v-alert>
<ImpersonationBanner />
<RouterView />
</v-main>
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
</v-app>
</template>
<style scoped>
.admin-drawer {
border-right: 1px solid rgba(255, 255, 255, 0.06);
}
.brand-block {
display: flex;
align-items: center;
gap: 10px;
padding: 18px 20px 4px;
}
.brand-mark {
width: 24px;
height: 24px;
border-radius: 5px;
background: #fff;
display: inline-flex;
align-items: center;
justify-content: center;
}
.brand-text {
font-weight: 600;
font-size: 16px;
color: #fff;
letter-spacing: -0.01em;
}
.brand-dot {
color: #32c8a9;
}
.brand-sub {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
letter-spacing: 0.16em;
color: #e06155;
padding: 0 20px 14px;
text-transform: uppercase;
font-weight: 600;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.nav-count {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum';
font-size: 11px;
color: #8a9c95;
background: rgba(255, 255, 255, 0.05);
padding: 2px 7px;
border-radius: 10px;
}
.admin-topbar {
border-bottom: 1px solid #d9d5cd !important;
}
.crumb {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
margin-left: 8px;
}
.user-chip {
text-transform: none;
}
.admin-main {
background: #f6f3ec;
}
</style>