Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
26 KiB
Sprint 3A — Layout & Navigation Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Закрыть аудит-эпики B1, B4, B5 — добавить «Напоминания» в основной сайдбар, 2 недостающих пункта в admin-сайдбар, и глобальный баннер активных impersonation-сессий.
Architecture: Чисто frontend-спринт (Vue 3 + Vuetify 3), 0 schema-delta, 0 backend-кода — все нужные API уже есть (GET /api/admin/impersonation/active) и все SPA-маршруты уже в роутере и routes/web.php. B1/B4 — правка декларативных nav-массивов. B5 — новый self-fetching компонент ImpersonationBanner.vue с polling 30 c (паттерн usePolling), встроенный в AdminLayout.
Tech Stack: Vue 3 <script setup>, Vuetify 3, vue-router 4, Vitest 4 + @vue/test-utils, TypeScript.
Source: portal-wide audit spec §3 Sprint 3 — эпики B1, B4, B5.
Branch: работать на feature-ветке (не main). Не пушить без явного запроса заказчика.
Контекст и факты recon (важно для исполнителя)
- B1 —
/remindersуже зарегистрирован в роутере (router/index.ts:171, namereminders) и вroutes/web.php:230. Эпик — только добавить пункт в nav-массивAppSidebar.vue. - AppSidebar template НЕ рендерит
icon— Quiet Luxury redesign убрал иконки из основного сайдбара. Полеiconвinterface NavItemоставлено для консистентности; значение косметическое, на рендер не влияет. - B4 —
/admin/pricing-tiersи/admin/supplier-pricesуже в роутере (router/index.ts:220,232) и вroutes/web.php:236-237. Эпик — только добавить 2 пункта вnavItemsмассивAdminLayout.vue. - AdminLayout РЕНДЕРИТ иконки (
:prepend-icon="item.icon"). Иконки идут через LucideIconSet(plugins/vuetify.ts); unmappedmdi-*→ fallbackHelpCircle(vuetify.ts:245). Поэтому для B4 берём только icon'ы, присутствующие в 103-entry mapping:mdi-tag-arrow-right(→ Tag) иmdi-currency-rub(→ RussianRuble) — оба замаплены (vuetify.ts:207,170). - B5 — реальность impersonation MVP: saas-admin auth НЕ реализован, реального «входа как клиент» (переключения сессии) нет —
verifyлишь ставитused_at. «Активная сессия» = токен сused_at != null AND session_ended_at == null.GET /api/admin/impersonation/activeвозвращает все такие сессии глобально. Поэтому баннер — честный глобальный индикатор («активны impersonation-сессии: N»), а не «вы вошли как X». Это соответствует формулировке аудита B5 («глобальный индикатор», «полоса в AdminLayout») и TODO-комментарию в самомAdminLayout.vue:11-12. - Тесты проекта:
app/tests/Frontend/**/*.spec.ts, vitest configapp/vitest.config.ts, setupapp/tests/Frontend/setup.ts. Все команды ниже — из директорииapp/. - Паттерн self-fetching компонента с mock'ом admin-API — см.
tests/Frontend/AdminIncidentsViewApi.spec.ts.
File Structure
| Файл | Ответственность | Действие |
|---|---|---|
app/resources/js/components/layout/AppSidebar.vue |
nav-дерево основного портала | Modify (Task 1) |
app/tests/Frontend/AppSidebarRedesign.spec.ts |
тесты AppSidebar | Modify (Task 1) |
app/resources/js/layouts/AdminLayout.vue |
layout админки + nav | Modify (Task 2 + Task 3) |
app/tests/Frontend/AdminLayout.spec.ts |
тесты AdminLayout | Modify (Task 2 + Task 3) |
app/resources/js/components/admin/ImpersonationBanner.vue |
self-fetching баннер активных сессий | Create (Task 3) |
app/tests/Frontend/ImpersonationBanner.spec.ts |
тесты баннера | Create (Task 3) |
Task 1: B1 — пункт «Напоминания» в основном сайдбаре
Files:
-
Modify:
app/resources/js/components/layout/AppSidebar.vue:37 -
Test:
app/tests/Frontend/AppSidebarRedesign.spec.ts -
Step 1: Написать падающий тест
В app/tests/Frontend/AppSidebarRedesign.spec.ts добавить новый тест внутри describe('AppSidebar — redesigned shell', ...) (после теста 'active nav-item has marker pseudo-element class', перед закрывающей }) блока describe):
it('содержит пункт «Напоминания» со ссылкой /reminders в группе «Работа»', async () => {
const { wrapper } = await setup();
const items = wrapper.findAll('a.ld-nav-item');
const reminders = items.find((a) => a.text().includes('Напоминания'));
expect(reminders).toBeDefined();
expect(reminders!.attributes('href')).toBe('/reminders');
});
- Step 2: Запустить тест — убедиться, что падает
Run (из app/): npx vitest run tests/Frontend/AppSidebarRedesign.spec.ts
Expected: FAIL — новый тест падает на expect(reminders).toBeDefined() (пункт отсутствует), остальные 4 теста PASS.
- Step 3: Добавить пункт в nav-массив
В app/resources/js/components/layout/AppSidebar.vue в группе eyebrow: 'Работа' добавить пункт после «Дашборд». Заменить:
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
],
},
на:
{ title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
{ title: 'Напоминания', icon: 'mdi-bell-outline', to: '/reminders' },
],
},
(mdi-bell-outline → Bell замаплен в vuetify.ts:152; на рендер в AppSidebar не влияет — поле косметическое.)
- Step 4: Запустить тест — убедиться, что проходит
Run (из app/): npx vitest run tests/Frontend/AppSidebarRedesign.spec.ts
Expected: PASS — все 5 тестов зелёные.
- Step 5: Commit
git add app/resources/js/components/layout/AppSidebar.vue app/tests/Frontend/AppSidebarRedesign.spec.ts
git commit -m "feat(nav): AppSidebar — пункт «Напоминания» в группе «Работа» (audit B1)"
Task 2: B4 — «Тарифная сетка» + «Цены поставщиков» в admin-сайдбаре
Files:
-
Modify:
app/resources/js/layouts/AdminLayout.vue:27-33 -
Test:
app/tests/Frontend/AdminLayout.spec.ts -
Step 1: Обновить тест-роутер и тесты nav/breadcrumb
В app/tests/Frontend/AdminLayout.spec.ts выполнить 4 правки.
1a. Добавить 2 маршрута в тест-роутер. Заменить:
{ path: '/admin/billing', component: { template: '<div>billing</div>' } },
{ path: '/admin/incidents', component: { template: '<div>incidents</div>' } },
на:
{ path: '/admin/billing', component: { template: '<div>billing</div>' } },
{ path: '/admin/pricing-tiers', component: { template: '<div>pricing-tiers</div>' } },
{ path: '/admin/supplier-prices', component: { template: '<div>supplier-prices</div>' } },
{ path: '/admin/incidents', component: { template: '<div>incidents</div>' } },
1b. Заменить тест 'рендерит 5 nav-пунктов ...' целиком:
it('рендерит 7 nav-пунктов (Тенанты, Биллинг, Тарифная сетка, Цены поставщиков, Инциденты, Impersonation, Система)', async () => {
const { wrapper } = await mountAdminLayout();
const text = wrapper.text();
['Тенанты', 'Биллинг', 'Тарифная сетка', 'Цены поставщиков', 'Инциденты', 'Impersonation', 'Система'].forEach(
(label) => expect(text).toContain(label),
);
});
1c. В тесте 'breadcrumb fallback на «Админка» ...' заменить массив-исключений. Заменить:
['Тенанты', 'Биллинг', 'Инциденты', 'Impersonation', 'Система'].forEach((title) => {
expect(crumbText).not.toContain(title);
});
на:
['Тенанты', 'Биллинг', 'Тарифная сетка', 'Цены поставщиков', 'Инциденты', 'Impersonation', 'Система'].forEach(
(title) => {
expect(crumbText).not.toContain(title);
},
);
1d. Добавить новый breadcrumb-тест после теста 'breadcrumb на /admin/billing показывает «Биллинг»':
it('breadcrumb на /admin/pricing-tiers показывает «Тарифная сетка»', async () => {
const { wrapper } = await mountAdminLayout('/admin/pricing-tiers');
expect(wrapper.find('.crumb').text()).toContain('Тарифная сетка');
});
- Step 2: Запустить тесты — убедиться, что падают
Run (из app/): npx vitest run tests/Frontend/AdminLayout.spec.ts
Expected: FAIL — тест 'рендерит 7 nav-пунктов ...' падает (text не содержит «Тарифная сетка»/«Цены поставщиков»), новый breadcrumb-тест падает (currentPageTitle fallback → 'Админка').
- Step 3: Добавить 2 пункта в
navItems
В app/resources/js/layouts/AdminLayout.vue заменить массив navItems:
const navItems: NavItem[] = [
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants', count: 142 },
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents', count: 3 },
{ title: 'Impersonation', icon: 'mdi-account-switch', to: '/admin/impersonation' },
{ title: 'Система', icon: 'mdi-cog-outline', to: '/admin/system' },
];
на:
const navItems: NavItem[] = [
{ title: 'Тенанты', icon: 'mdi-account-group-outline', to: '/admin/tenants', count: 142 },
{ title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/admin/billing' },
{ title: 'Тарифная сетка', icon: 'mdi-tag-arrow-right', to: '/admin/pricing-tiers' },
{ title: 'Цены поставщиков', icon: 'mdi-currency-rub', to: '/admin/supplier-prices' },
{ title: 'Инциденты', icon: 'mdi-alert-outline', to: '/admin/incidents', count: 3 },
{ title: 'Impersonation', icon: 'mdi-account-switch', to: '/admin/impersonation' },
{ title: 'Система', icon: 'mdi-cog-outline', to: '/admin/system' },
];
(Порядок: новые пункты сразу после «Биллинг» — billing-смежные. currentPageTitle использует route.path.startsWith(i.to); префиксных коллизий между admin-маршрутами нет. Иконки mdi-tag-arrow-right/mdi-currency-rub замаплены в vuetify.ts.)
- Step 4: Запустить тесты — убедиться, что проходят
Run (из app/): npx vitest run tests/Frontend/AdminLayout.spec.ts
Expected: PASS — все тесты AdminLayout зелёные (включая нетронутый 'показывает count-badge ... toHaveLength(2)' — новые пункты без count).
- Step 5: Commit
git add app/resources/js/layouts/AdminLayout.vue app/tests/Frontend/AdminLayout.spec.ts
git commit -m "feat(admin): AdminLayout nav — Тарифная сетка + Цены поставщиков (audit B4)"
Task 3: B5 — глобальный баннер активных impersonation-сессий
Files:
-
Create:
app/resources/js/components/admin/ImpersonationBanner.vue -
Create:
app/tests/Frontend/ImpersonationBanner.spec.ts -
Modify:
app/resources/js/layouts/AdminLayout.vue(импорт + размещение в<v-main>) -
Modify:
app/tests/Frontend/AdminLayout.spec.ts(stub нового компонента) -
Step 1: Написать падающий тест компонента
Создать app/tests/Frontend/ImpersonationBanner.spec.ts:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import ImpersonationBanner from '../../resources/js/components/admin/ImpersonationBanner.vue';
import type { ImpersonationActiveSession } from '../../resources/js/api/admin';
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
return { ...orig, impersonationActive: vi.fn() };
});
const adminApi = await import('../../resources/js/api/admin');
function makeSession(overrides: Partial<ImpersonationActiveSession> = {}): ImpersonationActiveSession {
return {
token_id: 1,
tenant_id: 10,
tenant_name: 'ООО Ромашка',
requested_by: 7,
reason: 'Диагностика проблемы с балансом по обращению клиента',
sent_to_email: 'client@romashka.ru',
used_at: '2026-05-16T08:00:00Z',
expires_at: '2026-05-16T08:15:00Z',
...overrides,
};
}
const mountBanner = () =>
mount(ImpersonationBanner, {
global: {
plugins: [createVuetify()],
stubs: {
RouterLink: {
props: ['to'],
template: '<a :class="$attrs.class" :href="to"><slot /></a>',
inheritAttrs: false,
},
},
},
});
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.useRealTimers());
describe('ImpersonationBanner', () => {
it('вызывает impersonationActive на mount', async () => {
vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([]);
const wrapper = mountBanner();
await flushPromises();
expect(adminApi.impersonationActive).toHaveBeenCalledTimes(1);
wrapper.unmount();
});
it('0 активных сессий — баннер не рендерится', async () => {
vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([]);
const wrapper = mountBanner();
await flushPromises();
expect(wrapper.find('[data-testid="impersonation-banner"]').exists()).toBe(false);
wrapper.unmount();
});
it('1 активная сессия — баннер с именем тенанта + ссылка на /admin/impersonation', async () => {
vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([makeSession({ tenant_name: 'ООО Ромашка' })]);
const wrapper = mountBanner();
await flushPromises();
const banner = wrapper.find('[data-testid="impersonation-banner"]');
expect(banner.exists()).toBe(true);
expect(banner.text()).toContain('Активна impersonation-сессия');
expect(banner.text()).toContain('ООО Ромашка');
expect(wrapper.find('[data-testid="impersonation-banner-link"]').attributes('href')).toBe(
'/admin/impersonation',
);
wrapper.unmount();
});
it('несколько активных сессий — баннер показывает счётчик', async () => {
vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([
makeSession({ token_id: 1 }),
makeSession({ token_id: 2 }),
makeSession({ token_id: 3 }),
]);
const wrapper = mountBanner();
await flushPromises();
expect(wrapper.find('[data-testid="impersonation-banner"]').text()).toContain(
'Активны impersonation-сессии: 3',
);
wrapper.unmount();
});
it('tenant_name=null — fallback на «тенант #id»', async () => {
vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([
makeSession({ tenant_name: null, tenant_id: 42 }),
]);
const wrapper = mountBanner();
await flushPromises();
expect(wrapper.find('[data-testid="impersonation-banner"]').text()).toContain('тенант #42');
wrapper.unmount();
});
it('ошибка impersonationActive — баннер не падает и остаётся скрыт', async () => {
vi.mocked(adminApi.impersonationActive).mockRejectedValueOnce(new Error('500'));
const wrapper = mountBanner();
await flushPromises();
expect(wrapper.find('[data-testid="impersonation-banner"]').exists()).toBe(false);
wrapper.unmount();
});
it('polling — impersonationActive вызывается повторно через 30 с', async () => {
vi.useFakeTimers();
vi.mocked(adminApi.impersonationActive).mockResolvedValue([]);
const wrapper = mountBanner();
await vi.advanceTimersByTimeAsync(0);
expect(adminApi.impersonationActive).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(30_000);
expect(adminApi.impersonationActive).toHaveBeenCalledTimes(2);
wrapper.unmount();
});
});
- Step 2: Запустить тест — убедиться, что падает
Run (из app/): npx vitest run tests/Frontend/ImpersonationBanner.spec.ts
Expected: FAIL — import не резолвится (ImpersonationBanner.vue ещё не создан).
- Step 3: Создать компонент
Создать app/resources/js/components/admin/ImpersonationBanner.vue:
<script setup lang="ts">
/**
* Глобальный индикатор активных impersonation-сессий (audit B5 / Ю-1).
*
* Размещён в AdminLayout над <RouterView> — виден на всех /admin/* страницах.
* На MVP saas-admin auth нет и реального переключения сессии нет, поэтому
* показываем счётчик ВСЕХ активных сессий (impersonationActive() =
* used_at != null AND session_ended_at == null). Polling 30 c — сессия может
* стартовать/завершиться, пока админ остаётся в админке (AdminLayout
* persistent, перемонтируется только <RouterView>).
*
* Если активных сессий 0 — компонент не рендерит ничего.
*/
import { computed, onMounted, ref } from 'vue';
import { impersonationActive, type ImpersonationActiveSession } from '../../api/admin';
import { usePolling } from '../../composables/usePolling';
const sessions = ref<ImpersonationActiveSession[]>([]);
async function load(): Promise<void> {
try {
sessions.value = await impersonationActive();
} catch {
// Баннер не критичен — ошибку детально покажет AdminImpersonationView.
// Сохраняем прежнее значение sessions, не падаем.
}
}
const count = computed(() => sessions.value.length);
const label = computed(() => {
if (count.value === 1) {
const s = sessions.value[0];
return `Активна impersonation-сессия: ${s.tenant_name ?? `тенант #${s.tenant_id}`}`;
}
return `Активны impersonation-сессии: ${count.value}`;
});
onMounted(load);
usePolling(load, { intervalMs: 30_000 });
defineExpose({ sessions, load });
</script>
<template>
<div v-if="count > 0" class="impersonation-banner" role="status" data-testid="impersonation-banner">
<v-icon size="16" class="impersonation-banner__icon">mdi-account-switch</v-icon>
<span class="impersonation-banner__label">{{ label }}</span>
<RouterLink
to="/admin/impersonation"
class="impersonation-banner__link"
data-testid="impersonation-banner-link"
>
Открыть
</RouterLink>
</div>
</template>
<style scoped>
.impersonation-banner {
display: flex;
align-items: center;
gap: 8px;
background: #fff4e0;
border-bottom: 1px solid #f0d8a8;
color: #8a5a00;
font-size: 13px;
padding: 8px 24px;
}
.impersonation-banner__icon {
color: #b87400;
}
.impersonation-banner__label {
flex: 1;
}
.impersonation-banner__link {
color: #0f6e56;
font-weight: 600;
text-decoration: none;
}
.impersonation-banner__link:hover {
text-decoration: underline;
}
</style>
- Step 4: Запустить тест — убедиться, что проходит
Run (из app/): npx vitest run tests/Frontend/ImpersonationBanner.spec.ts
Expected: PASS — все 7 тестов зелёные.
- Step 5: Встроить баннер в AdminLayout + застабить в AdminLayout.spec
5a. В app/resources/js/layouts/AdminLayout.vue добавить импорт. Заменить:
import DevIndexBadge from '../components/DevIndexBadge.vue';
на:
import DevIndexBadge from '../components/DevIndexBadge.vue';
import ImpersonationBanner from '../components/admin/ImpersonationBanner.vue';
5b. В app/resources/js/layouts/AdminLayout.vue разместить баннер в начале <v-main>. Заменить:
<v-main class="admin-main">
<RouterView />
</v-main>
на:
<v-main class="admin-main">
<ImpersonationBanner />
<RouterView />
</v-main>
5c. В app/tests/Frontend/AdminLayout.spec.ts застабить новый компонент (он сам делает API-вызов на mount — в тестах AdminLayout это не нужно; у баннера есть собственный spec). Заменить:
stubs: { DevIndexBadge: true },
на:
stubs: { DevIndexBadge: true, ImpersonationBanner: true },
- Step 6: Запустить тесты AdminLayout + ImpersonationBanner — убедиться, что проходят
Run (из app/): npx vitest run tests/Frontend/AdminLayout.spec.ts tests/Frontend/ImpersonationBanner.spec.ts
Expected: PASS — обе спеки зелёные.
- Step 7: Полный sweep frontend-регрессии
Run (из app/):
npm run test:vue
npm run type-check
npm run lint:vue
Expected: Vitest — все файлы зелёные (+1 файл ImpersonationBanner.spec.ts, +7 specs; AppSidebar +1 spec; AdminLayout +1 spec); type-check — 0 ошибок; lint:vue — 0 ошибок. Выписать фактические числа (passed/failed) в отчёт.
- Step 8: Commit
git add app/resources/js/components/admin/ImpersonationBanner.vue app/tests/Frontend/ImpersonationBanner.spec.ts app/resources/js/layouts/AdminLayout.vue app/tests/Frontend/AdminLayout.spec.ts
git commit -m "feat(admin): ImpersonationBanner — глобальный индикатор активных сессий (audit B5)"
Definition of Done
- B1: пункт «Напоминания» виден в группе «Работа» основного сайдбара, ведёт на
/reminders. - B4: «Тарифная сетка» + «Цены поставщиков» видны в admin-сайдбаре между «Биллинг» и «Инциденты», иконки рендерятся (не
HelpCircle-fallback), breadcrumb на этих страницах корректен. - B5: при наличии активных impersonation-сессий в AdminLayout сверху виден баннер со счётчиком и ссылкой на
/admin/impersonation; при 0 сессий баннер скрыт; данные обновляются раз в 30 c. npm run test:vue— 0 failed;npm run type-check— 0;npm run lint:vue— 0.- 3 атомарных коммита (B1 / B4 / B5), каждый — после прохождения своих тестов.
- Без push (до явного «пуш» от заказчика).
Self-Review (выполнено при написании плана)
Spec coverage: B1 → Task 1; B4 → Task 2; B5 → Task 3. Все 3 эпика Sprint 3A «Layout & Navigation» покрыты.
Placeholder scan: плейсхолдеров нет — весь код приведён полностью (тесты, компонент, точечные Edit'ы old→new).
Type consistency: ImpersonationActiveSession используется из api/admin.ts без изменения интерфейса; usePolling(loader, { intervalMs }) — сигнатура соответствует composables/usePolling.ts; interface NavItem в AppSidebar/AdminLayout не меняется (новые элементы используют существующие поля title/icon/to/count).
Recon-риски проверены: маршруты /reminders, /admin/pricing-tiers, /admin/supplier-prices подтверждены в роутере и routes/web.php (есть Route::fallback); иконки B4 подтверждены в Lucide-mapping; polling-тест использует advanceTimersByTimeAsync (а не flushPromises под fake-таймерами).