30ef61dff8
3 view'а с >300 строк разделены на shell + sub-components: AdminTenantsView 377→155 (+ TenantsStatsHeader 82 / TenantsFilters 93 / TenantsTable 116). AdminTenantDetailView 436→109 (+ TenantDetailHeader 158 / TenantDetailTabs 176 + adminTenantDetailFormatters 43 composable). AppLayout 466→78 (+ AppSidebar 155 / AppTopbar 269; R0.6 hard-стоп снят явным запросом заказчика 10.05.2026). State (filterStatuses, tenantsState, activeTab, tenant, drawerOpen) остаётся в parent view'ах ради `defineExpose`-контракта Vitest тестов. Sub-components читают Pinia stores напрямую (auth + notifications + reminders) — без prop-drilling. AppTopbar 269 строк <300 — acceptance threshold выдержан (можно дальше split на NotificationsDropdown + UserMenu в отдельном flow, не критично). Регрессия: ESLint 0 + vue-tsc 0 + Vitest 416/416 + build OK 1.17 сек. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
156 lines
4.9 KiB
Vue
156 lines
4.9 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* Sprint 4 Phase B/1 (audit O-refactor-04 хвост): sidebar выделен из AppLayout.
|
|
* Brand mark + nav-tree (3 группы: Работа, Финансы, Команда).
|
|
* Counts для «Напоминания» — живой из remindersStore; «Сделки»/«Менеджеры» — mock.
|
|
*/
|
|
import { computed } from 'vue';
|
|
import { useRoute } from 'vue-router';
|
|
import { useRemindersStore } from '../../stores/reminders';
|
|
|
|
interface NavItem {
|
|
title: string;
|
|
icon: string;
|
|
to: string;
|
|
countKey?: 'deals' | 'reminders' | 'managers';
|
|
count?: number;
|
|
}
|
|
interface NavGroup {
|
|
eyebrow: string;
|
|
items: NavItem[];
|
|
}
|
|
|
|
const drawerOpen = defineModel<boolean>('drawerOpen', { default: true });
|
|
|
|
const route = useRoute();
|
|
const reminders = useRemindersStore();
|
|
|
|
const navGroups = computed<NavGroup[]>(() => [
|
|
{
|
|
eyebrow: 'Работа',
|
|
items: [
|
|
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
|
|
{ title: 'Сделки', icon: 'mdi-format-list-bulleted', to: '/deals', count: 247 },
|
|
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
|
|
{
|
|
title: 'Напоминания',
|
|
icon: 'mdi-clock-outline',
|
|
to: '/reminders',
|
|
countKey: 'reminders',
|
|
count: reminders.counts.active,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
eyebrow: 'Финансы',
|
|
items: [
|
|
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/billing' },
|
|
{ title: 'Отчёты', icon: 'mdi-chart-box-outline', to: '/reports' },
|
|
],
|
|
},
|
|
{
|
|
eyebrow: 'Команда',
|
|
items: [
|
|
{ title: 'Менеджеры', icon: 'mdi-account-group-outline', to: '/managers', count: 4 },
|
|
{ title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' },
|
|
],
|
|
},
|
|
]);
|
|
|
|
defineExpose({ navGroups });
|
|
</script>
|
|
|
|
<template>
|
|
<v-navigation-drawer v-model="drawerOpen" color="secondary" theme="dark" :width="240" :rail="false" class="app-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>
|
|
|
|
<v-list nav density="comfortable" class="app-nav">
|
|
<template v-for="group in navGroups" :key="group.eyebrow">
|
|
<v-list-subheader class="nav-eyebrow">{{ group.eyebrow }}</v-list-subheader>
|
|
<v-list-item
|
|
v-for="item in group.items"
|
|
:key="item.to"
|
|
:to="item.to"
|
|
:prepend-icon="item.icon"
|
|
:active="route.path === item.to"
|
|
rounded="lg"
|
|
class="nav-item"
|
|
>
|
|
<v-list-item-title>{{ item.title }}</v-list-item-title>
|
|
<template v-if="item.count !== undefined && item.count > 0" #append>
|
|
<span
|
|
class="nav-count"
|
|
:data-testid="item.countKey ? `nav-count-${item.countKey}` : undefined"
|
|
>{{ item.count }}</span>
|
|
</template>
|
|
</v-list-item>
|
|
</template>
|
|
</v-list>
|
|
</v-navigation-drawer>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.app-drawer {
|
|
border-right: 1px solid rgba(255, 255, 255, 0.06);
|
|
}
|
|
.brand-block {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
padding: 18px 20px;
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
}
|
|
.brand-mark {
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 5px;
|
|
background: #fff;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
flex-shrink: 0;
|
|
}
|
|
.brand-text {
|
|
font-weight: 600;
|
|
font-size: 16px;
|
|
letter-spacing: -0.01em;
|
|
color: #fff;
|
|
}
|
|
.brand-dot {
|
|
color: #32c8a9;
|
|
}
|
|
.nav-eyebrow {
|
|
font-size: 11px !important;
|
|
font-weight: 600;
|
|
letter-spacing: 0.06em;
|
|
text-transform: uppercase;
|
|
color: #7a8c87 !important;
|
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
|
padding-top: 16px !important;
|
|
}
|
|
.nav-count {
|
|
font-family: 'JetBrains Mono', ui-monospace, monospace;
|
|
font-feature-settings: 'tnum';
|
|
font-size: 11px;
|
|
color: #7a8c87;
|
|
background: rgba(255, 255, 255, 0.05);
|
|
padding: 2px 7px;
|
|
border-radius: 10px;
|
|
}
|
|
</style>
|