Files
portal/app/resources/js/components/layout/AppSidebar.vue
T
Дмитрий 30ef61dff8 refactor(frontend): Sprint 4 Phase B/1 — split 3 admin/layout views (audit O-refactor-04 хвост)
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>
2026-05-10 04:38:08 +03:00

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>