Files
portal/app/resources/js/components/layout/AppSidebar.vue
T
Дмитрий 55a34af986 feat(deals): redesign groundwork — spec, plan, mockups + sidebar nav cleanup
Deals page redesign: design spec + implementation plan (Phase A page redesign,
Phase B 14->5 status funnel) + v8 HTML mockups (variants comparison + final).
AppSidebar: remove Импорт данных / Отчёты nav links (routes stay reachable by
direct URL); AppLayout.spec updated to 6 nav items. stylelint --fix on mockups;
cspell-words += deals-redesign terms.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 03:42:39 +03:00

245 lines
6.7 KiB
Vue

<script setup lang="ts">
/**
* Sprint 4 Phase B/1 (audit O-refactor-04 хвост): sidebar выделен из AppLayout.
* Task 12 (Portal Redesign Quiet Luxury): двухтоновый shell + ⌘K stub + group-eyebrows
* + active-marker pseudo-element + JetBrains Mono badges.
*
* Brand mark + nav-tree (3 группы: Работа, Финансы, Команда).
* Count для «Сделки» — live из API (dealsCount-store, audit B2).
*/
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import Kbd from '../ui/Kbd.vue';
import { useAuthStore } from '../../stores/auth';
import { useDealsCountStore } from '../../stores/dealsCount';
import { useCommandPalette } from '../../composables/useCommandPalette';
interface NavItem {
title: string;
icon: string;
to: string;
count?: number;
countKey?: string;
}
interface NavGroup {
eyebrow: string;
items: NavItem[];
}
const drawerOpen = defineModel<boolean>('drawerOpen', { default: true });
const route = useRoute();
const auth = useAuthStore();
const dealsCount = useDealsCountStore();
const { openPalette } = useCommandPalette();
onMounted(() => {
if (auth.user?.tenant_id) void dealsCount.load(auth.user.tenant_id);
});
const navGroups = computed<NavGroup[]>(() => [
{
eyebrow: 'Работа',
items: [
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
// B2: count из dealsCount-store; null → undefined (NavItem.count — number|undefined),
// resolveCount затем → 0 и v-if скрывает бейдж пока счётчик не загружен.
{
title: 'Сделки',
icon: 'mdi-format-list-bulleted',
to: '/deals',
count: dealsCount.count ?? undefined,
countKey: 'deals',
},
{ title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
],
},
{
eyebrow: 'Финансы',
items: [
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/billing' },
],
},
{
eyebrow: 'Команда',
items: [{ title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' }],
},
]);
function resolveCount(item: NavItem): number {
return item.count ?? 0;
}
defineExpose({ navGroups });
</script>
<template>
<aside class="ld-sidebar" :data-open="drawerOpen">
<div class="ld-sidebar__brand">
<span class="ld-sidebar__brand-name">Лидерра<span class="ld-sidebar__brand-dot">.</span></span>
</div>
<div
class="ld-cmdk-stub"
role="button"
tabindex="0"
data-testid="cmdk-stub"
@click="openPalette"
@keydown.enter="openPalette"
@keydown.space.prevent="openPalette"
>
<span class="ld-cmdk-stub__placeholder">Поиск, команды</span>
<Kbd dark>K</Kbd>
</div>
<nav class="ld-sidebar__nav">
<div v-for="(group, gi) in navGroups" :key="gi" class="ld-nav-group">
<div class="ld-nav-group__eyebrow">{{ group.eyebrow }}</div>
<RouterLink
v-for="item in group.items"
:key="item.to"
:to="item.to"
class="ld-nav-item"
:class="{ 'ld-nav-item--active': route.path === item.to }"
>
<span class="ld-nav-item__title">{{ item.title }}</span>
<span
v-if="resolveCount(item) > 0"
class="ld-nav-item__badge ld-mono"
:data-testid="item.countKey ? `nav-count-${item.countKey}` : undefined"
>{{ resolveCount(item) }}</span
>
</RouterLink>
</div>
</nav>
</aside>
</template>
<style scoped>
.ld-sidebar {
background: linear-gradient(180deg, var(--liderra-noir) 0%, #04261e 100%);
color: #e8e2d4;
padding: 20px 14px;
width: 232px;
height: 100vh;
display: flex;
flex-direction: column;
position: fixed;
top: 0;
left: 0;
z-index: 1006;
overflow-y: auto;
}
.ld-sidebar__brand {
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
padding: 0 8px;
margin-bottom: 22px;
}
.ld-sidebar__brand-name {
color: var(--liderra-ivory);
}
.ld-sidebar__brand-dot {
color: var(--liderra-teal);
}
.ld-cmdk-stub {
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.08);
padding: 8px 11px;
border-radius: var(--radius-8);
font-size: 12px;
color: #9b9484;
margin-bottom: 18px;
cursor: pointer;
transition: background 200ms cubic-bezier(0.16, 1, 0.3, 1);
}
.ld-cmdk-stub:hover {
background: rgba(255, 255, 255, 0.1);
}
.ld-sidebar__nav {
flex: 1;
}
.ld-nav-group {
margin-bottom: 6px;
}
.ld-nav-group__eyebrow {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.14em;
color: #6b7470;
margin: 14px 8px 4px;
font-weight: 500;
}
.ld-nav-item {
display: flex;
align-items: center;
gap: 10px;
padding: 7px 10px;
border-radius: var(--radius-6);
font-size: 13px;
color: #b8b0a0;
text-decoration: none;
position: relative;
transition:
color 200ms cubic-bezier(0.16, 1, 0.3, 1),
background 200ms cubic-bezier(0.16, 1, 0.3, 1);
margin-bottom: 1px;
}
.ld-nav-item:hover {
color: #e8e2d4;
background: rgba(255, 255, 255, 0.04);
}
.ld-nav-item--active {
color: var(--liderra-ivory);
background: rgba(15, 110, 86, 0.22);
}
.ld-nav-item--active::before {
content: '';
position: absolute;
left: 0;
top: 6px;
bottom: 6px;
width: 2px;
background: var(--liderra-teal);
border-radius: 2px;
transform-origin: center;
animation: ld-marker-grow 250ms cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes ld-marker-grow {
from {
transform: scaleY(0);
}
to {
transform: scaleY(1);
}
}
.ld-nav-item__title {
flex: 1;
}
.ld-nav-item__badge {
font-size: 10px;
background: rgba(255, 255, 255, 0.08);
padding: 1px 6px;
border-radius: 4px;
color: #b8b0a0;
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-feature-settings: 'tnum' 1;
}
.ld-nav-item--active .ld-nav-item__badge {
background: rgba(255, 255, 255, 0.1);
color: var(--liderra-ivory);
}
</style>