Files
portal/app/resources/js/components/layout/AppSidebar.vue
T
Дмитрий 4a993f8575 feat(кошелёк): видно, за что списали и сколько уходит на каждый канал
Три правки по замечаниям владельца от 06.08.2026.

1. Кошелёк переехал из «Рекламных возможностей» в «Финансы», рядом с
   «Биллингом» — это деньги, а не рекламный канал. Перенесён в обоих меню:
   боковая панель и мобильное «Ещё».

2. Каждое списание теперь называет канал, номер и название кампании:
   «Яндекс Аудитория · кампания №21 «Я4 приёмка — впритык…»». Раньше все до
   одного писались немой фразой «Списание за рекламу (факт)». Подпись
   собирается при показе, поэтому заговорили и уже записанные строки —
   летопись задним числом не переписывается, это финансовый документ.

   Попутно закрыта ловушка: номера кампаний у Яндекса и у СМС идут по разным
   счётчикам и совпадают. В боевой летописи есть и «кампания №4» от СМС, и
   рекламные — различить их было нечем. Теперь разводит канал в подписи.
   Поправлен падеж: было «Заморозка снята — кампанию №13».

3. Закладки по каналам с суммой трат прямо на закладке: Всё 157,74,
   Яндекс Аудитория 130,74, Рассылка СМС 27,00. Тратой считается только
   списание — заморозку ещё можно вернуть, что и случилось с 724 руб.

   Суммы считаются по ВСЕЙ летописи, а не по сотне отдаваемых строк: иначе у
   давнего канала цифра тихо усохла бы при переполнении ленты. Отдельный
   сторож создаёт 120 свежих движений и проверяет, что давние не пропали.

Сторожа: 6 на сервере, 5 на экране, каждый сначала увиден красным.
Рекламный блок 441 зелёный, фронт 2108 зелёных, статанализ ноль, типы чистые.
2026-08-06 06:35:55 +03:00

362 lines
12 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">
/**
* 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, ref } 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';
import { useAutopodborStore } from '../../stores/autopodborStore';
import AdStubDialog from './AdStubDialog.vue';
import { AD_CHANNELS } from './advertisingChannels';
interface NavItem {
title: string;
icon: string;
to?: string;
count?: number;
countKey?: string;
badge?: string;
// stub-пункт: не ведёт на страницу, по клику открывает окно «В разработке».
stub?: boolean;
testid?: string;
}
interface NavGroup {
eyebrow: string;
items: NavItem[];
}
const drawerOpen = defineModel<boolean>('drawerOpen', { default: true });
// Ярус экрана из AppLayout: на телефоне сайдбар скрыт (навигацию берёт нижняя панель-вкладки).
defineProps<{ tier?: 'phone' | 'tablet' | 'desktop' }>();
const route = useRoute();
const auth = useAuthStore();
const dealsCount = useDealsCountStore();
const { openPalette } = useCommandPalette();
const autopodbor = useAutopodborStore();
// «Рекламные возможности» — раздел-витрина будущих каналов. Пункты пока заглушки:
// клик открывает одно общее окно «В разработке» (AdStubDialog). Список каналов и дату
// релиза держим в advertisingChannels.ts — общий источник с мобильным «Ещё».
const adStubOpen = ref(false);
function openAdStub(): void {
adStubOpen.value = true;
}
onMounted(() => {
if (auth.user?.tenant_id) void dealsCount.load(auth.user.tenant_id);
void autopodbor.loadState().catch(() => {});
});
const navGroups = computed<NavGroup[]>(() => [
{
eyebrow: 'Работа',
items: [
{ title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
...(autopodbor.enabled
? [{ title: 'Конкурентное поле', icon: 'mdi-radar', to: '/autopodbor', badge: 'NEW' }]
: []),
// 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: 'Рекламные возможности',
// Канал с route (сейчас — Яндекс Аудитория и Реклама Телеграм) ведёт на реальный
// экран (RouterLink, как обычные пункты); остальные — по-прежнему заглушка openAdStub.
items: AD_CHANNELS.map((c) =>
c.route
? { title: c.title, icon: c.icon, to: c.route, testid: c.testid }
: { title: c.title, icon: c.icon, stub: true, testid: c.testid },
),
},
{
eyebrow: 'Финансы',
// «Рекламный кошелёк» переехал сюда из «Рекламных возможностей» 06.08.2026 по
// решению владельца: это деньги, а не рекламный канал, и место ему рядом
// с «Биллингом». Число баланса в меню по-прежнему не показываем.
items: [
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/billing' },
{ title: 'Рекламный кошелёк', icon: 'mdi-wallet-outline', to: '/advertising/wallet', testid: 'ad-nav-wallet' },
],
},
{
eyebrow: 'Команда',
items: [
{ title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' },
{ title: 'Помощь', icon: 'mdi-lifebuoy', to: '/help' },
],
},
]);
function resolveCount(item: NavItem): number {
return item.count ?? 0;
}
defineExpose({ navGroups });
</script>
<template>
<aside class="ld-sidebar" :data-open="drawerOpen" :data-tier="tier">
<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>
<template v-for="item in group.items" :key="item.to ?? item.testid">
<!-- stub-пункт «Рекламные возможности»: кнопка, открывает окно «В разработке». -->
<button
v-if="item.stub"
type="button"
class="ld-nav-item ld-nav-item--stub"
:data-testid="item.testid"
@click="openAdStub"
>
<span class="ld-nav-item__icon"><v-icon :icon="item.icon" size="20" /></span>
<span class="ld-nav-item__title">{{ item.title }}</span>
</button>
<RouterLink
v-else
:to="item.to!"
class="ld-nav-item"
:class="{ 'ld-nav-item--active': route.path === item.to }"
:data-tour="`nav-${item.to!.replace('/', '')}`"
:data-testid="item.testid"
>
<span class="ld-nav-item__icon"><v-icon :icon="item.icon" size="20" /></span>
<span class="ld-nav-item__title">{{ item.title }}</span>
<span v-if="item.badge" class="ld-nav-item__new">{{ item.badge }}</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>
</template>
</div>
</nav>
<!-- Общее окно-заглушка для всех рекламных пунктов. -->
<AdStubDialog v-model:open="adStubOpen" />
</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;
}
/* Телефон: сайдбара нет — навигацию берёт нижняя панель-вкладки (AppBottomNav). */
.ld-sidebar[data-tier='phone'] {
display: none;
}
/* Иконка пункта — по умолчанию скрыта (десктоп остаётся текстовым, как был). */
.ld-nav-item__icon {
display: none;
align-items: center;
justify-content: center;
flex: 0 0 auto;
}
/* Планшет: узкая колонка-иконки (rail). Прячем подписи/поиск/бренд-текст, показываем иконки. */
.ld-sidebar[data-tier='tablet'] {
width: 64px;
padding: 20px 8px;
}
.ld-sidebar[data-tier='tablet'] .ld-sidebar__brand-name,
.ld-sidebar[data-tier='tablet'] .ld-cmdk-stub,
.ld-sidebar[data-tier='tablet'] .ld-nav-group__eyebrow,
.ld-sidebar[data-tier='tablet'] .ld-nav-item__title,
.ld-sidebar[data-tier='tablet'] .ld-nav-item__badge,
.ld-sidebar[data-tier='tablet'] .ld-nav-item__new {
display: none;
}
.ld-sidebar[data-tier='tablet'] .ld-nav-item__icon {
display: inline-flex;
}
.ld-sidebar[data-tier='tablet'] .ld-nav-item {
justify-content: center;
padding: 10px 0;
gap: 0;
}
.ld-sidebar[data-tier='tablet'] .ld-sidebar__brand {
height: 22px;
}
.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);
}
}
/* stub-пункт — это <button>; убираем родные стили кнопки, наследуем облик .ld-nav-item. */
.ld-nav-item--stub {
width: 100%;
border: none;
background: transparent;
font: inherit;
text-align: left;
cursor: pointer;
}
.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);
}
.ld-nav-item__new {
font-size: 9px;
background: var(--liderra-teal);
color: #fff;
border-radius: 4px;
padding: 1px 5px;
letter-spacing: 0.04em;
margin-left: 6px;
}
</style>