Files
portal/docs/superpowers/plans/2026-05-16-sprint3a-layout-navigation.md
T
Дмитрий 9f1196100f docs(plan): Sprint 3A layout & navigation implementation plan
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 09:38:01 +03:00

26 KiB
Raw Blame History

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, name reminders) и в 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"). Иконки идут через Lucide IconSet (plugins/vuetify.ts); unmapped mdi-* → fallback HelpCircle (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 config app/vitest.config.ts, setup app/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-outlineBell замаплен в 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-таймерами).