# Sprint 1 — P0 UI Fixes 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 P0 UI-багов из аудита 2026-05-15 — UI обещает функциональность, которая не работает (dead handlers / silent failures / blocking alert / non-persisted DnD).

**Architecture:** Атомарные TDD-таски, по одному эпику = один commit. 0 schema changes. Используем существующие API endpoints (`/api/projects`, `/api/managers`, `/api/deals/transition`, `/api/admin/pricing-tiers`, `/api/admin/suppliers/{id}`) и существующие patterns (`extractErrorMessage` хелпер, `v-snackbar` snackbar, `v-menu` activator slot). Vitest TDD strict — failing test first, GREEN после.

**Tech Stack:** Vue 3 (composition API) + Vuetify 3 + Pinia + Vitest + @vue/test-utils + axios. Backend Laravel 13 + PostgreSQL 16 (без миграций в этом спринте).

**Source spec:** [docs/superpowers/specs/2026-05-15-portal-audit-design.md](../specs/2026-05-15-portal-audit-design.md) (commit `e978b33`).

**Baseline state:**

- Branch: `main`, +4 unpushed local commits, push BLOCKED GitHub 403 «verify email» (quirk #89)
- Pest --parallel: 749/746/3sk/0 ✅
- Vitest: 92 files / 759 passed / 4 skipped / 0 failed ✅
- vue-tsc 0, ESLint 0, lefthook chain green

---

## File Structure

| Task | Files (Create / Modify / Test) |
|---|---|
| 1. C2 — DealsView FilterChip popovers | Modify `app/resources/js/views/DealsView.vue:41-44, 470-492`<br>Test `app/tests/Frontend/DealsViewRedesign.spec.ts` (extend) |
| 2. C4 — KanbanView DnD persist | Modify `app/resources/js/views/KanbanView.vue:53-61, 1-32`<br>Test `app/tests/Frontend/KanbanView.spec.ts` (extend) |
| 3. C5 — BulkActionsBar snackbar | Modify `app/resources/js/components/projects/BulkActionsBar.vue:1-108`<br>Test `app/tests/Frontend/BulkActionsBar.spec.ts` (extend) |
| 4. G1 — AdminPricingTiersView error handling | Modify `app/resources/js/views/admin/AdminPricingTiersView.vue:120-178`<br>Test `app/tests/Frontend/AdminPricingTiersView.spec.ts` (extend) |
| 5. G2 — AdminSupplierPricesView error handling | Modify `app/resources/js/views/admin/AdminSupplierPricesView.vue:50-106`<br>Test `app/tests/Frontend/AdminSupplierPricesView.spec.ts` (extend) |

**Existing pattern references:**

- Error-handling: [AdminSystemView.vue:32-45](../../../app/resources/js/views/admin/AdminSystemView.vue#L32-L45) — `extractErrorMessage(err, fallback)` + `v-alert :data-testid="fetch-error-alert" closable @click:close`
- Snackbar pattern: [DealsView.vue:566-581](../../../app/resources/js/views/DealsView.vue#L566-L581) — `v-snackbar v-model :timeout location="bottom right"` + `data-testid`
- v-menu activator pattern: standard Vuetify 3 — `<v-menu><template #activator="{ props }"><MyButton v-bind="props" /></template>...</v-menu>`
- Vitest mount + Vuetify plugin: [AdminPricingTiersView.spec.ts:1-39](../../../app/tests/Frontend/AdminPricingTiersView.spec.ts#L1-L39)

---

## Task 1: C2 — DealsView FilterChip popovers (Проект / Менеджер)

**Source line:** [DealsView.vue:41-44](../../../app/resources/js/views/DealsView.vue#L41-L44):

```ts
function onRedesignFilterClick(name: string): void {
    console.log(`[redesign filterbar] ${name} clicked — popover TBD`);
}
```

**Files:**

- Modify: `app/resources/js/views/DealsView.vue` (script section: remove stub + add menu state; template section: replace 2 chips with v-menu wrappers)
- Test: `app/tests/Frontend/DealsViewRedesign.spec.ts` (extend with popover tests)

### Steps

- [ ] **Step 1.1: Write failing test for Project chip popover**

Edit `app/tests/Frontend/DealsViewRedesign.spec.ts` — append `describe('FilterChip popovers')` block at end of file:

```ts
describe('FilterChip popovers (Sprint 1 C2)', () => {
    it('clicking Project chip toggles projectMenuOpen ref to true', async () => {
        const wrapper = mount(DealsView, {
            global: {
                plugins: [createPinia(), createVuetify()],
                stubs: { DealDetailDrawer: true, NewDealDialog: true, VMenu: { template: '<div><slot name="activator" :props="{}" /><slot /></div>' } },
            },
        });
        await new Promise((r) => setTimeout(r, 50));
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        expect(vm.projectMenuOpen).toBe(false);
        // Trigger chip click via exposed handler
        vm.onRedesignFilterClick('Проект');
        await wrapper.vm.$nextTick();
        expect(vm.projectMenuOpen).toBe(true);
    });

    it('clicking Manager chip toggles managerMenuOpen ref to true', async () => {
        const wrapper = mount(DealsView, {
            global: {
                plugins: [createPinia(), createVuetify()],
                stubs: { DealDetailDrawer: true, NewDealDialog: true, VMenu: { template: '<div><slot name="activator" :props="{}" /><slot /></div>' } },
            },
        });
        await new Promise((r) => setTimeout(r, 50));
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        expect(vm.managerMenuOpen).toBe(false);
        vm.onRedesignFilterClick('Менеджер');
        await wrapper.vm.$nextTick();
        expect(vm.managerMenuOpen).toBe(true);
    });

    it('applying project selection updates filterProjects and closes menu', async () => {
        const wrapper = mount(DealsView, {
            global: {
                plugins: [createPinia(), createVuetify()],
                stubs: { DealDetailDrawer: true, NewDealDialog: true, VMenu: { template: '<div><slot name="activator" :props="{}" /><slot /></div>' } },
            },
        });
        await new Promise((r) => setTimeout(r, 50));
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        vm.projectMenuDraft = ['demo-project-1', 'demo-project-2'];
        vm.applyProjectFilter();
        await wrapper.vm.$nextTick();
        expect(vm.filterProjects).toEqual(['demo-project-1', 'demo-project-2']);
        expect(vm.projectMenuOpen).toBe(false);
    });
});
```

Also add at top of file (alongside existing imports) if missing:

```ts
import { createPinia } from 'pinia';
```

- [ ] **Step 1.2: Run test to verify fail**

```bash
cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/DealsViewRedesign.spec.ts -t "FilterChip popovers"
```

Expected: FAIL — «vm.projectMenuOpen is undefined» / «applyProjectFilter is not a function».

- [ ] **Step 1.3: Add menu state + apply handlers to DealsView.vue script section**

Edit `app/resources/js/views/DealsView.vue` — replace lines 40-45 (`onRedesignFilterClick` stub) with:

```ts
// Sprint 1 C2: popovers для Проект/Менеджер chip'ов. Draft-state накапливает
// выбор в v-menu, при «Применить» переносится в filterProjects/filterManagers.
// Status chip остаётся read-only (P2 backlog в Sprint 5).
const projectMenuOpen = ref(false);
const managerMenuOpen = ref(false);
const projectMenuDraft = ref<string[]>([]);
const managerMenuDraft = ref<string[]>([]);

function onRedesignFilterClick(name: string): void {
    if (name === 'Проект') {
        projectMenuDraft.value = [...filterProjects.value];
        projectMenuOpen.value = true;
    } else if (name === 'Менеджер') {
        managerMenuDraft.value = [...filterManagers.value];
        managerMenuOpen.value = true;
    }
    // 'Статус' — read-only summary, popover откроется в Sprint 5 (P2).
}

function applyProjectFilter(): void {
    filterProjects.value = [...projectMenuDraft.value];
    projectMenuOpen.value = false;
}

function applyManagerFilter(): void {
    filterManagers.value = [...managerMenuDraft.value];
    managerMenuOpen.value = false;
}

function clearProjectDraft(): void {
    projectMenuDraft.value = [];
}

function clearManagerDraft(): void {
    managerMenuDraft.value = [];
}
```

And extend `defineExpose` (line 327-353) — добавить в объект:

```ts
projectMenuOpen,
managerMenuOpen,
projectMenuDraft,
managerMenuDraft,
applyProjectFilter,
applyManagerFilter,
```

- [ ] **Step 1.4: Re-run test to verify pass**

```bash
cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/DealsViewRedesign.spec.ts -t "FilterChip popovers"
```

Expected: PASS — все 3 теста.

- [ ] **Step 1.5: Replace template chiprow with v-menu wrappers**

Edit `app/resources/js/views/DealsView.vue` — заменить блок lines 470-492 (entire `<div v-if="!trashMode" class="ld-filterbar mt-3">` block):

```vue
<div v-if="!trashMode" class="ld-filterbar mt-3">
    <div class="ld-filterbar__chips">
        <FilterChip
            label="Статус"
            :count="filteredDeals.length"
            :active="false"
            @click="onRedesignFilterClick('Статус')"
        />
        <v-menu v-model="projectMenuOpen" :close-on-content-click="false" location="bottom start">
            <template #activator="{ props: activatorProps }">
                <span v-bind="activatorProps">
                    <FilterChip
                        label="Проект"
                        :count="filterProjects.length"
                        :active="filterProjects.length > 0"
                        @click="onRedesignFilterClick('Проект')"
                    />
                </span>
            </template>
            <v-card min-width="260" max-width="320" data-testid="project-menu-card">
                <v-card-text class="pa-2">
                    <v-list density="compact" class="pa-0">
                        <v-list-item v-if="availableProjects.length === 0" class="text-medium-emphasis">
                            <v-list-item-title>Нет проектов в текущем списке</v-list-item-title>
                        </v-list-item>
                        <v-list-item
                            v-for="proj in availableProjects"
                            :key="proj"
                            class="py-1"
                            @click="
                                projectMenuDraft.includes(proj)
                                    ? (projectMenuDraft = projectMenuDraft.filter((p) => p !== proj))
                                    : (projectMenuDraft = [...projectMenuDraft, proj])
                            "
                        >
                            <template #prepend>
                                <v-checkbox-btn :model-value="projectMenuDraft.includes(proj)" />
                            </template>
                            <v-list-item-title>{{ proj }}</v-list-item-title>
                        </v-list-item>
                    </v-list>
                </v-card-text>
                <v-card-actions class="px-3 pb-2">
                    <v-btn variant="text" size="small" data-testid="project-menu-clear" @click="clearProjectDraft">
                        Очистить
                    </v-btn>
                    <v-spacer />
                    <v-btn
                        color="primary"
                        variant="flat"
                        size="small"
                        data-testid="project-menu-apply"
                        @click="applyProjectFilter"
                    >
                        Применить
                    </v-btn>
                </v-card-actions>
            </v-card>
        </v-menu>
        <v-menu v-model="managerMenuOpen" :close-on-content-click="false" location="bottom start">
            <template #activator="{ props: activatorProps }">
                <span v-bind="activatorProps">
                    <FilterChip
                        label="Менеджер"
                        :count="filterManagers.length"
                        :active="filterManagers.length > 0"
                        @click="onRedesignFilterClick('Менеджер')"
                    />
                </span>
            </template>
            <v-card min-width="260" max-width="320" data-testid="manager-menu-card">
                <v-card-text class="pa-2">
                    <v-list density="compact" class="pa-0">
                        <v-list-item v-if="availableManagers.length === 0" class="text-medium-emphasis">
                            <v-list-item-title>Нет менеджеров в текущем списке</v-list-item-title>
                        </v-list-item>
                        <v-list-item
                            v-for="mgr in availableManagers"
                            :key="mgr.name"
                            class="py-1"
                            @click="
                                managerMenuDraft.includes(mgr.name)
                                    ? (managerMenuDraft = managerMenuDraft.filter((m) => m !== mgr.name))
                                    : (managerMenuDraft = [...managerMenuDraft, mgr.name])
                            "
                        >
                            <template #prepend>
                                <v-checkbox-btn :model-value="managerMenuDraft.includes(mgr.name)" />
                            </template>
                            <v-list-item-title>{{ mgr.name }}</v-list-item-title>
                            <v-list-item-subtitle class="text-caption">{{ mgr.initials }}</v-list-item-subtitle>
                        </v-list-item>
                    </v-list>
                </v-card-text>
                <v-card-actions class="px-3 pb-2">
                    <v-btn variant="text" size="small" data-testid="manager-menu-clear" @click="clearManagerDraft">
                        Очистить
                    </v-btn>
                    <v-spacer />
                    <v-btn
                        color="primary"
                        variant="flat"
                        size="small"
                        data-testid="manager-menu-apply"
                        @click="applyManagerFilter"
                    >
                        Применить
                    </v-btn>
                </v-card-actions>
            </v-card>
        </v-menu>
    </div>
    <DensityToggle class="ld-filterbar__density" />
</div>
```

- [ ] **Step 1.6: Run full DealsViewRedesign spec to ensure no regression in existing tests**

```bash
cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/DealsViewRedesign.spec.ts
```

Expected: ALL tests PASS (existing + 3 new).

- [ ] **Step 1.7: Run wider DealsView regression sweep**

```bash
cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/DealsView.spec.ts app/tests/Frontend/DealsListIntegration.spec.ts app/tests/Frontend/DealsTable.spec.ts
```

Expected: ALL PASS, 0 regressions.

- [ ] **Step 1.8: Manual browser smoke via Playwright MCP**

Navigate to http://127.0.0.1:8000/login, login as admin@demo.local / password (если seeder работает — иначе пропустить и зафиксировать как finding для Sprint 5 A8). Перейти на /deals. Кликнуть «Проект» chip — открыться popover со списком проектов. Выбрать 1-2 → «Применить» → таблица фильтруется. Аналогично для «Менеджер».

Если smoke не пройти — выписать в Limitations section commit message.

- [ ] **Step 1.9: vue-tsc + ESLint**

```bash
cd "c:/моя/проекты/портал crm/Документация/app" && npm run type-check && npm run lint:vue
```

Expected: 0 errors на изменённых файлах.

- [ ] **Step 1.10: Commit**

```bash
cd "c:/моя/проекты/портал crm/Документация" && git add app/resources/js/views/DealsView.vue app/tests/Frontend/DealsViewRedesign.spec.ts && git commit -m "$(cat <<'EOF'
feat(deals): C2 — wire FilterChip popovers (Проект/Менеджер) with v-menu

Заменён dead-stub onRedesignFilterClick (console.log only) на работающие
v-menu popover'ы. Project и Manager chip'ы открывают v-card с v-list checkbox-
multi-select, бинд на projectMenuDraft/managerMenuDraft → Применить → перенос
в существующие filterProjects/filterManagers refs. Status chip остаётся
read-only (P2 backlog Sprint 5).

+3 Vitest specs в DealsViewRedesign.spec.ts (toggle menu / apply selection /
empty state). Регрессий 0.

Closes audit ID C2 from docs/superpowers/specs/2026-05-15-portal-audit-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

Expected: lefthook green, commit created.

---

## Task 2: C4 — KanbanView DnD persist через `POST /api/deals/transition`

**Source line:** [KanbanView.vue:53-61](../../../app/resources/js/views/KanbanView.vue#L53-L61):

```ts
function onColumnChange(targetSlug: MockDeal['statusSlug'], event: DraggableChangeEvent) {
    if (event.added) {
        event.added.element.statusSlug = targetSlug;
    }
}
```

**Files:**

- Modify: `app/resources/js/views/KanbanView.vue` (script: + 1 import, onColumnChange optimistic+await+revert+toast, + snackbar state, + defineExpose; template: + v-snackbar)
- Test: `app/tests/Frontend/KanbanView.spec.ts` (extend)

### Steps

- [ ] **Step 2.1: Write failing test for transition API call**

Open `app/tests/Frontend/KanbanView.spec.ts` (read full first to understand existing structure), then append at end of file:

```ts
describe('KanbanView DnD persist (Sprint 1 C4)', () => {
    it('onColumnChange triggers dealsApi.transitionDeals with [dealId] and target status', async () => {
        const transitionSpy = vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({
            updated: 1,
            requested: 1,
            status: 'hot',
        });
        const wrapper = mount(KanbanView, {
            global: {
                plugins: [createPinia(), createVuetify()],
                stubs: { KanbanColumn: true, DealDetailDrawer: true, NewDealDialog: true },
            },
        });
        const auth = useAuthStore();
        auth.user = { id: 99, tenant_id: 7, email: 'demo@demo.local' } as never;
        await new Promise((r) => setTimeout(r, 30));

        const deal = { id: 42, statusSlug: 'new' as const, name: 'X', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5 };
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        await (wrapper.vm as any).onColumnChange('hot', { added: { element: deal, newIndex: 0 } });

        expect(transitionSpy).toHaveBeenCalledWith({
            tenant_id: 7,
            ids: [42],
            status: 'hot',
        });
        expect(deal.statusSlug).toBe('hot');
    });

    it('onColumnChange reverts statusSlug + opens toast when API rejects', async () => {
        vi.spyOn(dealsApi, 'transitionDeals').mockRejectedValue(new Error('500'));
        const wrapper = mount(KanbanView, {
            global: {
                plugins: [createPinia(), createVuetify()],
                stubs: { KanbanColumn: true, DealDetailDrawer: true, NewDealDialog: true },
            },
        });
        const auth = useAuthStore();
        auth.user = { id: 99, tenant_id: 7, email: 'demo@demo.local' } as never;
        await new Promise((r) => setTimeout(r, 30));

        const deal = { id: 43, statusSlug: 'new' as const, name: 'Y', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5 };
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        await (wrapper.vm as any).onColumnChange('hot', { added: { element: deal, newIndex: 0 } });

        // После failure — statusSlug откатывается на оригинал
        expect(deal.statusSlug).toBe('new');
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        expect((wrapper.vm as any).transitionToastOpen).toBe(true);
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        expect((wrapper.vm as any).transitionToastText).toContain('Не удалось');
    });

    it('onColumnChange skips API call if no auth.user.tenant_id', async () => {
        const transitionSpy = vi.spyOn(dealsApi, 'transitionDeals').mockResolvedValue({
            updated: 1, requested: 1, status: 'hot',
        });
        const wrapper = mount(KanbanView, {
            global: {
                plugins: [createPinia(), createVuetify()],
                stubs: { KanbanColumn: true, DealDetailDrawer: true, NewDealDialog: true },
            },
        });
        const auth = useAuthStore();
        auth.user = null;
        await new Promise((r) => setTimeout(r, 30));

        const deal = { id: 44, statusSlug: 'new' as const, name: 'Z', phone: '+79161234567', project: 'p', manager: { name: 'M', initials: 'M' }, cost: 100, receivedMinutesAgo: 5 };
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        await (wrapper.vm as any).onColumnChange('hot', { added: { element: deal, newIndex: 0 } });

        // Без auth — только optimistic local change, API не зовётся
        expect(transitionSpy).not.toHaveBeenCalled();
        expect(deal.statusSlug).toBe('hot');
    });
});
```

Add imports at top of file if missing (rely on file's existing import-list — but if not present, add):

```ts
import { createPinia } from 'pinia';
import { useAuthStore } from '../../resources/js/stores/auth';
import * as dealsApi from '../../resources/js/api/deals';
```

- [ ] **Step 2.2: Run test to verify fail**

```bash
cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/KanbanView.spec.ts -t "DnD persist"
```

Expected: FAIL — «transitionSpy not called» / «transitionToastOpen is undefined».

- [ ] **Step 2.3: Refactor onColumnChange to call API with revert-on-fail**

Edit `app/resources/js/views/KanbanView.vue` — заменить function `onColumnChange` (lines 53-61) на:

```ts
async function onColumnChange(targetSlug: MockDeal['statusSlug'], event: DraggableChangeEvent) {
    if (!event.added) {
        // 'removed' и 'moved' — vuedraggable мутирует array; reactive triggers re-render.
        return;
    }

    const dealItem = event.added.element;
    const previousSlug = dealItem.statusSlug;

    // Optimistic: меняем статус в local state сразу (UX отвечает мгновенно).
    dealItem.statusSlug = targetSlug;

    // Без auth — local-only mode (dev/demo без tenant context). API не зовём.
    if (!auth.user?.tenant_id) return;

    try {
        await dealsApi.transitionDeals({
            tenant_id: auth.user.tenant_id,
            ids: [dealItem.id],
            status: targetSlug,
        });
        // success — статус уже применён, тостить не нужно.
    } catch {
        // Revert на исходный статус + переместить карточку обратно в исходную колонку.
        dealItem.statusSlug = previousSlug;
        // Найдём целевую колонку и удалим из неё карточку, потом вернём в исходную.
        const targetCol = dealsByStatus[targetSlug];
        if (targetCol) {
            const idx = targetCol.findIndex((d) => d.id === dealItem.id);
            if (idx >= 0) targetCol.splice(idx, 1);
        }
        const sourceCol = dealsByStatus[previousSlug];
        if (sourceCol && !sourceCol.find((d) => d.id === dealItem.id)) {
            sourceCol.push(dealItem);
        }
        transitionToastText.value = `Не удалось переместить сделку #${dealItem.id} — восстановлен исходный статус.`;
        transitionToastOpen.value = true;
    }
}
```

И добавить в script setup секцию (после `const newDealOpen = ref(false);`, около line 81):

```ts
// Sprint 1 C4: revert-on-fail toast при DnD-fail.
const transitionToastOpen = ref(false);
const transitionToastText = ref('');
```

Расширить `defineExpose` (line 116) — добавить:

```ts
defineExpose({
    dealsByStatus,
    totalDeals,
    newDealOpen,
    onDealCreated,
    fetchError,
    loadDeals,
    onColumnChange,
    transitionToastOpen,
    transitionToastText,
});
```

Добавить в `<template>` после `<NewDealDialog />` (после line 178):

```vue
<v-snackbar
    v-model="transitionToastOpen"
    :timeout="4000"
    color="warning"
    location="bottom right"
    data-testid="kanban-transition-toast"
>
    {{ transitionToastText }}
</v-snackbar>
```

- [ ] **Step 2.4: Re-run test to verify pass**

```bash
cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/KanbanView.spec.ts
```

Expected: ALL PASS (existing + 3 new).

- [ ] **Step 2.5: Backend regression check — Pest test for transition idempotency**

Verify existing Pest covers the case (no new test needed — `app/tests/Feature/DealsApi/DealTransitionTest.php` already covers transition). Just confirm:

```bash
cd "c:/моя/проекты/портал crm/Документация/app" && composer test -- --filter=Transition
```

Expected: ALL PASS (existing transition tests). Если не существует — добавить минимальный Pest test:

```php
// app/tests/Feature/DealsApi/DealTransitionSingleTest.php
<?php
declare(strict_types=1);

use App\Models\Deal;
use App\Models\LeadStatus;
use App\Models\Project;
use App\Models\Tenant;
use App\Models\User;

it('transition deal new → hot via POST /api/deals/transition idempotent', function () {
    $tenant = Tenant::factory()->create();
    $user = User::factory()->for($tenant)->create();
    $project = Project::factory()->for($tenant)->create();
    LeadStatus::firstOrCreate(['slug' => 'new'], ['name_ru' => 'Новые', 'sort_order' => 1]);
    LeadStatus::firstOrCreate(['slug' => 'hot'], ['name_ru' => 'Горячий', 'sort_order' => 12]);
    $deal = Deal::factory()->for($tenant)->for($project)->create(['status' => 'new']);

    $this->actingAs($user)->postJson('/api/deals/transition', [
        'tenant_id' => $tenant->id,
        'ids' => [$deal->id],
        'status' => 'hot',
    ])->assertOk()->assertJson(['updated' => 1, 'requested' => 1, 'status' => 'hot']);

    // Idempotent re-call with same status — updated=0 because already hot
    $this->actingAs($user)->postJson('/api/deals/transition', [
        'tenant_id' => $tenant->id,
        'ids' => [$deal->id],
        'status' => 'hot',
    ])->assertOk()->assertJson(['updated' => 0, 'requested' => 1, 'status' => 'hot']);
});
```

Run:

```bash
cd "c:/моя/проекты/портал crm/Документация/app" && ./vendor/bin/pest tests/Feature/DealsApi/DealTransitionSingleTest.php
```

Expected: PASS.

- [ ] **Step 2.6: Full KanbanView Vitest regression**

```bash
cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/KanbanView.spec.ts app/tests/Frontend/KanbanColumn.spec.ts app/tests/Frontend/KanbanCard.spec.ts app/tests/Frontend/KanbanRedesign.spec.ts
```

Expected: ALL PASS.

- [ ] **Step 2.7: vue-tsc + ESLint**

```bash
cd "c:/моя/проекты/портал crm/Документация/app" && npm run type-check && npm run lint:vue
```

Expected: 0 errors на KanbanView.vue.

- [ ] **Step 2.8: Manual browser smoke**

http://127.0.0.1:8000/kanban (после login). Drag карточку между колонками. Open DevTools Network → POST /api/deals/transition должен быть в логах. Refresh страницы — статус карточки сохранился.

- [ ] **Step 2.9: Commit**

```bash
cd "c:/моя/проекты/портал crm/Документация" && git add app/resources/js/views/KanbanView.vue app/tests/Frontend/KanbanView.spec.ts && git commit -m "$(cat <<'EOF'
feat(kanban): C4 — persist DnD status changes via POST /api/deals/transition

Drag-drop между колонками теперь сохраняется в БД через существующий
DealBulkActionController@transition endpoint (single-element массив).
Optimistic UI update (statusSlug меняется сразу) + revert-on-fail с
toast «Не удалось переместить — восстановлен исходный статус».

Без auth.user.tenant_id (dev/demo без login) — local-only mode, API не
зовётся (graceful degradation).

+3 Vitest specs в KanbanView.spec.ts (success / revert / no-auth skip).
Pest covered by existing DealTransitionTest. Регрессий 0.

Closes audit ID C4 from docs/superpowers/specs/2026-05-15-portal-audit-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

Expected: lefthook green.

---

## Task 3: C5 — BulkActionsBar `window.alert()` → `v-snackbar`

**Source line:** [BulkActionsBar.vue:87-94](../../../app/resources/js/components/projects/BulkActionsBar.vue#L87-L94):

```ts
async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
    const result = await store.bulkUpdate(payload);
    if (result.skipped.length > 0) {
        window.alert(
            `Применено: ${result.updated}. Пропущено: ${result.skipped.length} (конфликт с уже доставленными лидами).`,
        );
    }
}
```

**Files:**

- Modify: `app/resources/js/components/projects/BulkActionsBar.vue` (script + template)
- Test: `app/tests/Frontend/BulkActionsBar.spec.ts` (extend)

### Steps

- [ ] **Step 3.1: Write failing test for snackbar replacement of alert**

Edit `app/tests/Frontend/BulkActionsBar.spec.ts` — append после существующих describe-блоков:

```ts
describe('BulkActionsBar snackbar replacement (Sprint 1 C5)', () => {
    it('runBulk with skipped > 0 opens snackbar (not window.alert)', async () => {
        setActivePinia(createPinia());
        const store = useProjectsStore();
        store.selectedIds.add(1);
        vi.spyOn(store, 'bulkUpdate').mockResolvedValue({
            updated: 5,
            skipped: [{ id: 7 }, { id: 8 }],
            warnings: [],
        } as never);
        const alertSpy = vi.spyOn(window, 'alert').mockImplementation(() => {});
        window.confirm = vi.fn(() => true);

        const wrapper = mount(BulkActionsBar, {
            global: {
                plugins: [createVuetify()],
                stubs: defaultStubs,
            },
        });
        await wrapper.find('[data-testid="bulk-pause"]').trigger('click');
        // Wait for async confirmAndRun + runBulk + DOM update
        await new Promise((r) => setTimeout(r, 30));

        // window.alert MUST NOT be called
        expect(alertSpy).not.toHaveBeenCalled();
        // snackbar state should be open
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        expect((wrapper.vm as any).skipToastOpen).toBe(true);
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        expect((wrapper.vm as any).skipToastText).toContain('Применено: 5');
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        expect((wrapper.vm as any).skipToastText).toContain('Пропущено: 2');
    });

    it('runBulk with skipped=0 does NOT open snackbar', async () => {
        setActivePinia(createPinia());
        const store = useProjectsStore();
        store.selectedIds.add(1);
        vi.spyOn(store, 'bulkUpdate').mockResolvedValue({
            updated: 3,
            skipped: [],
            warnings: [],
        } as never);
        window.confirm = vi.fn(() => true);

        const wrapper = mount(BulkActionsBar, {
            global: {
                plugins: [createVuetify()],
                stubs: defaultStubs,
            },
        });
        await wrapper.find('[data-testid="bulk-pause"]').trigger('click');
        await new Promise((r) => setTimeout(r, 30));

        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        expect((wrapper.vm as any).skipToastOpen).toBe(false);
    });
});
```

- [ ] **Step 3.2: Run test to verify fail**

```bash
cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/BulkActionsBar.spec.ts -t "snackbar replacement"
```

Expected: FAIL — «alertSpy was called» / «skipToastOpen is undefined».

- [ ] **Step 3.3: Replace window.alert with snackbar in BulkActionsBar.vue**

Edit `app/resources/js/components/projects/BulkActionsBar.vue` — заменить script section и template (entire file rewrite в чистом виде):

```vue
<template>
    <v-card class="bulk-actions-bar" elevation="6">
        <DevIndexBadge :index="20" label="BulkActionsBar" :dialog-mode="true" style="top: 4px; right: 4px" />
        <v-card-text class="d-flex align-center gap-3 flex-wrap">
            <strong>Выбрано: {{ store.selectedIds.size }}</strong>

            <v-divider vertical />

            <v-btn color="primary" variant="outlined" data-testid="bulk-regions" @click="regionsOpen = true">
                🌍 Регионы…
            </v-btn>
            <v-btn color="primary" variant="outlined" data-testid="bulk-days" @click="daysOpen = true">
                📅 Дни сбора…
            </v-btn>
            <v-btn color="primary" variant="outlined" data-testid="bulk-limit" @click="limitOpen = true">
                🎯 Лимит лидов…
            </v-btn>

            <v-divider vertical />

            <v-btn color="warning" prepend-icon="mdi-pause" data-testid="bulk-pause" @click="confirmAndRun('pause')">
                Приостановить
            </v-btn>
            <v-btn color="success" prepend-icon="mdi-play" data-testid="bulk-resume" @click="confirmAndRun('resume')">
                Возобновить
            </v-btn>

            <v-divider vertical />

            <v-btn
                color="error"
                prepend-icon="mdi-archive"
                data-testid="bulk-archive"
                @click="confirmAndRun('archive')"
            >
                Архивировать
            </v-btn>

            <v-spacer />
            <v-btn variant="text" data-testid="bulk-clear" @click="store.clearSelection">Снять выбор</v-btn>
        </v-card-text>

        <RegionsBulkDialog
            v-model="regionsOpen"
            :count="store.selectedIds.size"
            @apply="(p) => runBulk({ action: 'update_regions', ...p })"
        />
        <DaysBulkDialog
            v-model="daysOpen"
            :count="store.selectedIds.size"
            @apply="(p) => runBulk({ action: 'update_days', ...p })"
        />
        <LimitBulkDialog
            v-model="limitOpen"
            :count="store.selectedIds.size"
            @apply="(p) => runBulk({ action: 'update_limit', ...p })"
        />

        <v-snackbar
            v-model="skipToastOpen"
            :timeout="6000"
            color="warning"
            location="bottom right"
            data-testid="bulk-skip-toast"
        >
            {{ skipToastText }}
            <template #actions>
                <v-btn variant="text" @click="skipToastOpen = false">Закрыть</v-btn>
            </template>
        </v-snackbar>
    </v-card>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import { useProjectsStore } from '../../stores/projectsStore';
import DevIndexBadge from '../DevIndexBadge.vue';
import RegionsBulkDialog from './RegionsBulkDialog.vue';
import DaysBulkDialog from './DaysBulkDialog.vue';
import LimitBulkDialog from './LimitBulkDialog.vue';

const store = useProjectsStore();

const regionsOpen = ref(false);
const daysOpen = ref(false);
const limitOpen = ref(false);

// Sprint 1 C5: window.alert → v-snackbar (non-blocking, accessible, не breaks браузерный automation).
const skipToastOpen = ref(false);
const skipToastText = ref('');

const messages: Record<string, string> = {
    pause: 'Приостановить выбранные проекты?',
    resume: 'Возобновить выбранные проекты?',
    archive:
        'Архивировать выбранные проекты?\nДействие необратимо в Plan 5 (восстановление потребует ручного запроса).',
};

async function confirmAndRun(action: 'pause' | 'resume' | 'archive') {
    if (!window.confirm(messages[action])) return;
    await runBulk({ action });
}

async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
    const result = await store.bulkUpdate(payload);
    if (result.skipped.length > 0) {
        skipToastText.value = `Применено: ${result.updated}. Пропущено: ${result.skipped.length} (конфликт с уже доставленными лидами).`;
        skipToastOpen.value = true;
    }
}

defineExpose({ regionsOpen, daysOpen, limitOpen, skipToastOpen, skipToastText, runBulk });
</script>

<style scoped>
.bulk-actions-bar {
    position: fixed;
    bottom: 24px;
    left: 50%;
    transform: translateX(-50%);
    z-index: 10;
    max-width: calc(100vw - 48px);
}
</style>
```

- [ ] **Step 3.4: Re-run test to verify pass**

```bash
cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/BulkActionsBar.spec.ts
```

Expected: ALL PASS (existing + 2 new).

- [ ] **Step 3.5: Full Projects-related regression**

```bash
cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/ProjectsView.spec.ts app/tests/Frontend/BulkActionsBar.spec.ts
```

Expected: ALL PASS.

- [ ] **Step 3.6: vue-tsc + ESLint**

```bash
cd "c:/моя/проекты/портал crm/Документация/app" && npm run type-check && npm run lint:vue
```

Expected: 0 errors на BulkActionsBar.vue.

- [ ] **Step 3.7: Manual browser smoke**

http://127.0.0.1:8000/projects (после login). Выбрать 2+ проекта → BulkActionsBar появляется снизу → «Приостановить» → confirm → если backend вернёт skipped > 0 → должен показаться snackbar (не alert).

- [ ] **Step 3.8: Commit**

```bash
cd "c:/моя/проекты/портал crm/Документация" && git add app/resources/js/components/projects/BulkActionsBar.vue app/tests/Frontend/BulkActionsBar.spec.ts && git commit -m "$(cat <<'EOF'
fix(projects): C5 — replace window.alert() with v-snackbar in BulkActionsBar

window.alert блокирует UI thread, не accessible (a11y), breaks браузерный
automation (Playwright/Selenium). Заменено на v-snackbar (timeout 6s,
color warning, location bottom-right, кнопка «Закрыть»). Текст идентичен:
«Применено: N. Пропущено: M (конфликт с уже доставленными лидами).»

+2 Vitest specs (snackbar opens / snackbar НЕ opens at skipped=0).
window.confirm для pause/resume/archive намеренно оставлен — это
deliberate blocking прерывание для деструктивных операций (UX-pattern).

Closes audit ID C5 from docs/superpowers/specs/2026-05-15-portal-audit-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

Expected: lefthook green.

---

## Task 4: G1 — AdminPricingTiersView submit/delete error handling

**Source lines:** [AdminPricingTiersView.vue:157-174](../../../app/resources/js/views/admin/AdminPricingTiersView.vue#L157-L174):

```ts
async function submit(): Promise<void> {
    saving.value = true;
    try {
        await axios.post('/api/admin/pricing-tiers', { tiers: editor.value });
        editorOpen.value = false;
        await load();
    } finally {
        saving.value = false;
    }
}

async function confirmDelete(effectiveFrom: string): Promise<void> {
    if (!window.confirm(`Удалить запланированный набор с ${effectiveFrom}?`)) {
        return;
    }
    await axios.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
    await load();
}
```

**Files:**

- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue` (script: + errorMessage + successMessage refs, + try/catch in submit/confirmDelete; template: + v-alert + v-snackbar)
- Test: `app/tests/Frontend/AdminPricingTiersView.spec.ts` (extend)

### Steps

- [ ] **Step 4.1: Write failing tests for error display**

Edit `app/tests/Frontend/AdminPricingTiersView.spec.ts` — append at end:

```ts
describe('AdminPricingTiersView error handling (Sprint 1 G1)', () => {
    it('submit() shows errorMessage when axios.post rejects with 422', async () => {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } });
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (axios.post as any).mockRejectedValue({
            response: { status: 422, data: { message: 'Validation failed: tier 7 leads_in_tier must be null' } },
        });
        const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
        await new Promise((r) => setTimeout(r, 50));
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        await (wrapper.vm as any).submit();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        expect(vm.errorMessage).toContain('Validation failed');
        expect(vm.saving).toBe(false);
        // Dialog should remain OPEN so user can fix and retry
        expect(vm.editorOpen).toBe(true);
    });

    it('submit() shows successMessage on 200', async () => {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } });
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (axios.post as any).mockResolvedValue({ data: { effective_from: '2026-06-01' } });
        const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
        await new Promise((r) => setTimeout(r, 50));
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        await (wrapper.vm as any).submit();
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        expect(vm.successMessage).toContain('Сохранено');
        expect(vm.errorMessage).toBe(null);
        expect(vm.saving).toBe(false);
        expect(vm.editorOpen).toBe(false);
    });

    it('confirmDelete() shows errorMessage when axios.delete rejects', async () => {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } });
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (axios.delete as any).mockRejectedValue({
            response: { status: 500, data: { message: 'Database connection failed' } },
        });
        window.confirm = vi.fn(() => true);
        const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
        await new Promise((r) => setTimeout(r, 50));
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        await (wrapper.vm as any).confirmDelete('2026-06-01');
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        expect((wrapper.vm as any).errorMessage).toContain('Database connection failed');
    });

    it('confirmDelete() shows successMessage on OK', async () => {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (axios.get as any).mockResolvedValue({ data: { data: { active: mockTiers, scheduled: {} } } });
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (axios.delete as any).mockResolvedValue({ data: { ok: true } });
        window.confirm = vi.fn(() => true);
        const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
        await new Promise((r) => setTimeout(r, 50));
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        await (wrapper.vm as any).confirmDelete('2026-06-01');
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        expect((wrapper.vm as any).successMessage).toContain('Удалено');
    });
});
```

- [ ] **Step 4.2: Run test to verify fail**

```bash
cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/AdminPricingTiersView.spec.ts -t "error handling"
```

Expected: FAIL — «errorMessage is undefined», «successMessage is undefined».

- [ ] **Step 4.3: Add error/success state + try/catch to AdminPricingTiersView**

Edit `app/resources/js/views/admin/AdminPricingTiersView.vue` script section — заменить:

```ts
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import axios from 'axios';
import { extractErrorMessage } from '../../api/client';

/**
 * SaaS-admin → Тарифная сетка (Plan 4 Task 9, Sprint 1 G1 error handling).
 *
 * Backend: AdminPricingTiersController (GET/POST/DELETE).
 * Палитра Forest + JetBrains Mono для tnum-цифр.
 *
 * defineExpose ниже — для Vitest unit-тестов.
 */

interface Tier {
    tier_no: number;
    leads_in_tier: number | null;
    price_per_lead_kopecks: number;
    effective_from: string;
}
interface EditorRow {
    tier_no: number;
    leads_in_tier: number | null;
    price_rub: string;
}

const active = ref<Tier[]>([]);
const scheduled = ref<Record<string, Tier[]>>({});
const editorOpen = ref(false);
const saving = ref(false);

// Sprint 1 G1: error/success state для UI feedback.
const errorMessage = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const successToastOpen = ref(false);

const defaultEditor: EditorRow[] = [
    { tier_no: 1, leads_in_tier: 100, price_rub: '500.00' },
    { tier_no: 2, leads_in_tier: 200, price_rub: '450.00' },
    { tier_no: 3, leads_in_tier: 400, price_rub: '400.00' },
    { tier_no: 4, leads_in_tier: 800, price_rub: '350.00' },
    { tier_no: 5, leads_in_tier: 1500, price_rub: '300.00' },
    { tier_no: 6, leads_in_tier: 3000, price_rub: '270.00' },
    { tier_no: 7, leads_in_tier: null, price_rub: '250.00' },
];
const editor = ref<EditorRow[]>(JSON.parse(JSON.stringify(defaultEditor)));

const tierHeaders = [
    { title: '№', key: 'tier_no', sortable: false, width: 80 },
    { title: 'Лидов в ступени', key: 'leads_in_tier', sortable: false },
    { title: 'Цена за лид', key: 'price_rub', sortable: false },
];

const nextMonthStart = computed(() => {
    const d = new Date();
    d.setDate(1);
    d.setMonth(d.getMonth() + 1);
    return d.toISOString().slice(0, 10);
});

const hasScheduled = computed(() => Object.keys(scheduled.value).length > 0);

async function load(): Promise<void> {
    try {
        const { data } = await axios.get('/api/admin/pricing-tiers');
        active.value = data.data.active;
        scheduled.value = data.data.scheduled || {};
    } catch (err) {
        errorMessage.value = extractErrorMessage(err, 'Не удалось загрузить тарифную сетку.');
    }
}

async function submit(): Promise<void> {
    saving.value = true;
    errorMessage.value = null;
    successMessage.value = null;
    try {
        await axios.post('/api/admin/pricing-tiers', { tiers: editor.value });
        editorOpen.value = false;
        successMessage.value = `Сохранено: новая сетка вступит в силу с ${nextMonthStart.value}.`;
        successToastOpen.value = true;
        await load();
    } catch (err) {
        errorMessage.value = extractErrorMessage(err, 'Не удалось сохранить тарифную сетку.');
        // Диалог остаётся открытым — пользователь может исправить и повторить.
    } finally {
        saving.value = false;
    }
}

async function confirmDelete(effectiveFrom: string): Promise<void> {
    if (!window.confirm(`Удалить запланированный набор с ${effectiveFrom}?`)) {
        return;
    }
    errorMessage.value = null;
    successMessage.value = null;
    try {
        await axios.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
        successMessage.value = `Удалено: запланированный набор с ${effectiveFrom}.`;
        successToastOpen.value = true;
        await load();
    } catch (err) {
        errorMessage.value = extractErrorMessage(err, 'Не удалось удалить запланированный набор.');
    }
}

onMounted(load);

defineExpose({
    load,
    submit,
    confirmDelete,
    editorOpen,
    active,
    scheduled,
    editor,
    errorMessage,
    successMessage,
    successToastOpen,
    saving,
});
</script>
```

И добавить в `<template>` — перед `<v-card class="mb-6">` (после `<h1>`):

```vue
<v-alert
    v-if="errorMessage"
    type="error"
    variant="tonal"
    class="mb-4"
    density="compact"
    data-testid="pricing-error-alert"
    closable
    @click:close="errorMessage = null"
>
    {{ errorMessage }}
</v-alert>
```

И перед закрывающим `</div>` корневого `<div class="admin-pricing-tiers-view">`:

```vue
<v-snackbar
    v-model="successToastOpen"
    :timeout="4000"
    color="success"
    location="bottom right"
    data-testid="pricing-success-toast"
>
    {{ successMessage }}
</v-snackbar>
```

- [ ] **Step 4.4: Re-run test to verify pass**

```bash
cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/AdminPricingTiersView.spec.ts
```

Expected: ALL PASS (existing 5 + new 4).

- [ ] **Step 4.5: vue-tsc + ESLint**

```bash
cd "c:/моя/проекты/портал crm/Документация/app" && npm run type-check && npm run lint:vue
```

Expected: 0 errors.

- [ ] **Step 4.6: Manual browser smoke**

http://127.0.0.1:8000/admin/pricing-tiers. Открыть редактор → ввести невалидные значения (e.g. tier 7 leads_in_tier=999 — backend ждёт null) → «Сохранить» → должен показаться красный v-alert с error message. Затем исправить → «Сохранить» → snackbar success.

- [ ] **Step 4.7: Commit**

```bash
cd "c:/моя/проекты/портал crm/Документация" && git add app/resources/js/views/admin/AdminPricingTiersView.vue app/tests/Frontend/AdminPricingTiersView.spec.ts && git commit -m "$(cat <<'EOF'
fix(admin): G1 — error/success handling in AdminPricingTiersView submit/delete

axios.post/delete теперь обёрнуты в try/catch с extractErrorMessage()
хелпером из api/client.ts (same pattern as AdminSystemView.vue:32-45).
errorMessage отображается в v-alert (closable, type=error, tonal),
successMessage — в v-snackbar (color=success, 4s timeout).

На failed submit диалог остаётся открытым чтобы пользователь мог
исправить и повторить (UX-pattern). saving=false гарантированно
сбрасывается в finally.

+4 Vitest specs (submit error / submit success / delete error / delete success).
Регрессий 0.

Closes audit ID G1 from docs/superpowers/specs/2026-05-15-portal-audit-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

Expected: lefthook green.

---

## Task 5: G2 — AdminSupplierPricesView save error handling

**Source lines:** [AdminSupplierPricesView.vue:90-101](../../../app/resources/js/views/admin/AdminSupplierPricesView.vue#L90-L101):

```ts
async function save(s: SupplierRow): Promise<void> {
    saving[s.id] = true;
    try {
        await axios.patch(`/api/admin/suppliers/${s.id}`, {
            cost_rub: s.cost_rub,
            quality_score: s.quality_score,
            is_active: s.is_active,
        });
    } finally {
        saving[s.id] = false;
    }
}
```

**Files:**

- Modify: `app/resources/js/views/admin/AdminSupplierPricesView.vue` (script: + errorMessages reactive map + try/catch; template: + per-row v-alert + snackbar)
- Test: `app/tests/Frontend/AdminSupplierPricesView.spec.ts` (extend)

### Steps

- [ ] **Step 5.1: Write failing tests**

Edit `app/tests/Frontend/AdminSupplierPricesView.spec.ts` — append at end (need to read file first to know existing imports). Append:

```ts
describe('AdminSupplierPricesView error handling (Sprint 1 G2)', () => {
    it('save() shows per-row errorMessage when axios.patch rejects', async () => {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (axios.get as any).mockResolvedValue({
            data: {
                data: [
                    { id: 1, code: 'B1', name: 'Supplier 1', cost_rub: '120.00', quality_score: '8.50', is_active: true },
                ],
            },
        });
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (axios.patch as any).mockRejectedValue({
            response: { status: 422, data: { message: 'cost_rub must be non-negative' } },
        });
        const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
        await new Promise((r) => setTimeout(r, 50));
        const row = { id: 1, code: 'B1', name: 'Supplier 1', cost_rub: '-5.00', quality_score: '8.50', is_active: true };
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        await (wrapper.vm as any).save(row);
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        expect(vm.errorMessages[1]).toContain('cost_rub');
        expect(vm.saving[1]).toBe(false);
    });

    it('save() shows successMessage on 200', async () => {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (axios.get as any).mockResolvedValue({
            data: {
                data: [
                    { id: 2, code: 'B2', name: 'Supplier 2', cost_rub: '100.00', quality_score: '9.00', is_active: true },
                ],
            },
        });
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (axios.patch as any).mockResolvedValue({ data: { ok: true } });
        const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
        await new Promise((r) => setTimeout(r, 50));
        const row = { id: 2, code: 'B2', name: 'Supplier 2', cost_rub: '110.00', quality_score: '9.00', is_active: true };
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        await (wrapper.vm as any).save(row);
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        expect(vm.successToastOpen).toBe(true);
        expect(vm.successToastText).toContain('Supplier 2');
        expect(vm.errorMessages[2]).toBeUndefined();
        expect(vm.saving[2]).toBe(false);
    });

    it('save() clears previous error on successful retry', async () => {
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (axios.get as any).mockResolvedValue({
            data: { data: [{ id: 3, code: 'B3', name: 'Supplier 3', cost_rub: '100.00', quality_score: '8.00', is_active: true }] },
        });
        // First call fails
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (axios.patch as any).mockRejectedValueOnce({
            response: { status: 500, data: { message: 'transient' } },
        });
        // Second call succeeds
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (axios.patch as any).mockResolvedValueOnce({ data: { ok: true } });

        const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
        await new Promise((r) => setTimeout(r, 50));
        const row = { id: 3, code: 'B3', name: 'Supplier 3', cost_rub: '100.00', quality_score: '8.00', is_active: true };
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        await (wrapper.vm as any).save(row);
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        expect((wrapper.vm as any).errorMessages[3]).toContain('transient');
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        await (wrapper.vm as any).save(row);
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        expect((wrapper.vm as any).errorMessages[3]).toBeUndefined();
    });
});
```

If test file имеет другие imports — добавить vi.mock('axios') в top, если ещё нет (copy from AdminPricingTiersView.spec.ts pattern lines 1-12).

- [ ] **Step 5.2: Run test to verify fail**

```bash
cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/AdminSupplierPricesView.spec.ts -t "error handling"
```

Expected: FAIL — «errorMessages is undefined», «successToastOpen is undefined».

- [ ] **Step 5.3: Refactor AdminSupplierPricesView.vue script + template**

Edit `app/resources/js/views/admin/AdminSupplierPricesView.vue` script section — заменить полностью:

```ts
<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue';
import axios from 'axios';
import { extractErrorMessage } from '../../api/client';

/**
 * SaaS-admin → Цены поставщиков (Plan 4 Task 10, Sprint 1 G2 error handling).
 *
 * Backend: AdminSuppliersController (GET/PATCH).
 * Палитра Forest + JetBrains Mono для tnum-цифр.
 *
 * defineExpose ниже — для Vitest unit-тестов.
 */

interface SupplierRow {
    id: number;
    code: string;
    name: string;
    cost_rub: string;
    quality_score: string;
    is_active: boolean;
}

const suppliers = ref<SupplierRow[]>([]);
const saving = reactive<Record<number, boolean>>({});
const errorMessages = reactive<Record<number, string>>({});
const fetchError = ref<string | null>(null);
const successToastOpen = ref(false);
const successToastText = ref('');

const headers = [
    { title: 'Code', key: 'code', sortable: false, width: 80 },
    { title: 'Name', key: 'name', sortable: false },
    { title: 'Cost (₽)', key: 'cost_rub', sortable: false, width: 140 },
    { title: 'Quality', key: 'quality_score', sortable: false, width: 100 },
    { title: 'Active', key: 'is_active', sortable: false, width: 100 },
    { title: 'Действия', key: 'actions', sortable: false, width: 120 },
];

async function load(): Promise<void> {
    fetchError.value = null;
    try {
        const { data } = await axios.get('/api/admin/suppliers');
        suppliers.value = data.data;
    } catch (err) {
        fetchError.value = extractErrorMessage(err, 'Не удалось загрузить список поставщиков.');
    }
}

async function save(s: SupplierRow): Promise<void> {
    saving[s.id] = true;
    delete errorMessages[s.id]; // очистить предыдущую ошибку перед retry
    try {
        await axios.patch(`/api/admin/suppliers/${s.id}`, {
            cost_rub: s.cost_rub,
            quality_score: s.quality_score,
            is_active: s.is_active,
        });
        successToastText.value = `Сохранено: ${s.name} (${s.code}).`;
        successToastOpen.value = true;
    } catch (err) {
        errorMessages[s.id] = extractErrorMessage(err, 'Не удалось сохранить изменения.');
    } finally {
        saving[s.id] = false;
    }
}

onMounted(load);

defineExpose({ load, save, suppliers, saving, errorMessages, fetchError, successToastOpen, successToastText });
</script>
```

И заменить `<template>` целиком:

```vue
<template>
    <div class="admin-supplier-prices-view">
        <h1 class="text-h4 mb-6">Цены поставщиков (закупка)</h1>

        <v-alert
            v-if="fetchError"
            type="warning"
            variant="tonal"
            class="mb-4"
            density="compact"
            data-testid="suppliers-fetch-error"
            closable
            @click:close="fetchError = null"
        >
            {{ fetchError }}
        </v-alert>

        <v-card elevation="1">
            <v-data-table :headers="headers" :items="suppliers" density="comfortable" class="numeric-tnum">
                <template #[`item.cost_rub`]="{ item }">
                    <v-text-field
                        v-model="item.cost_rub"
                        type="number"
                        step="0.01"
                        min="0"
                        density="compact"
                        hide-details
                        variant="plain"
                        :aria-label="`Cost (₽) для ${item.name}`"
                    />
                </template>
                <template #[`item.quality_score`]="{ item }">
                    <v-text-field
                        v-model="item.quality_score"
                        type="number"
                        step="0.01"
                        min="0"
                        max="9.99"
                        density="compact"
                        hide-details
                        variant="plain"
                        :aria-label="`Quality для ${item.name}`"
                    />
                </template>
                <template #[`item.is_active`]="{ item }">
                    <v-switch
                        v-model="item.is_active"
                        hide-details
                        inset
                        density="compact"
                        :aria-label="`Active для ${item.name}`"
                    />
                </template>
                <template #[`item.actions`]="{ item }">
                    <div class="d-flex flex-column align-end ga-1">
                        <v-btn size="small" color="primary" :loading="!!saving[item.id]" @click="save(item)">
                            Сохранить
                        </v-btn>
                        <v-tooltip v-if="errorMessages[item.id]" location="left">
                            <template #activator="{ props: tooltipProps }">
                                <v-icon
                                    v-bind="tooltipProps"
                                    color="error"
                                    size="small"
                                    :data-testid="`supplier-error-${item.id}`"
                                >
                                    mdi-alert-circle
                                </v-icon>
                            </template>
                            <span>{{ errorMessages[item.id] }}</span>
                        </v-tooltip>
                    </div>
                </template>
            </v-data-table>
        </v-card>

        <v-snackbar
            v-model="successToastOpen"
            :timeout="3000"
            color="success"
            location="bottom right"
            data-testid="supplier-success-toast"
        >
            {{ successToastText }}
        </v-snackbar>
    </div>
</template>
```

Style section (lines 108-113) оставить как есть.

- [ ] **Step 5.4: Re-run test to verify pass**

```bash
cd "c:/моя/проекты/портал crm/Документация" && npx --prefix app vitest run app/tests/Frontend/AdminSupplierPricesView.spec.ts
```

Expected: ALL PASS (existing + new 3).

- [ ] **Step 5.5: vue-tsc + ESLint**

```bash
cd "c:/моя/проекты/портал crm/Документация/app" && npm run type-check && npm run lint:vue
```

Expected: 0 errors.

- [ ] **Step 5.6: Manual browser smoke**

http://127.0.0.1:8000/admin/supplier-prices. В одной строке поставить `cost_rub=-1` → «Сохранить» → 422 → ❗ красный icon с tooltip ошибки. В другой строке валидное значение → success-snackbar.

- [ ] **Step 5.7: Commit**

```bash
cd "c:/моя/проекты/портал crm/Документация" && git add app/resources/js/views/admin/AdminSupplierPricesView.vue app/tests/Frontend/AdminSupplierPricesView.spec.ts && git commit -m "$(cat <<'EOF'
fix(admin): G2 — error/success handling in AdminSupplierPricesView save

axios.patch теперь в try/catch с extractErrorMessage() helper. Per-row
ошибки — reactive errorMessages: Record<number, string> отображаются как
v-icon mdi-alert-circle с v-tooltip рядом с кнопкой «Сохранить».
Success — v-snackbar (3s timeout, color=success, bottom-right) с именем
поставщика.

Retry на той же строке очищает предыдущий error перед новым axios.patch.

load() тоже обёрнут — fetchError ref + v-alert warning сверху таблицы.

+3 Vitest specs (save error / save success / retry clears error).
Регрессий 0.

Closes audit ID G2 from docs/superpowers/specs/2026-05-15-portal-audit-design.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```

Expected: lefthook green.

---

## Final Sprint 1 Acceptance

After all 5 tasks committed atomically (5 commits), run **full regression sweep**:

- [ ] **Sprint Acceptance Step 1: Full Pest --parallel**

```bash
cd "c:/моя/проекты/портал crm/Документация/app" && composer test -- --parallel
```

Expected: **749+ passed, 3 skipped, 0 failed** (baseline 749 + possibly +1 from Task 2 single-transition test).

- [ ] **Sprint Acceptance Step 2: Full Vitest**

```bash
cd "c:/моя/проекты/портал crm/Документация/app" && npm run test:vue
```

Expected: **92+ files passed, ~775+ specs passed, 4 skipped, 0 failed** (baseline 92f/759 + ~16 new specs (3+3+2+4+3) = ~775).

- [ ] **Sprint Acceptance Step 3: vue-tsc + ESLint**

```bash
cd "c:/моя/проекты/портал crm/Документация/app" && npm run type-check && npm run lint:vue
```

Expected: 0 errors.

- [ ] **Sprint Acceptance Step 4: Vite build verification**

```bash
cd "c:/моя/проекты/портал crm/Документация/app" && npm run build
```

Expected: build succeeds, time ≤3s (baseline 2.0-2.7s).

- [ ] **Sprint Acceptance Step 5: Git log review**

```bash
cd "c:/моя/проекты/портал crm/Документация" && git log --oneline e978b33..HEAD
```

Expected: 5 atomic commits, conventional message format, each closes one audit ID (C2/C4/C5/G1/G2).

- [ ] **Sprint Acceptance Step 6: Browser smoke on actual portal (если dev-server жив + login работает)**

Сценарии:

1. `/deals` → клик Проект chip → popover открывается → выбрать → Применить → таблица фильтруется ✅
2. `/kanban` → drag карточку → Network → POST /api/deals/transition → reload → статус сохранился ✅
3. `/projects` → выбрать 2 → BulkActionsBar появляется → «Архивировать» → confirm → toast (НЕ alert) ✅
4. `/admin/pricing-tiers` → submit с невалидными → v-alert error ✅; valid → snackbar success ✅
5. `/admin/supplier-prices` → save с невалидным cost_rub → tooltip error на строке ✅; valid → snackbar ✅

- [ ] **Sprint Acceptance Step 7: НЕ push (GitHub 403 BLOCKED)**

```bash
cd "c:/моя/проекты/портал crm/Документация" && git status && git log --oneline -10
```

Verify 5 new commits on top of `e978b33`. Push'нёт пользователь сам после fix'а GitHub email-verification.

---

## Risks & Notes

- **Vuetify v-menu в jsdom тестах:** Stub применён через `stubs: { VMenu: { template: '<div>...</div>' } }` чтобы избежать teleport-проблем. Полный popover-flow проверяется только в browser smoke (Step 1.8).
- **DealsView `availableProjects` / `availableManagers` computed:** Они извлекают unique values из `dealsState` (текущего списка mock + API). Если deals пуст — popover purpose'ы будут пустые. UX-edge case покрыт «Нет проектов» / «Нет менеджеров» fallback'ом в v-list.
- **Pinia в Vitest:** Каждый test — `setActivePinia(createPinia())` в `beforeEach`. Все 5 тестовых файлов уже используют этот паттерн.
- **`extractErrorMessage` API contract:** Возвращает `string` всегда (не undefined). Берёт `error.response.data.message` либо `error.response.data.error` либо `error.message` либо fallback. См. [client.ts:47-63](../../../app/resources/js/api/client.ts#L47).
- **`vi.mock('axios')` partial mock:** Vitest mocks ALL axios methods. Если test зовёт `axios.get` без `.mockResolvedValue` — получит undefined response → могут падать. Все 5 тестовых файлов имеют `beforeEach` с разумными defaults.
- **lefthook green on each commit:** gitleaks/markdownlint/cspell/eslint-vue/pint/larastan активны на staged files. Если larastan ругается на baseline — `composer stan -- --generate-baseline=phpstan-baseline.neon`.
- **Push BLOCKED:** Все 5 коммитов остаются локально. После закрытия GitHub 403 «verify email» — пользователь делает `git push origin main`.

## Out-of-scope (defer to next sprints)

- Status chip popover (multi-status filter) — Sprint 5 P2.
- KanbanView Plan 6.5 regions field — отдельный эпик.
- Bulk-transition single endpoint optimization (сейчас reuse'им bulk endpoint c массивом из 1) — Post-MVP.
- AdminPricingTiersView v-text-field validation на клиенте (max=null для tier 7) — Sprint 5 G7.
- AdminSupplierPricesView переписать на TypeScript-импорт api/admin.ts pattern — Sprint 5 G3.

---

## Self-Review (выполнен writing-plans skill'ом перед сохранением)

**1. Spec coverage:** ✅ Все 5 P0 эпиков (C2, C4, C5, G1, G2) из spec'а имеют по 1 задаче. Sprint Schedule в spec'е → Sprint 1 описывает ровно эти 5 → совпадение.

**2. Placeholder scan:** ✅ Проверено по разделу «No Placeholders»:

- 0 «TBD»/«TODO»/«implement later» в production-коде (TODO в Vue-файлах SOURCE-коде остаются как documentation — это OK).
- 0 «Add appropriate error handling» без кода.
- Все тесты имеют actual test bodies.
- 0 «Similar to Task N» — код повторяется явно где нужно (extractErrorMessage используется в G1 и G2 — каждый раз показан полностью).

**3. Type consistency:**

- `errorMessage: Ref<string | null>` consistent в G1 и G2 (G2 использует `errorMessages: Record<number, string>` per-row, что обосновано — несколько строк могут иметь свои ошибки).
- `successToastOpen` / `successToastText` consistent через G1/G2.
- `transitionToastOpen` / `transitionToastText` уникальны для KanbanView (C4).
- `skipToastOpen` / `skipToastText` уникальны для BulkActionsBar (C5).
- `projectMenuOpen` / `managerMenuOpen` / `projectMenuDraft` / `managerMenuDraft` — Task 1 only.
- Имена функций consistent: `applyProjectFilter` / `applyManagerFilter` / `clearProjectDraft` / `clearManagerDraft` — все используются как в тестах так и в template.

Никаких inline-fixов не потребовалось.

---

## Execution Handoff

**Plan complete and saved to `docs/superpowers/plans/2026-05-15-sprint1-p0-fixes.md`. Two execution options:**

**1. Subagent-Driven (recommended)** — fresh subagent per task (5 tasks = 5 subagents), two-stage review между задачами (implementer subagent → code-quality reviewer subagent → mark complete → next). Хороший fit для атомарных независимых задач.

**2. Inline Execution** — execute tasks в этой session через `superpowers:executing-plans`, batch execution с checkpoints для review.

**Which approach?**
