Files
portal/app/resources/js/layouts/AdminLayout.vue
T
Дмитрий a56dcb06b2 feat(биллинг): оплата по счёту (Этап 1) — счёт, акт, отметка оплаты
Клиент сам выставляет PDF-счёт (TopupDialog вкладка «По счёту»), счета и
акты — в отдельной вкладке «Счета». Админ (/admin/invoices) отмечает оплату
одной кнопкой → атомарно зачисляет баланс (BillingTopupService), формирует
Акт (без НДС, saas_upd_documents ДОП) и шлёт клиенту письмо «Счёт оплачен»
с вложением PDF-акта. PDF открываются inline в браузере (ASCII-имя).

- Сервисы InvoiceNumberGenerator/InvoiceService/ActService/InvoicePaymentService/PdfRenderer
- Контроллеры InvoiceController (клиент) + AdminInvoiceController (список+mark-paid)
- Модели SaasInvoice/SaasInvoiceItem/SaasUpdDocument; шаблоны pdf/invoice|act
- Нумерация СЧ-ГГГГ-NNNNN (advisory-lock); просрочка invoices:expire (cron)
- Наименование услуги: «Оплата генерации рекламных лидов»
- Зависимость barryvdh/laravel-dompdf (default_font dejavu sans); схема БД не менялась
- Этап 2 (автомат через ВТБ API) — отдельно, спека/план в docs/superpowers

Тесты: счета 13, Billing 138, фронт зелёные; larastan baseline +6 (Pest false-pos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 11:25:16 +03:00

233 lines
8.5 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-file-document-outline', to: '/admin/invoices' },
{ 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>