# 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](../specs/2026-05-15-portal-audit-design.md) — эпики 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):

```ts
    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: 'Работа'` добавить пункт после «Дашборд». Заменить:

```ts
            { title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
        ],
    },
```

на:

```ts
            { 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**

```bash
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 маршрута в тест-роутер. Заменить:

```ts
            { path: '/admin/billing', component: { template: '<div>billing</div>' } },
            { path: '/admin/incidents', component: { template: '<div>incidents</div>' } },
```

на:

```ts
            { 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-пунктов ...'` целиком:

```ts
    it('рендерит 7 nav-пунктов (Тенанты, Биллинг, Тарифная сетка, Цены поставщиков, Инциденты, Impersonation, Система)', async () => {
        const { wrapper } = await mountAdminLayout();
        const text = wrapper.text();
        ['Тенанты', 'Биллинг', 'Тарифная сетка', 'Цены поставщиков', 'Инциденты', 'Impersonation', 'Система'].forEach(
            (label) => expect(text).toContain(label),
        );
    });
```

**1c.** В тесте `'breadcrumb fallback на «Админка» ...'` заменить массив-исключений. Заменить:

```ts
        ['Тенанты', 'Биллинг', 'Инциденты', 'Impersonation', 'Система'].forEach((title) => {
            expect(crumbText).not.toContain(title);
        });
```

на:

```ts
        ['Тенанты', 'Биллинг', 'Тарифная сетка', 'Цены поставщиков', 'Инциденты', 'Impersonation', 'Система'].forEach(
            (title) => {
                expect(crumbText).not.toContain(title);
            },
        );
```

**1d.** Добавить новый breadcrumb-тест после теста `'breadcrumb на /admin/billing показывает «Биллинг»'`:

```ts
    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`:

```ts
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' },
];
```

на:

```ts
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**

```bash
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`:

```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`:

```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` добавить импорт. Заменить:

```ts
import DevIndexBadge from '../components/DevIndexBadge.vue';
```

на:

```ts
import DevIndexBadge from '../components/DevIndexBadge.vue';
import ImpersonationBanner from '../components/admin/ImpersonationBanner.vue';
```

**5b.** В `app/resources/js/layouts/AdminLayout.vue` разместить баннер в начале `<v-main>`. Заменить:

```html
        <v-main class="admin-main">
            <RouterView />
        </v-main>
```

на:

```html
        <v-main class="admin-main">
            <ImpersonationBanner />
            <RouterView />
        </v-main>
```

**5c.** В `app/tests/Frontend/AdminLayout.spec.ts` застабить новый компонент (он сам делает API-вызов на mount — в тестах AdminLayout это не нужно; у баннера есть собственный spec). Заменить:

```ts
        stubs: { DevIndexBadge: true },
```

на:

```ts
        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/`):

```bash
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**

```bash
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-таймерами).
