77f61fb1d1
Слияние feat/client-telegram-ads с main 8bdd58e8. Десять швов разобраны вручную:
денежный файл AdWalletService взят из main целиком — проверено поимённо, что все три
починки на месте: свой контекст клиента, оживление брони, таяние заморозки. В расписании
объединены оба набора заданий: телеграмные два и рекламные четыре. В боковом меню и в
мобильном «Ещё» сохранён пункт «Рекламный кошелёк», подписи поправлены — на реальные
экраны ведут ОБА канала. Словарь, пример настроек и журнал схемы объединены.
Сверх самого слияния:
- Журнал схемы: телеграмные записи v8.86-v8.95 перенумерованы в v9.18-v9.27, блок
переставлен наверх, пометки «номер предварительный» сняты и заменены одной врезкой
о перенумерации. Задвоенных номеров не осталось. Врезка шапки теперь называет и
телеграмные таблицы: их DDL, как и рекламный, живёт только в дельта-миграциях.
- Новый сторож денег tests/Feature/ClientTg/TgMoneyUnderRealRoleTest.php: списание и
возврат под боевой ролью crm_app_user. С контекстом клиента деньги двигаются, без
контекста возврат падает громко. Обычные тесты ходят суперюзером и этот класс дыр
увидеть не могут.
- Помощник rejectedCampaign переименован в tgRejectedCampaign: одноимённый помощник
есть у рекламного модуля, помощники Pest глобальные, полный прогон падал фаталом.
Каждая ветка по отдельности этого увидеть не могла.
- Два теста уведомлений считали ВСЮ таблицу целиком вместо строк своего пользователя:
в одиночку зелёные, в полном прогоне красные. Счёт сужен до конкретного пользователя.
- Убраны две проверки отменённой сущности «своё имя отправителя» — сама сущность
дропнута в v9.27 как СМС-фантазия, её адрес отдаёт 405.
Прогоны: телеграм 193/193, реклама 336/336 при 1029 проверках, вместе 532/532,
экраны 1704/1708, сборка фронта чисто, полный Unit+Feature 3923/3960. Шестнадцать
падений полного прогона совпадают построчно с прогоном ветки без телеграма — слияние
не добавило ни одного. Статанализ в свежем каталоге запустить не удалось: он требует
сгенерированного файла-подсказки, которого нет в репозитории, и без него молча падает
на обеих ветках.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
361 lines
12 KiB
Vue
361 lines
12 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, 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.
|
||
// T19: «Рекламный кошелёк» — не канал (в AD_CHANNELS не входит), отдельный
|
||
// пункт-ссылка на /advertising/wallet. Число баланса в меню не показываем.
|
||
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 },
|
||
),
|
||
{ title: 'Рекламный кошелёк', icon: 'mdi-wallet-outline', to: '/advertising/wallet', testid: 'ad-nav-wallet' },
|
||
],
|
||
},
|
||
{
|
||
eyebrow: 'Финансы',
|
||
items: [{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/billing' }],
|
||
},
|
||
{
|
||
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>
|