Files
portal/app/resources/js/components/layout/AppSidebar.vue
T
Дмитрий 0c9642f3d0 feat(телеграм-модуль): клиентский экран + ручной запуск в песочнице — API, telegram.ts, экран, сквозной сценарий
Сессия 3 плана docs/superpowers/plans/2026-07-27-client-telegram-ads-module.md.
Клиент сам собирает объявление + аудиторию и жмёт «Запустить»; робот кабинета МТС
(в тестах замокан) доводит до черновика. Всё в песочнице: деньги/живой запуск выключены.

- Клиентский API (Api\ClientTg\CampaignController + маршруты /api/telegram/*):
  создание (store) и запуск (launch) РАЗДЕЛЕНЫ. store создаёт ЧЕРНОВИК со сметой и
  числом кандидатов (деньги/робот не трогаются); launch draft→queued, в бою бронирует
  лимит на объявление (budget_cap_rub) и ставит RunTelegramCampaignJob. Нехватка денег
  → 409 (кампания остаётся черновиком); повторный запуск не-черновика → 422; изоляция
  тенантов (чужой show → 404). Валидация текст/ссылка/аудитория/бюджет; deals без срока → 422.
- Фронт-API telegram.ts (зеркало client-sms.ts, уже): fetch/create/get/launch, cookie-сессия.
- Экран AdvertisingTelegramView.vue (/advertising/telegram): форма (объявление, ссылка,
  3 способа аудитории, лимит), кнопка «Запустить» (create→launch), баннер песочницы,
  список кампаний с человеческим ярлыком статуса, сообщение при нехватке денег.
- Канал «Реклама Телеграм» АКТИВИРОВАН: advertisingChannels.ts route, провод в AppSidebar
  и AppMoreDrawer (route → реальный экран, остальные каналы — прежняя заглушка), маршрут
  в router. Тесты каналов обновлены (телеграм больше не заглушка).
- Ручной сквозной сценарий (песочница): создать → запустить → джоб (робот замокан, draft)
  → draft_ready + matched виден в API.

Отличие от СМС-близнеца: отдельного эндпоинта «предпросчёт до создания» нет — смету и
кандидатов клиент видит в самом черновике (создание черновик денег не тратит).

Проверки: 60/60 Pest ClientTg зелёные (+10 новых), 1512 Vitest зелёные (0 регрессий),
phpstan 0 по своему коду (level 5), pint чисто, deptrac 0 нарушений. Робот в тестах замокан
(кабинет МТС не тронут). Синтетические номера 7999… — реальных нет.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 17:39:43 +03:00

354 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: 'Финансы',
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>