Files
portal/app/resources/js/layouts/SalesLayout.vue
T
Дмитрий ef0f7c803f feat(sales): каркас фронта портала — layout, роутер, вход, период-пикер
Task 0.7: SalesLayout (сайдбар ОТДЕЛ ПРОДАЖ, 2 секции nav, boss-only для head), SalesLoginView (реальные email+пароль, не демо-кнопки), сторы salesAuth/salesPeriod, api/sales.ts, PeriodPicker (этот/прошлый/позапрошлый/произвольный), HelpHint (?). Роуты /sales/* с гардом (токен→login, boss-only→/sales). Заглушка SalesStubView для экранов будущих фаз. Vitest SalesLogin 5/5, весь фронт 1005 без регрессий, type-check/lint чисто. Вёрстка по демо v8_sales.html. Один эскейп на сессию.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 13:27:04 +03:00

265 lines
8.8 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 портала отдела продаж — sidebar #012019 с брендом «Лидерра / ОТДЕЛ ПРОДАЖ»,
* двумя группами навигации (Менеджер / Начальник), topbar с PeriodPicker.
*
* Источник дизайна: liderra_v8_handoff/concepts/v8_sales.html #screen-portal.
* Структурная модель: AdminLayout.vue.
*
* Секция «Начальник» отображается только при salesAuth.isHead === true.
*/
import { computed } from 'vue';
import { RouterView, useRoute, useRouter } from 'vue-router';
import { useSalesAuthStore } from '../stores/salesAuth';
import PeriodPicker from '../components/sales/PeriodPicker.vue';
interface NavItem {
title: string;
icon: string;
to: string;
}
const managerNav: NavItem[] = [
{ title: 'Сводка', icon: 'mdi-view-dashboard-outline', to: '/sales' },
{ title: 'Мои клиенты', icon: 'mdi-account-group-outline', to: '/sales/clients' },
{ title: 'Привязать клиента', icon: 'mdi-account-search-outline', to: '/sales/attach' },
{ title: 'Мой доход', icon: 'mdi-currency-rub', to: '/sales/income' },
];
const bossNav: NavItem[] = [
{ title: 'Сводка отдела', icon: 'mdi-chart-line', to: '/sales/boss' },
{ title: 'Результативность', icon: 'mdi-account-check-outline', to: '/sales/performance' },
{ title: 'Тарифы менеджеров', icon: 'mdi-tag-arrow-right', to: '/sales/tariffs' },
{ title: 'Счета', icon: 'mdi-file-document-outline', to: '/sales/invoices' },
{ title: 'Заявки на привязку', icon: 'mdi-file-check-outline', to: '/sales/requests' },
{ title: 'Выплаты', icon: 'mdi-credit-card-outline', to: '/sales/payouts' },
{ title: 'Менеджеры', icon: 'mdi-account-multiple-outline', to: '/sales/managers' },
];
const route = useRoute();
const router = useRouter();
const salesAuth = useSalesAuthStore();
const roleName = computed(() => (salesAuth.isHead ? 'Начальник' : 'Менеджер'));
const userInitials = computed(() => {
const u = salesAuth.user;
if (!u) return 'МП';
const parts = u.name.split(' ').filter(Boolean);
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
return u.name.slice(0, 2).toUpperCase();
});
const currentPageTitle = computed(() => {
const all = [...managerNav, ...bossNav];
return all.find((i) => route.path === i.to || route.path.startsWith(i.to + '/'))?.title ?? 'Продажи';
});
function isActive(to: string): boolean {
if (to === '/sales') return route.path === '/sales';
return route.path.startsWith(to);
}
async function handleLogout() {
await salesAuth.logout();
await router.push('/sales/login');
}
</script>
<template>
<v-app>
<v-navigation-drawer color="#012019" theme="dark" :width="240" class="sales-drawer">
<!-- Brand block -->
<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">ОТДЕЛ ПРОДАЖ</div>
<v-list nav density="comfortable" class="app-nav" role="navigation" aria-label="Навигация отдела продаж">
<!-- МЕНЕДЖЕР group -->
<div class="nav-eyebrow">Менеджер</div>
<v-list-item
v-for="item in managerNav"
:key="item.to"
:to="item.to"
:prepend-icon="item.icon"
:active="isActive(item.to)"
rounded="lg"
class="nav-item"
:exact="item.to === '/sales'"
>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
<!-- НАЧАЛЬНИК group only for head role -->
<template v-if="salesAuth.isHead">
<div class="nav-eyebrow nav-eyebrow--boss">Начальник</div>
<v-list-item
v-for="item in bossNav"
:key="item.to"
:to="item.to"
:prepend-icon="item.icon"
:active="isActive(item.to)"
rounded="lg"
class="nav-item"
>
<v-list-item-title>{{ item.title }}</v-list-item-title>
</v-list-item>
</template>
</v-list>
</v-navigation-drawer>
<v-app-bar :elevation="0" color="surface" :height="56" class="sales-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 />
<!-- Period picker -->
<PeriodPicker class="mr-3" />
<!-- Role chip -->
<v-chip
:color="salesAuth.isHead ? '#7B4D00' : '#084635'"
:style="{
background: salesAuth.isHead ? '#FFF4DD' : '#E1EEEA',
color: salesAuth.isHead ? '#7B4D00' : '#084635',
}"
size="small"
class="role-chip mr-2"
label
>
{{ roleName.toUpperCase() }}
</v-chip>
<!-- User menu -->
<v-menu offset="8">
<template #activator="{ props }">
<v-btn
v-bind="props"
variant="outlined"
size="small"
class="user-chip mr-2"
aria-label="Меню пользователя"
>
<v-avatar size="24" color="#0F6E56" class="mr-2">
<span class="text-caption" style="color: #fff; font-size: 10px">{{ userInitials }}</span>
</v-avatar>
<span class="text-body-2">{{ salesAuth.user?.name ?? '' }}</span>
</v-btn>
</template>
<v-list density="compact" min-width="200">
<v-list-item v-if="salesAuth.user" :title="salesAuth.user.email" disabled />
<v-divider v-if="salesAuth.user" />
<v-list-item prepend-icon="mdi-logout" title="Выйти" @click="handleLogout" />
</v-list>
</v-menu>
</v-app-bar>
<v-main class="sales-main">
<RouterView />
</v-main>
</v-app>
</template>
<style scoped>
.sales-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.08em;
color: #32c8a9;
padding: 0 20px 14px;
text-transform: uppercase;
font-weight: 600;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.nav-eyebrow {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.01em;
color: rgba(255, 255, 255, 0.38);
padding: 14px 16px 6px;
text-transform: uppercase;
}
.nav-eyebrow--boss {
margin-top: 4px;
}
.sales-topbar {
border-bottom: 1px solid #d9d5cd !important;
}
.crumb {
display: flex;
align-items: center;
gap: 4px;
font-size: 14px;
margin-left: 8px;
}
.role-chip {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px !important;
letter-spacing: 0.04em;
font-weight: 600;
}
.user-chip {
text-transform: none;
border-color: #d9d5cd !important;
}
.sales-main {
background: #f6f3ec;
}
</style>