55a34af986
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>
245 lines
6.7 KiB
Vue
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>
|