# Sprint 5B — Layout/Views Polish 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:** Закрыть 5 P2-эпиков подраздела Layout/Views портал-аудита (B2, B3, C3, C6, C7): живой счётчик сделок в сайдбаре, рабочая ⌘K command-palette, экспорт всего списка из шапки DealsView, degradation-alert в NewDealDialog, верификация degradation-alert в DealDetailDrawer.

**Architecture:** Backend — один новый query-параметр `count_only` на существующем `GET /api/deals`. Frontend — новый Pinia-store счётчика, новый компонент `CommandPalette.vue` + module-singleton composable, точечные правки 4 view/layout-компонентов. Без изменений схемы БД, без новых роутов, без новых зависимостей.

**Tech Stack:** PHP 8.3 / Laravel 13 / Pest 4 (backend); Vue 3.5 + Vuetify 3.12 + TypeScript / Pinia 3 / vue-router 4 / Vitest 4 (frontend).

**Решения заказчика (2026-05-17):** B3 → минимальная command-palette; C3 → подвязать кнопку «Экспорт» на экспорт всего отфильтрованного списка.

---

## Контекст: текущее состояние (file:line)

| Эпик | Файл | Состояние |
|---|---|---|
| B2 | `app/resources/js/components/layout/AppSidebar.vue:35` | `count: 247` — хардкод |
| B2 | `app/app/Http/Controllers/Api/DealController.php:54-190` | `index` возвращает `total`, но всегда тянет строки |
| B3 | `app/resources/js/components/layout/AppSidebar.vue:66-69` | `.ld-cmdk-stub` — `role=button` без handler |
| B3 | `app/resources/js/components/layout/AppTopbar.vue:102-107` | `<v-btn ... disabled>Поиск` — disabled-заглушка |
| C3 | `app/resources/js/views/DealsView.vue:516` | `<v-btn>Экспорт</v-btn>` — без `@click` |
| C6 | `app/resources/js/components/deals/NewDealDialog.vue:44-46` | `loadLookups` catch — молчаливый fallback на mock |
| C7 | `app/resources/js/components/deals/DealDetailTimeline.vue:25-35` | degradation-alert **уже есть** (`events-fetch-error-alert`) |

C7 — реализация уже выполнена (вероятно, в Sprint 3 Phase C при extraction `DealDetailTimeline`). Задача T5 — верификация + характеризационный тест, **без правки production-кода**.

---

## File Structure

**Создаются:**

- `app/resources/js/stores/dealsCount.ts` — Pinia-store счётчика сделок (B2).
- `app/resources/js/composables/useCommandPalette.ts` — module-singleton состояние палитры (B3).
- `app/resources/js/components/layout/CommandPalette.vue` — компонент палитры (B3).
- `app/tests/Frontend/CommandPalette.spec.ts` — тесты палитры (B3).

**Модифицируются:**

- `app/app/Http/Controllers/Api/DealController.php` — `count_only` параметр (B2).
- `app/tests/Feature/DealIndexTest.php` — +3 теста `count_only` (B2).
- `app/resources/js/api/deals.ts` — `fetchDealsCount()` (B2).
- `app/resources/js/components/layout/AppSidebar.vue` — счётчик из store (B2) + ⌘K handler (B3).
- `app/resources/js/components/layout/AppTopbar.vue` — кнопка «Поиск» открывает палитру (B3).
- `app/resources/js/layouts/AppLayout.vue` — монтирует `<CommandPalette>` (B3).
- `app/resources/js/views/DealsView.vue` — экспорт всего списка (C3).
- `app/resources/js/components/deals/NewDealDialog.vue` — degradation-alert (C6).
- `app/tests/Frontend/AppSidebarRedesign.spec.ts` — +тесты счётчика и ⌘K (B2/B3).
- `app/tests/Frontend/DealsView.spec.ts` — +тест экспорта всего списка (C3).
- `app/tests/Frontend/NewDealDialog.spec.ts` — +тест degradation-alert (C6).
- `app/tests/Frontend/DealDetailDrawerApi.spec.ts` — +характеризационный тест (C7, если не покрыто).

---

## Команды (для всех задач)

```bash
# Backend (из app/)
cd app
./vendor/bin/pest tests/Feature/DealIndexTest.php   # один файл
composer pint
composer stan

# Frontend (из app/)
npx vitest run tests/Frontend/CommandPalette.spec.ts   # один файл
npm run type-check
npm run lint:vue
```

Bash-вывод показывать в ответе всегда (economy-режим).

---

## Task 1 — B2: живой счётчик «Сделки» в сайдбаре

**Files:**

- Modify: `app/app/Http/Controllers/Api/DealController.php`
- Test: `app/tests/Feature/DealIndexTest.php` (append)
- Create: `app/resources/js/stores/dealsCount.ts`
- Modify: `app/resources/js/api/deals.ts`
- Modify: `app/resources/js/components/layout/AppSidebar.vue`
- Test: `app/tests/Frontend/AppSidebarRedesign.spec.ts` (append)

### Backend: `count_only` параметр

- [ ] **Step 1: Написать падающие тесты**

Дописать в конец `app/tests/Feature/DealIndexTest.php` (перед последним `test(...)` или в самый конец файла — после строки 291):

```php
test('GET /api/deals?count_only=1 возвращает только total без массива deals', function () {
    Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
    Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);

    $r = $this->getJson('/api/deals?count_only=1');

    $r->assertStatus(200);
    expect($r->json('total'))->toBe(2);
    expect($r->json('deals'))->toBeNull();
});

test('GET /api/deals?count_only=1 учитывает фильтры (status_in)', function () {
    Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
    Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);
    Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'paid']);

    expect($this->getJson('/api/deals?count_only=1&status_in[]=new')->json('total'))->toBe(2);
});

test('GET /api/deals?count_only=1 изолирует чужой tenant (RLS)', function () {
    Deal::factory()->for($this->tenant)->for($this->project)->create(['status' => 'new']);

    DB::statement('SET app.current_tenant_id = '.$this->otherTenant->id);
    $foreignProject = Project::factory()->for($this->otherTenant)->create();
    Deal::factory()->for($this->otherTenant)->for($foreignProject)->create(['status' => 'new']);

    expect($this->getJson('/api/deals?count_only=1')->json('total'))->toBe(1);
});
```

- [ ] **Step 2: Запустить — убедиться, что падают**

Run: `cd app && ./vendor/bin/pest tests/Feature/DealIndexTest.php`
Expected: 3 новых теста FAIL (`count_only` пока игнорируется → `deals` присутствует в ответе, `toBeNull()` падает).

- [ ] **Step 3: Реализовать `count_only` в `DealController@index`**

В `app/app/Http/Controllers/Api/DealController.php`, после строки 64 (`$onlyDeleted = $request->boolean('only_deleted');`) добавить:

```php
        $countOnly = $request->boolean('count_only');
```

Внутри transaction-замыкания, **сразу после блока фильтров** (после строки 116 `}` закрывающей `if ($search !== '')`, перед `if ($cursor !== null) {`) добавить:

```php
            // Audit B2: count_only — отдаём только COUNT(*), пропуская SELECT строк
            // и cursor/offset-логику (лёгкий запрос для бейджа в сайдбаре).
            if ($countOnly) {
                return [collect(), $query->count(), null];
            }

```

После `DB::transaction(...)` (после строки 160 `});`), **перед** сборкой `$payload` (перед строкой 162 `$payload = [`) добавить:

```php
        if ($countOnly) {
            return response()->json(['total' => $total]);
        }

```

- [ ] **Step 4: Запустить тесты — убедиться, что зелёные**

Run: `cd app && ./vendor/bin/pest tests/Feature/DealIndexTest.php`
Expected: все тесты PASS (новые 3 + существующие 17 — без регрессий).

- [ ] **Step 5: Pint + Larastan**

Run: `cd app && composer pint && composer stan`
Expected: Pint — no changes / passed; Larastan — 0 errors (тесты дописаны в существующий файл, новый Larastan-baseline не требуется).

- [ ] **Step 6: Commit**

```bash
git add app/app/Http/Controllers/Api/DealController.php app/tests/Feature/DealIndexTest.php
git commit -m "feat(api): B2 — count_only параметр на GET /api/deals"
```

### Frontend: store + сайдбар

- [ ] **Step 7: Добавить `fetchDealsCount` в API-слой**

В `app/resources/js/api/deals.ts`, после `listProjects` (после строки 235), добавить:

```ts
/**
 * Лёгкий count-only запрос для бейджа «Сделки» в AppSidebar (audit B2).
 * Backend пропускает SELECT строк — отдаёт только COUNT(*).
 */
export async function fetchDealsCount(tenantId: number): Promise<number> {
    const { data } = await apiClient.get<{ total: number }>('/api/deals', {
        params: { tenant_id: tenantId, count_only: 1 },
    });
    return data.total;
}
```

- [ ] **Step 8: Создать store `dealsCount.ts`**

Создать `app/resources/js/stores/dealsCount.ts`:

```ts
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { fetchDealsCount } from '../api/deals';

/**
 * Счётчик сделок tenant'а для бейджа «Сделки» в AppSidebar (audit B2).
 * count=null до загрузки или на fail → бейдж скрыт (resolveCount → 0).
 */
export const useDealsCountStore = defineStore('dealsCount', () => {
    const count = ref<number | null>(null);

    async function load(tenantId: number): Promise<void> {
        try {
            count.value = await fetchDealsCount(tenantId);
        } catch {
            count.value = null;
        }
    }

    return { count, load };
});
```

- [ ] **Step 9: Написать падающий тест счётчика**

Дописать в `app/tests/Frontend/AppSidebarRedesign.spec.ts` (следовать существующим импортам/setup-харнессу файла — Pinia, router-stub). Тест:

```ts
test('B2: бейдж «Сделки» рендерит count из dealsCount-store', async () => {
    const wrapper = mountSidebar(); // использовать существующий mount-хелпер файла
    const store = useDealsCountStore();
    store.count = 42;
    await wrapper.vm.$nextTick();

    const badge = wrapper.find('[data-testid="nav-count-deals"]');
    expect(badge.exists()).toBe(true);
    expect(badge.text()).toBe('42');
});

test('B2: бейдж «Сделки» скрыт пока count=null', async () => {
    const wrapper = mountSidebar();
    const store = useDealsCountStore();
    store.count = null;
    await wrapper.vm.$nextTick();

    expect(wrapper.find('[data-testid="nav-count-deals"]').exists()).toBe(false);
});
```

Добавить импорт `import { useDealsCountStore } from '../../resources/js/stores/dealsCount';` (путь — выровнять по существующим импортам файла). Если в файле нет `mountSidebar`-хелпера — использовать тот же `mount(AppSidebar, {...})`-паттерн, что в существующих тестах файла.

- [ ] **Step 10: Запустить — убедиться, что падает**

Run: `cd app && npx vitest run tests/Frontend/AppSidebarRedesign.spec.ts`
Expected: 2 новых теста FAIL (бейдж = 247 хардкод, `nav-count-deals` testid отсутствует).

- [ ] **Step 11: Подключить store в `AppSidebar.vue`**

В `app/resources/js/components/layout/AppSidebar.vue`:

Заменить импорты (строки 10-12):

```ts
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import Kbd from '../ui/Kbd.vue';
import { useAuthStore } from '../../stores/auth';
import { useDealsCountStore } from '../../stores/dealsCount';
```

После `const route = useRoute();` (строка 28) добавить:

```ts
const auth = useAuthStore();
const dealsCount = useDealsCountStore();

onMounted(() => {
    if (auth.user?.tenant_id) void dealsCount.load(auth.user.tenant_id);
});
```

Заменить строку 35 (элемент «Сделки» в `navGroups`):

```ts
            { title: 'Сделки', icon: 'mdi-format-list-bulleted', to: '/deals', count: dealsCount.count ?? undefined, countKey: 'deals' },
```

`navGroups` — `computed`, обращение к `dealsCount.count` делает бейдж реактивным. `resolveCount` (строки 53-55) уже отдаёт `item.count ?? 0` → при `count=null` бейдж скрыт (`v-if resolveCount > 0`).

- [ ] **Step 12: Запустить тесты — убедиться, что зелёные**

Run: `cd app && npx vitest run tests/Frontend/AppSidebarRedesign.spec.ts`
Expected: все тесты PASS.

- [ ] **Step 13: type-check + lint**

Run: `cd app && npm run type-check && npm run lint:vue`
Expected: 0 errors.

- [ ] **Step 14: Commit**

```bash
git add app/resources/js/api/deals.ts app/resources/js/stores/dealsCount.ts app/resources/js/components/layout/AppSidebar.vue app/tests/Frontend/AppSidebarRedesign.spec.ts
git commit -m "feat(ui): B2 — счётчик «Сделки» в сайдбаре из API вместо хардкода"
```

---

## Task 2 — B3: минимальная ⌘K command-palette

**Files:**

- Create: `app/resources/js/composables/useCommandPalette.ts`
- Create: `app/resources/js/components/layout/CommandPalette.vue`
- Create: `app/tests/Frontend/CommandPalette.spec.ts`
- Modify: `app/resources/js/components/layout/AppSidebar.vue`
- Modify: `app/resources/js/components/layout/AppTopbar.vue`
- Modify: `app/resources/js/layouts/AppLayout.vue`
- Test: `app/tests/Frontend/AppSidebarRedesign.spec.ts` (append)

> NB: AppSidebar.vue здесь правится поверх изменений Task 1 — реализатор увидит уже подключённый store.

- [ ] **Step 1: Создать composable `useCommandPalette.ts`**

Создать `app/resources/js/composables/useCommandPalette.ts`:

```ts
import { ref } from 'vue';

/**
 * Глобальное состояние command-palette (⌘K, audit B3). Module-level singleton
 * ref — AppSidebar/AppTopbar открывают палитру без prop-drilling, CommandPalette
 * (смонтирована один раз в AppLayout) использует тот же ref как v-model.
 */
const open = ref(false);

export function useCommandPalette() {
    return {
        open,
        openPalette: (): void => {
            open.value = true;
        },
        closePalette: (): void => {
            open.value = false;
        },
    };
}
```

- [ ] **Step 2: Написать падающий тест палитры**

Создать `app/tests/Frontend/CommandPalette.spec.ts`. Харнесс (vuetify-плагин, mount-опции) — скопировать из `app/tests/Frontend/AppSidebarRedesign.spec.ts` (sibling-спека с router). `useRouter` замокать для перехвата `push`.

```ts
import { describe, expect, test, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import * as components from 'vuetify/components';
import * as directives from 'vuetify/directives';
import CommandPalette from '../../resources/js/components/layout/CommandPalette.vue';
import { useCommandPalette } from '../../resources/js/composables/useCommandPalette';

const pushMock = vi.fn();
vi.mock('vue-router', () => ({
    useRouter: () => ({ push: pushMock }),
}));

const vuetify = createVuetify({ components, directives });

function mountPalette() {
    return mount(CommandPalette, {
        global: {
            plugins: [vuetify],
            stubs: { VDialog: true }, // teleport → inline render
        },
    });
}

describe('CommandPalette (B3)', () => {
    beforeEach(() => {
        pushMock.mockClear();
        useCommandPalette().closePalette(); // сброс singleton между тестами
    });

    test('по умолчанию показывает все 8 разделов', () => {
        const wrapper = mountPalette();
        expect(wrapper.vm.filteredItems).toHaveLength(8);
    });

    test('фильтрует по подстроке (case-insensitive)', () => {
        const wrapper = mountPalette();
        wrapper.vm.query = 'КАНБ';
        expect(wrapper.vm.filteredItems).toHaveLength(1);
        expect(wrapper.vm.filteredItems[0].to).toBe('/kanban');
    });

    test('пустой результат при отсутствии совпадений', () => {
        const wrapper = mountPalette();
        wrapper.vm.query = 'zzzнеттакого';
        expect(wrapper.vm.filteredItems).toHaveLength(0);
    });

    test('selectItem навигирует и закрывает палитру', () => {
        const { open } = useCommandPalette();
        open.value = true;
        const wrapper = mountPalette();
        wrapper.vm.selectItem({ title: 'Сделки', icon: 'x', to: '/deals' });
        expect(pushMock).toHaveBeenCalledWith('/deals');
        expect(open.value).toBe(false);
    });

    test('onSubmit навигирует на первый отфильтрованный результат', () => {
        const wrapper = mountPalette();
        wrapper.vm.query = 'отч';
        wrapper.vm.onSubmit();
        expect(pushMock).toHaveBeenCalledWith('/reports');
    });
});
```

- [ ] **Step 3: Запустить — убедиться, что падает**

Run: `cd app && npx vitest run tests/Frontend/CommandPalette.spec.ts`
Expected: FAIL — `CommandPalette.vue` не существует (import error).

- [ ] **Step 4: Создать компонент `CommandPalette.vue`**

Создать `app/resources/js/components/layout/CommandPalette.vue`:

```vue
<script setup lang="ts">
/**
 * Минимальная command-palette (audit B3). Открывается по ⌘K / Ctrl+K, кликом
 * на плашку в AppSidebar или кнопку «Поиск» в AppTopbar. Список — навигация
 * по 8 разделам портала; фильтр по подстроке; Enter → первый результат.
 * Монтируется один раз в AppLayout.
 */
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useCommandPalette } from '../../composables/useCommandPalette';

interface PaletteItem {
    title: string;
    icon: string;
    to: string;
}

const NAV_ITEMS: PaletteItem[] = [
    { title: 'Проекты', icon: 'mdi-folder-multiple-outline', to: '/projects' },
    { title: 'Сделки', icon: 'mdi-format-list-bulleted', to: '/deals' },
    { title: 'Канбан', icon: 'mdi-view-column-outline', to: '/kanban' },
    { title: 'Дашборд', icon: 'mdi-view-dashboard-outline', to: '/dashboard' },
    { title: 'Импорт данных', icon: 'mdi-database-import-outline', to: '/import' },
    { title: 'Биллинг', icon: 'mdi-credit-card-outline', to: '/billing' },
    { title: 'Отчёты', icon: 'mdi-chart-box-outline', to: '/reports' },
    { title: 'Настройки', icon: 'mdi-cog-outline', to: '/settings' },
];

const { open, closePalette } = useCommandPalette();
const router = useRouter();

const query = ref('');

const filteredItems = computed<PaletteItem[]>(() => {
    const q = query.value.trim().toLowerCase();
    if (q === '') return NAV_ITEMS;
    return NAV_ITEMS.filter((i) => i.title.toLowerCase().includes(q));
});

// Сброс query при каждом открытии.
watch(open, (isOpen) => {
    if (isOpen) query.value = '';
});

function selectItem(item: PaletteItem): void {
    closePalette();
    void router.push(item.to);
}

function onSubmit(): void {
    const first = filteredItems.value[0];
    if (first) selectItem(first);
}

function onGlobalKeydown(e: KeyboardEvent): void {
    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
        e.preventDefault();
        open.value = !open.value;
    }
}

onMounted(() => window.addEventListener('keydown', onGlobalKeydown));
onUnmounted(() => window.removeEventListener('keydown', onGlobalKeydown));

defineExpose({ query, filteredItems, selectItem, onSubmit });
</script>

<template>
    <v-dialog v-model="open" :max-width="520" data-testid="command-palette">
        <v-card class="cmdk-card">
            <v-text-field
                v-model="query"
                autofocus
                placeholder="Поиск разделов…"
                variant="plain"
                density="comfortable"
                hide-details
                prepend-inner-icon="mdi-magnify"
                class="cmdk-input px-3 pt-2"
                data-testid="command-palette-input"
                @keydown.enter="onSubmit"
            />
            <v-divider />
            <v-list density="compact" class="cmdk-list" data-testid="command-palette-list">
                <v-list-item
                    v-for="item in filteredItems"
                    :key="item.to"
                    :prepend-icon="item.icon"
                    :title="item.title"
                    data-testid="command-palette-item"
                    @click="selectItem(item)"
                />
                <v-list-item
                    v-if="filteredItems.length === 0"
                    class="text-medium-emphasis"
                    title="Ничего не найдено"
                    data-testid="command-palette-empty"
                />
            </v-list>
        </v-card>
    </v-dialog>
</template>

<style scoped>
.cmdk-card {
    overflow: hidden;
}
.cmdk-list {
    max-height: 320px;
    overflow-y: auto;
}
</style>
```

- [ ] **Step 5: Запустить тесты палитры — убедиться, что зелёные**

Run: `cd app && npx vitest run tests/Frontend/CommandPalette.spec.ts`
Expected: 5 тестов PASS.

- [ ] **Step 6: Подключить ⌘K-handler в `AppSidebar.vue`**

В `app/resources/js/components/layout/AppSidebar.vue`:

Добавить импорт (после `import Kbd from '../ui/Kbd.vue';`):

```ts
import { useCommandPalette } from '../../composables/useCommandPalette';
```

В `<script setup>` (рядом с `const auth = useAuthStore();` из Task 1) добавить:

```ts
const { openPalette } = useCommandPalette();
```

Заменить блок `.ld-cmdk-stub` (строки 66-69) на:

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

- [ ] **Step 7: Подключить ⌘K-handler в `AppTopbar.vue`**

В `app/resources/js/components/layout/AppTopbar.vue`:

Добавить импорт (после строки 10, рядом с импортами stores):

```ts
import { useCommandPalette } from '../../composables/useCommandPalette';
```

В `<script setup>` (после `const router = useRouter();`) добавить:

```ts
const { openPalette } = useCommandPalette();
```

Заменить кнопку «Поиск» (строки 102-107) — убрать `disabled`, добавить `@click`:

```html
        <v-btn
            variant="outlined"
            size="small"
            prepend-icon="mdi-magnify"
            class="searchbar mr-2"
            data-testid="topbar-search-btn"
            @click="openPalette"
        >
            Поиск
            <template #append>
                <kbd class="search-kbd">⌘K</kbd>
            </template>
        </v-btn>
```

- [ ] **Step 8: Смонтировать `<CommandPalette>` в `AppLayout.vue`**

В `app/resources/js/layouts/AppLayout.vue`:

Добавить импорт (после строки 20 `import DevIndexBadge ...`):

```ts
import CommandPalette from '../components/layout/CommandPalette.vue';
```

В шаблоне, внутри `<v-app>`, после `<DevIndexBadge ... />` (строка 75) добавить:

```html
        <CommandPalette />
```

- [ ] **Step 9: Написать тест ⌘K-врезки сайдбара**

Дописать в `app/tests/Frontend/AppSidebarRedesign.spec.ts`:

```ts
test('B3: клик на ⌘K-плашку открывает command-palette', async () => {
    const { open, closePalette } = useCommandPalette();
    closePalette();
    const wrapper = mountSidebar();

    await wrapper.find('[data-testid="cmdk-stub"]').trigger('click');
    expect(open.value).toBe(true);
});
```

Импорт: `import { useCommandPalette } from '../../resources/js/composables/useCommandPalette';` (путь выровнять по файлу).

- [ ] **Step 10: Запустить связанные тесты**

Run: `cd app && npx vitest run tests/Frontend/CommandPalette.spec.ts tests/Frontend/AppSidebarRedesign.spec.ts tests/Frontend/AppLayout.spec.ts`
Expected: все PASS. Если `AppLayout.spec.ts` падает из-за нового `<CommandPalette>` в дереве — добавить `CommandPalette` в `stubs` существующего mount-харнесса этого файла.

- [ ] **Step 11: type-check + lint**

Run: `cd app && npm run type-check && npm run lint:vue`
Expected: 0 errors.

- [ ] **Step 12: Commit**

```bash
git add app/resources/js/composables/useCommandPalette.ts app/resources/js/components/layout/CommandPalette.vue app/resources/js/components/layout/AppSidebar.vue app/resources/js/components/layout/AppTopbar.vue app/resources/js/layouts/AppLayout.vue app/tests/Frontend/CommandPalette.spec.ts app/tests/Frontend/AppSidebarRedesign.spec.ts
git commit -m "feat(ui): B3 — минимальная ⌘K command-palette навигации"
```

---

## Task 3 — C3: экспорт всего списка из шапки DealsView

**Files:**

- Modify: `app/resources/js/views/DealsView.vue`
- Test: `app/tests/Frontend/DealsView.spec.ts` (append)

- [ ] **Step 1: Написать падающий тест**

Дописать в `app/tests/Frontend/DealsView.spec.ts` (следовать существующему mount-харнессу/мокам файла; экспорт-функции `dealsApi` мокаются — см. существующие тесты `applyBulkExport`):

```ts
test('C3: exportAllFiltered вызывает backend-экспорт со всеми отфильтрованными id', async () => {
    const xlsxSpy = vi.spyOn(dealsApi, 'exportDealsXlsx').mockResolvedValue(new Blob());
    const wrapper = mountDealsView(); // существующий хелпер; auth.user.tenant_id задан
    await flushPromises();

    await wrapper.vm.exportAllFiltered();

    expect(xlsxSpy).toHaveBeenCalledTimes(1);
    const callArg = xlsxSpy.mock.calls[0][0];
    expect(callArg.ids).toEqual(wrapper.vm.dealsState.map((d: { id: number }) => d.id));
    expect(wrapper.vm.exportToastOpen).toBe(true);
});

test('C3: exportAllFiltered на пустом списке показывает toast и не зовёт backend', async () => {
    const xlsxSpy = vi.spyOn(dealsApi, 'exportDealsXlsx').mockResolvedValue(new Blob());
    const wrapper = mountDealsView();
    await flushPromises();
    wrapper.vm.dealsState.splice(0, wrapper.vm.dealsState.length); // очистить список

    await wrapper.vm.exportAllFiltered();

    expect(xlsxSpy).not.toHaveBeenCalled();
    expect(wrapper.vm.exportToastText).toBe('Список пуст — нечего экспортировать.');
});
```

> Без выбранных фильтров `filteredDeals` = весь `dealsState` (вкладка `active` фильтрует по slug — если mock-сделки активны). Реализатор: если mock-данные имеют неактивные статусы — выставить `activeTab='all'` через exposed или сравнивать с `filteredDeals`, а не `dealsState`. Сверить с фактическим составом `MOCK_DEALS`.

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && npx vitest run tests/Frontend/DealsView.spec.ts`
Expected: 2 новых теста FAIL (`exportAllFiltered` не существует / не в `defineExpose`).

- [ ] **Step 3: Рефакторинг экспорт-функций в `DealsView.vue`**

В `app/resources/js/views/DealsView.vue` заменить `applyBulkExport` + `buildLocalCsv` (строки 342-392) на:

```ts
async function applyBulkExport(format: 'xlsx' | 'csv' = 'xlsx') {
    if (selected.value.length === 0) {
        exportToastText.value = 'Нет выбранных сделок.';
        exportToastOpen.value = true;
        return;
    }
    await exportDealIds([...selected.value], format);
}

// Audit C3: экспорт всех отфильтрованных сделок — кнопка «Экспорт» в page-head.
async function exportAllFiltered(format: 'xlsx' | 'csv' = 'xlsx') {
    const ids = filteredDeals.value.map((d) => d.id);
    if (ids.length === 0) {
        exportToastText.value = 'Список пуст — нечего экспортировать.';
        exportToastOpen.value = true;
        return;
    }
    await exportDealIds(ids, format);
}

/**
 * Общий экспорт по списку id. С tenant_id — backend (RLS-фильтрация чужих id).
 * На fail / без tenant — fallback на локальный CSV.
 */
async function exportDealIds(ids: number[], format: 'xlsx' | 'csv') {
    if (auth.user?.tenant_id) {
        try {
            if (format === 'xlsx') {
                const blob = await dealsApi.exportDealsXlsx({
                    tenant_id: auth.user.tenant_id,
                    ids,
                });
                triggerBlobDownload(blob, `deals_export_${new Date().toISOString().slice(0, 10)}.xlsx`);
                exportToastText.value = `Экспортировано ${ids.length} сделок в XLSX.`;
            } else {
                const csv = await dealsApi.exportDeals({
                    tenant_id: auth.user.tenant_id,
                    ids,
                });
                triggerCsvDownload(csv, `deals_export_${new Date().toISOString().slice(0, 10)}.csv`);
                exportToastText.value = `Экспортировано ${ids.length} сделок в CSV.`;
            }
            exportToastOpen.value = true;
            return;
        } catch {
            exportToastText.value = 'Backend недоступен — экспорт сформирован локально (CSV).';
        }
    }

    buildLocalCsv(ids);
}

function buildLocalCsv(ids: number[]) {
    const idSet = new Set(ids);
    const rows = dealsState.filter((d) => idSet.has(d.id));
    const headers = ['ID', 'Имя', 'Телефон', 'Статус', 'Проект', 'Менеджер', 'Стоимость', 'Получено мин назад'];
    const csv = buildCsvString(
        headers,
        rows.map((d) => [d.id, d.name, d.phone, d.statusSlug, d.project, d.manager.name, d.cost, d.receivedMinutesAgo]),
    );
    triggerCsvDownload(csv, `deals_export_${new Date().toISOString().slice(0, 10)}.csv`);

    if (!exportToastText.value) {
        exportToastText.value = `Экспортировано ${rows.length} сделок в CSV.`;
    }
    exportToastOpen.value = true;
}
```

- [ ] **Step 4: Добавить `exportAllFiltered` в `defineExpose`**

В блоке `defineExpose({...})` (строки 395-433) добавить строку рядом с `applyBulkExport`:

```ts
    exportAllFiltered,
```

- [ ] **Step 5: Подвязать кнопку в шаблоне**

Заменить строку 516 в `DealsView.vue`:

```html
                <v-btn
                    v-if="!trashMode"
                    variant="outlined"
                    prepend-icon="mdi-download"
                    data-testid="export-all-btn"
                    @click="exportAllFiltered()"
                >
                    Экспорт
                </v-btn>
```

- [ ] **Step 6: Запустить тесты — убедиться, что зелёные**

Run: `cd app && npx vitest run tests/Frontend/DealsView.spec.ts tests/Frontend/DealsViewRedesign.spec.ts`
Expected: все PASS (новые 2 + существующие — без регрессий по `applyBulkExport`).

- [ ] **Step 7: type-check + lint**

Run: `cd app && npm run type-check && npm run lint:vue`
Expected: 0 errors.

- [ ] **Step 8: Commit**

```bash
git add app/resources/js/views/DealsView.vue app/tests/Frontend/DealsView.spec.ts
git commit -m "feat(ui): C3 — кнопка «Экспорт» в шапке DealsView экспортирует весь список"
```

---

## Task 4 — C6: degradation-alert в NewDealDialog

**Files:**

- Modify: `app/resources/js/components/deals/NewDealDialog.vue`
- Test: `app/tests/Frontend/NewDealDialog.spec.ts` (append)

- [ ] **Step 1: Написать падающий тест**

Дописать в `app/tests/Frontend/NewDealDialog.spec.ts` (следовать существующему харнессу файла; `VDialog` стабится для inline-рендера):

```ts
test('C6: при провале loadLookups показывает degradation-alert', async () => {
    vi.spyOn(dealsApi, 'listProjects').mockRejectedValue(new Error('network'));
    vi.spyOn(dealsApi, 'listManagers').mockRejectedValue(new Error('network'));

    const wrapper = mountNewDealDialog({ tenantId: 7 }); // существующий хелпер
    // открыть диалог (model → true), если хелпер не открывает сам:
    await wrapper.setProps({ modelValue: true });
    await flushPromises();

    expect(wrapper.vm.lookupsFailed).toBe(true);
    expect(wrapper.find('[data-testid="lookups-error-alert"]').exists()).toBe(true);
});

test('C6: при успешном loadLookups alert отсутствует', async () => {
    vi.spyOn(dealsApi, 'listProjects').mockResolvedValue([{ id: 1, name: 'P', tag: null, type: 'manual' }]);
    vi.spyOn(dealsApi, 'listManagers').mockResolvedValue([{ id: 1, email: 'a@b.c', first_name: 'A', last_name: 'B', name: 'A B', initials: 'AB' }]);

    const wrapper = mountNewDealDialog({ tenantId: 7 });
    await wrapper.setProps({ modelValue: true });
    await flushPromises();

    expect(wrapper.vm.lookupsFailed).toBe(false);
    expect(wrapper.find('[data-testid="lookups-error-alert"]').exists()).toBe(false);
});
```

- [ ] **Step 2: Запустить — убедиться, что падает**

Run: `cd app && npx vitest run tests/Frontend/NewDealDialog.spec.ts`
Expected: тест с провалом FAIL (`lookupsFailed` не существует).

- [ ] **Step 3: Добавить флаг + alert в `NewDealDialog.vue`**

В `app/resources/js/components/deals/NewDealDialog.vue`:

После `const busy = ref(false);` (строка 77) добавить:

```ts
// Audit C6: loadLookups упал → показываем degradation-alert (списки = mock).
const lookupsFailed = ref(false);
```

В `loadLookups` catch-блок (строки 44-46) заменить на:

```ts
    } catch {
        // Audit C6: фиксируем провал — UI покажет degradation-alert.
        lookupsFailed.value = true;
    }
```

В функцию `reset()` (строки 84-94) добавить строку (например, после `submitError.value = null;`):

```ts
    lookupsFailed.value = false;
```

Перед `function close()` (строка 173) добавить:

```ts
defineExpose({ lookupsFailed });
```

В шаблоне, в `<v-card-text>`, **после** блока `submit-error-alert` (после строки 192 `</v-alert>`) добавить:

```html
                <v-alert
                    v-if="lookupsFailed"
                    type="warning"
                    variant="tonal"
                    density="compact"
                    class="mb-3"
                    data-testid="lookups-error-alert"
                >
                    Не удалось загрузить списки проектов и менеджеров — показаны примерные
                    значения. Проверьте выбор перед сохранением.
                </v-alert>
```

- [ ] **Step 4: Запустить тесты — убедиться, что зелёные**

Run: `cd app && npx vitest run tests/Frontend/NewDealDialog.spec.ts`
Expected: все PASS.

- [ ] **Step 5: type-check + lint**

Run: `cd app && npm run type-check && npm run lint:vue`
Expected: 0 errors.

- [ ] **Step 6: Commit**

```bash
git add app/resources/js/components/deals/NewDealDialog.vue app/tests/Frontend/NewDealDialog.spec.ts
git commit -m "feat(ui): C6 — degradation-alert в NewDealDialog при провале загрузки списков"
```

---

## Task 5 — C7: верификация degradation-alert DealDetailDrawer

**Files:**

- Verify (NO production change): `app/resources/js/components/deals/DealDetailDrawer.vue`, `DealDetailTimeline.vue`
- Test: `app/tests/Frontend/DealDetailDrawerApi.spec.ts` (append, если не покрыто)

> C7 — degradation-alert уже реализован: `DealDetailTimeline.vue:25-35` рендерит `events-fetch-error-alert`, `DealDetailDrawer.vue:289` пробрасывает `eventsFetchError`. Задача — подтвердить поведение характеризационным тестом (паттерн A5 из Sprint 5A). **Production-код не меняется.**

- [ ] **Step 1: Проверить существующее покрытие**

Run: `cd app && npx vitest run tests/Frontend/DealDetailDrawerApi.spec.ts tests/Frontend/DealDetailDrawer.spec.ts`
Затем grep по обоим файлам: ищем `eventsFetchError` и `events-fetch-error-alert`.

- Если тест, проверяющий `eventsFetchError === true` при провале `getDeal`, **уже есть** → C7 верифицирован, перейти к Step 4 (production-код и тесты не трогаем, зафиксировать факт в отчёте).
- Если такого теста **нет** → Step 2.

- [ ] **Step 2: Написать характеризационный тест**

Дописать в `app/tests/Frontend/DealDetailDrawerApi.spec.ts` (следовать харнессу файла — `getDeal` мокается):

```ts
test('C7: провал getDeal выставляет eventsFetchError и fallback на MOCK_EVENTS', async () => {
    vi.spyOn(dealsApi, 'getDeal').mockRejectedValue(new Error('500'));
    const wrapper = mountDrawer({ open: true, deal: SAMPLE_DEAL, tenantId: 7 }); // существующий хелпер
    await flushPromises();

    expect(wrapper.vm.eventsFetchError).toBe(true);
    expect(wrapper.vm.events.length).toBeGreaterThan(0); // MOCK_EVENTS fallback
});

test('C7: при eventsFetchError DealDetailTimeline рендерит degradation-alert', async () => {
    vi.spyOn(dealsApi, 'getDeal').mockRejectedValue(new Error('500'));
    const wrapper = mountDrawer({ open: true, deal: SAMPLE_DEAL, tenantId: 7 });
    await flushPromises();

    expect(wrapper.find('[data-testid="events-fetch-error-alert"]').exists()).toBe(true);
});
```

> Если `events-fetch-error-alert` не находится из-за `v-navigation-drawer`-телепорта — проверять только `wrapper.vm.eventsFetchError` (флаг — достаточная характеризация; alert-рендер из флага тривиален и виден в `DealDetailTimeline.vue:25`).

- [ ] **Step 3: Запустить — убедиться, что зелёные**

Run: `cd app && npx vitest run tests/Frontend/DealDetailDrawerApi.spec.ts`
Expected: все PASS (тесты подтверждают уже существующее поведение — должны пройти сразу, это характеризация, не TDD-red).

- [ ] **Step 4: Commit**

Если добавлены тесты:

```bash
git add app/tests/Frontend/DealDetailDrawerApi.spec.ts
git commit -m "test(ui): C7 — характеризационный тест degradation-alert DealDetailDrawer"
```

Если C7 уже был покрыт — коммита нет; зафиксировать в отчёте «C7 verified, покрытие уже существовало в <файл:тест>».

---

## Task 6 — Регрессия Sprint 5B

**Files:** нет правок — только прогон.

- [ ] **Step 1: Подготовка worktree-окружения (quirk #96)**

Native `EnterWorktree` даёт fresh checkout без gitignored-контента. Перед полной регрессией:

```bash
mkdir -p app/storage/framework/views app/storage/framework/sessions
```

Скопировать из main-checkout (если идентичны lock-файлы): `app/node_modules`, `app/vendor`, `app/.env`, `bin/gitleaks.exe`, `bin/lychee.exe`, каталог `лендинг/`. Затем:

```bash
cd app && npm run build
```

(иначе `ExampleTest` падает `ViteManifestNotFoundException`).

- [ ] **Step 2: Backend — полный Pest**

Run: `cd app && ./vendor/bin/pest --parallel`
Expected: 0 failed. Baseline до Sprint 5B — Pest 891/888 passed/0 failed/3 skipped (Sprint 5A). После 5B: +3 backend-теста (`count_only`) → ожидаемо 894/891/0/3. Любой `failed` — выписать file:line, классифицировать (quirk 72 vs реальный регресс).

- [ ] **Step 3: Frontend — полный Vitest**

Run: `cd app && npx vitest run`
Expected: 0 failed. Baseline — 102 files / 849 passed / 3 skipped. После 5B: +новые тесты (B2/B3/C3/C6/C7). Выписать фактические числа.

- [ ] **Step 4: type-check + lint + Pint**

Run:

```bash
cd app && npm run type-check
cd app && npm run lint:vue
cd app && composer pint -- --test
```

Expected: vue-tsc 0 errors; ESLint — 0 **новых** ошибок (известная pre-existing — `tests/Frontend/ImportView.spec.ts:4`, не Sprint 5B; verified на c64be74); Pint — 0 diff.

- [ ] **Step 5: Зафиксировать результат**

Блок в отчёте: Pest X/Y/fail/skip, Vitest Xf/Y/skip, vue-tsc, ESLint, Pint — с фактическими числами и явным списком failures (если есть).

---

## Acceptance criteria Sprint 5B

- B2 — бейдж «Сделки» отражает реальное число сделок tenant'а (не хардкод 247); на fail API — бейдж скрыт.
- B3 — ⌘K (клавиша + плашка сайдбара + кнопка топбара) открывает работающую палитру навигации; кнопка «Поиск» в топбаре больше не `disabled`.
- C3 — кнопка «Экспорт» в шапке DealsView экспортирует все отфильтрованные сделки.
- C6 — провал загрузки списков в NewDealDialog показывает degradation-alert.
- C7 — degradation-alert DealDetailDrawer подтверждён тестом.
- Регрессия: Pest + Vitest + vue-tsc + Pint зелёные; 0 новых ESLint-ошибок.

## Self-review (выполнено при написании плана)

- **Spec-coverage:** 5 эпиков 5B (B2/B3/C3/C6/C7) — по задаче на каждый + T6 регрессия. ✓
- **Placeholder-scan:** код приведён полностью в каждом шаге; «следовать харнессу файла» — только для тест-setup (импорты/mount-хелперы), не для логики. ✓
- **Type-consistency:** `count_only` (snake_case query-param) ↔ `fetchDealsCount` ↔ store `useDealsCountStore.count` ↔ `countKey: 'deals'` ↔ `data-testid="nav-count-deals"`; `useCommandPalette().open/openPalette/closePalette` consistent во всех 4 потребителях; `exportDealIds/exportAllFiltered/buildLocalCsv(ids)` сигнатуры согласованы. ✓
- **Известный риск:** AppSidebar.vue правится в T1 и T2 — subagent-driven исполняет последовательно, T2 садится поверх T1 без конфликта.
