Files
portal/app/resources/js/layouts/SalesLayout.vue
T
Дмитрий 25144a3cce feat(прогрев кандидатов): фронтенд трёх площадок вместо одного экрана
Экран «Реклама на кандидатов» разъехался на SalesWarmingView.vue,
параметризованный площадкой (yandex|vk|mts) через route /sales/warming/:platform:
рубильник и три числа — свои у каждой площадки, 11 сроков планировщика — пока
общие. Меню отдела продаж получило три пункта прогрева + пропущенный пункт
«Прогрев СМС». Таблица фирм лишилась группового выбора площадок — вместо неё
переключатель «Греть здесь» в строке. api/sales.ts получил getWarming/
updateWarming/listWarmingFirms/toggleWarmingFirm/assignWarmingFirm/
downloadWarmingMtsFile поверх нового backend /api/sales/warming/{platform}.

listSalesAdAudienceFirms оставлен (используется SalesSmsView.vue), но
backend-маршрут /api/sales/ad-audience/firms в этой ветке уже не
зарегистрирован — экран «Прогрев СМС» на данный момент не может загрузить
список фирм для галочек; чинить источник фирм там — вне периметра этой задачи.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 11:34:18 +03:00

287 lines
11 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-clock-outline', to: '/sales/prospects' },
{ 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-filter-variant', to: '/sales/prospects/board' },
{ 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' },
{ title: 'Прогрев — Яндекс', icon: 'mdi-bullhorn-outline', to: '/sales/warming/yandex' },
{ title: 'Прогрев — ВК', icon: 'mdi-alpha-v-box-outline', to: '/sales/warming/vk' },
{ title: 'Прогрев — Телеграм', icon: 'mdi-send-outline', to: '/sales/warming/mts' },
{ title: 'Прогрев СМС', icon: 'mdi-message-text-outline', to: '/sales/sms' },
];
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>
<!-- ym-hide-content на корне портала продаж защита от Вебвизора.
Своя страница входа (sales-login) счётчик не поднимает, но полагаться
на это нельзя: Метрика, запустившись где-то раньше, продолжает писать
при переходах внутри приложения, и роутером её не выключить.
Здесь карточки клиентов с телефонами класс размывает содержимое записи. -->
<v-app class="ym-hide-content">
<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>
<!-- Поиск клиентов (Python sales-finder, отдельная служба под /finder/) -->
<v-list-item
href="/finder/"
target="_blank"
rel="noopener"
prepend-icon="mdi-map-search-outline"
rounded="lg"
class="nav-item"
>
<v-list-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>