# Sprint 5C — Billing/Admin Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to
> implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Закрыть 5 P2-эпиков подсистемы Billing/Admin портального аудита — E2, E4, G3, G7, G10.

**Architecture:** Frontend Vue 3.5 + Vuetify 3.12 + TypeScript (Composition API, `<script setup>`);
backend Laravel 13. Изменения локализованы в 4 view/компонентах биллинга/админки + 1 контроллере +
1 api-модуле. БД не трогаем (schema без изменений). Decide-эпики E2/E4 разрешены заказчиком
2026-05-17: E2 → `disabled` + tooltip (паттерн 5A A1); E4 → убрать mock-баннер целиком.

**Tech Stack:** Vue 3.5, Vuetify 3.12, TypeScript, Pinia, vue-router 4, Pest 4, Vitest 4, Laravel 13.

**Порядок задач:** T1 (E2) и T2 (E4) независимы. T3→T4→T5 (G3→G7→G10) — последовательны, все три
правят `AdminPricingTiersView.vue`; T3 — фундамент (вынос API), T4 и T5 строятся поверх.

---

## Task 1: E2 — BalanceCard «Автопополнение» + «Сменить тариф» → `disabled` + tooltip

**Контекст:** `BalanceCard.vue` — три wallet-карты в `BillingView`. Кнопка «Пополнить» подвязана
(`@click="$emit('topup')"`). Кнопки «Автопополнение» (рекуррентный биллинг) и «Сменить тариф»
(self-service смена тарифа) — без обработчиков; backend-endpoint'ов под них нет, реализация вне
scope P2. Решение заказчика — `disabled` + tooltip (как 5A A1 Yandex SSO). Disabled `v-btn` не
ловит pointer-события → активатор tooltip навешивается на оборачивающий `<span>`.

**Files:**

- Modify: `app/resources/js/components/billing/BalanceCard.vue`
- Create: `app/tests/Frontend/BalanceCard.spec.ts`

- [ ] **Step 1: Написать падающий тест** — `app/tests/Frontend/BalanceCard.spec.ts`

```ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import BalanceCard from '../../resources/js/components/billing/BalanceCard.vue';

const vuetify = createVuetify();

function factory() {
    return mount(BalanceCard, {
        global: { plugins: [vuetify] },
        props: {
            walletRub: 14250,
            leadsBalance: 285,
            tariffName: 'Про',
            tariffPrice: '990.00',
            tariffFeatures: ['Webhook', 'Канбан'],
        },
    });
}

describe('BalanceCard.vue', () => {
    it('кнопка «Пополнить» активна и эмитит topup', async () => {
        const wrapper = factory();
        const btn = wrapper.findAll('button').find((b) => b.text().includes('Пополнить'));
        expect(btn).toBeDefined();
        expect(btn!.attributes('disabled')).toBeUndefined();
        await btn!.trigger('click');
        expect(wrapper.emitted('topup')).toBeTruthy();
    });

    it('кнопка «Автопополнение» disabled (E2 — нет backend)', () => {
        const wrapper = factory();
        const btn = wrapper.findAll('button').find((b) => b.text().includes('Автопополнение'));
        expect(btn).toBeDefined();
        expect(btn!.attributes('disabled')).toBeDefined();
    });

    it('кнопка «Сменить тариф» disabled (E2 — нет backend)', () => {
        const wrapper = factory();
        const btn = wrapper.findAll('button').find((b) => b.text().includes('Сменить тариф'));
        expect(btn).toBeDefined();
        expect(btn!.attributes('disabled')).toBeDefined();
    });
});
```

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

Run: `cd app && npx vitest run tests/Frontend/BalanceCard.spec.ts`
Expected: FAIL — «Автопополнение» и «Сменить тариф» сейчас не disabled.

- [ ] **Step 3: Обернуть обе кнопки в `v-tooltip` с `disabled`**

В `BalanceCard.vue` заменить кнопку «Автопополнение» (сейчас `<v-btn variant="outlined"
prepend-icon="mdi-autorenew" size="small"> Автопополнение </v-btn>`) на:

```vue
<v-tooltip text="Автопополнение будет доступно после подключения платёжного шлюза.">
    <template #activator="{ props: tipProps }">
        <span v-bind="tipProps" class="d-inline-flex">
            <v-btn variant="outlined" prepend-icon="mdi-autorenew" size="small" disabled>
                Автопополнение
            </v-btn>
        </span>
    </template>
</v-tooltip>
```

Заменить кнопку «Сменить тариф →» (сейчас `<v-btn variant="outlined" size="small"
class="mt-auto">Сменить тариф →</v-btn>`) на:

```vue
<v-tooltip text="Самостоятельная смена тарифа появится после запуска биллинга.">
    <template #activator="{ props: tipProps }">
        <span v-bind="tipProps" class="mt-auto d-inline-flex">
            <v-btn variant="outlined" size="small" disabled>Сменить тариф →</v-btn>
        </span>
    </template>
</v-tooltip>
```

Примечание: класс `mt-auto` перенесён с кнопки на `<span>`-обёртку — обёртка теперь flex-ребёнок
карты, ей нужен `mt-auto` для прижатия книзу.

- [ ] **Step 4: Прогнать тест — убедиться, что проходит**

Run: `cd app && npx vitest run tests/Frontend/BalanceCard.spec.ts`
Expected: PASS (3/3).

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

Run: `cd app && npx vue-tsc --noEmit -p tsconfig.json && npm run lint:vue`
Expected: 0 ошибок.

- [ ] **Step 6: Commit**

```bash
git add app/resources/js/components/billing/BalanceCard.vue app/tests/Frontend/BalanceCard.spec.ts
git commit -m "feat(billing): E2 — disabled+tooltip на кнопках Автопополнение/Сменить тариф"
```

---

## Task 2: E4 — убрать mock pending-баннер в BillingView + удалить mockBilling.ts

**Контекст:** `BillingView.vue` рисует `v-alert` «1 платёж в обработке» по хардкоду `MOCK_PENDING`
из `composables/mockBilling.ts`. Платёжного шлюза нет (заблокирован Б-1), `POST /api/billing/topup`
кредитует баланс мгновенно — состояния «платёж в обработке» в БД не существует и не появится до
Б-1. Хардкод-баннер с фейковым «TX-89421 ЮKassa 14:21» вводит в заблуждение в проде. Решение
заказчика — убрать баннер и файл `mockBilling.ts` целиком.

**Files:**

- Modify: `app/resources/js/views/BillingView.vue`
- Delete: `app/resources/js/composables/mockBilling.ts`
- Modify: `app/tests/Frontend/BillingView.spec.ts`

- [ ] **Step 1: Проверить, что `mockBilling` нигде больше не используется**

Run: `cd app && npx --yes grep -rn "mockBilling\|MOCK_PENDING" resources tests` (либо Grep-тул)
Expected: совпадения только в `views/BillingView.vue` и `composables/mockBilling.ts`. Если есть
другие — остановиться и эскалировать контроллеру.

- [ ] **Step 2: Написать падающий тест** — добавить в `app/tests/Frontend/BillingView.spec.ts`
внутрь `describe('BillingView.vue', ...)`:

```ts
    it('не показывает pending-баннер (E4 — mock убран)', async () => {
        const wrapper = factory();
        await flushPromises();
        expect(wrapper.text()).not.toContain('в обработке');
    });
```

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

Run: `cd app && npx vitest run tests/Frontend/BillingView.spec.ts`
Expected: FAIL — баннер «1 платёж в обработке» сейчас рендерится (`MOCK_PENDING` truthy).

- [ ] **Step 4: Убрать баннер и импорт из `BillingView.vue`**

1. Удалить строку импорта: `import { MOCK_PENDING } from '../composables/mockBilling';`
2. Удалить блок `v-alert` (`<v-alert v-if="MOCK_PENDING" ...>...</v-alert>` — целиком, ~12 строк
   внутри `<template v-else-if="wallet">` перед `<BalanceCard ...>`).
3. В doc-комментарии (строки 8–12) убрать абзац про «Pending-баннер остаётся mock (MOCK_PENDING) —
   это отдельный эпик E4». Заменить на одну строку:
   `Sprint 5C (E4): pending-баннер убран — платёжного шлюза нет (Б-1), реального состояния «платёж в обработке» в БД не существует.`

- [ ] **Step 5: Удалить файл `mockBilling.ts`**

```bash
git rm app/resources/js/composables/mockBilling.ts
```

- [ ] **Step 6: Прогнать тесты — убедиться, что проходят**

Run: `cd app && npx vitest run tests/Frontend/BillingView.spec.ts`
Expected: PASS (все, включая новый тест). `formatPlain`/`featureLabel` импорты остаются — они из
`billingFormatters`, не из `mockBilling`.

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

Run: `cd app && npx vue-tsc --noEmit -p tsconfig.json && npm run lint:vue`
Expected: 0 ошибок.

- [ ] **Step 8: Commit**

```bash
git add app/resources/js/views/BillingView.vue app/tests/Frontend/BillingView.spec.ts
git commit -m "feat(billing): E4 — убрать mock pending-баннер (нет платёжного шлюза до Б-1)"
```

---

## Task 3: G3 — AdminPricingTiersView + AdminSupplierPricesView → типизированный api/admin.ts

**Контекст:** Обе админ-вьюхи уже на `<script setup lang="ts">` с интерфейсами (находка аудита
«plain JS» устарела — Sprint 1 уже типизировал). Реальный остаток G3 — вьюхи дёргают сырой
`import axios from 'axios'` напрямую, минуя `apiClient` (без `withCredentials`/`withXSRFToken`/
CSRF — латентный баг для прода). Остальные админ-вьюхи (`AdminBillingView`) ходят через типизо-
ванный `api/admin.ts` + `apiClient`. Задача — вынести вызовы pricing-tiers/suppliers в `api/admin.ts`.

**Files:**

- Modify: `app/resources/js/api/admin.ts`
- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue`
- Modify: `app/resources/js/views/admin/AdminSupplierPricesView.vue`
- Modify: `app/tests/Frontend/AdminPricingTiersView.spec.ts`
- Modify: `app/tests/Frontend/AdminSupplierPricesView.spec.ts`

- [ ] **Step 1: Добавить функции и типы в `api/admin.ts`** (в конец файла)

```ts
// === SaaS-admin → Тарифная сетка (Plan 4 / Sprint 5C G3) ===

export interface AdminPricingTier {
    tier_no: number;
    leads_in_tier: number | null;
    price_per_lead_kopecks: number;
    effective_from: string;
}

export interface PricingTiersResponse {
    active: AdminPricingTier[];
    scheduled: Record<string, AdminPricingTier[]>;
}

export interface PricingTierEditorRow {
    tier_no: number;
    leads_in_tier: number | null;
    price_rub: string;
}

export async function getPricingTiers(): Promise<PricingTiersResponse> {
    const { data } = await apiClient.get<{ data: PricingTiersResponse }>('/api/admin/pricing-tiers');
    return { active: data.data.active, scheduled: data.data.scheduled ?? {} };
}

export async function createPricingTiers(tiers: PricingTierEditorRow[]): Promise<{ effective_from: string }> {
    await ensureCsrfCookie();
    const { data } = await apiClient.post<{ effective_from: string }>('/api/admin/pricing-tiers', { tiers });
    return data;
}

export async function deleteScheduledPricingTier(effectiveFrom: string): Promise<void> {
    await ensureCsrfCookie();
    await apiClient.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
}

// === SaaS-admin → Цены поставщиков (Plan 4 / Sprint 5C G3) ===

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

export async function getAdminSuppliers(): Promise<AdminSupplier[]> {
    const { data } = await apiClient.get<{ data: AdminSupplier[] }>('/api/admin/suppliers');
    return data.data;
}

export async function updateAdminSupplier(
    id: number,
    payload: { cost_rub: string; quality_score: string; is_active: boolean },
): Promise<AdminSupplier> {
    await ensureCsrfCookie();
    const { data } = await apiClient.patch<{ data: AdminSupplier }>(`/api/admin/suppliers/${id}`, payload);
    return data.data;
}
```

- [ ] **Step 2: Переписать `AdminPricingTiersView.vue` на api/admin**

1. Удалить `import axios from 'axios';`.
2. Добавить:
   `import { getPricingTiers, createPricingTiers, deleteScheduledPricingTier, type AdminPricingTier, type PricingTierEditorRow } from '../../api/admin';`
3. Удалить локальные интерфейсы `Tier` и `EditorRow`; заменить их использования на `AdminPricingTier`
   и `PricingTierEditorRow` соответственно (`active: ref<AdminPricingTier[]>([])`,
   `scheduled: ref<Record<string, AdminPricingTier[]>>({})`, `editor: ref<PricingTierEditorRow[]>(...)`,
   `defaultEditor: PricingTierEditorRow[]`).
4. `load()` — заменить тело:

   ```ts
   const data = await getPricingTiers();
   active.value = data.active;
   scheduled.value = data.scheduled;
   ```

5. `submit()` — заменить `await axios.post('/api/admin/pricing-tiers', { tiers: editor.value });` на
   `await createPricingTiers(editor.value);`.
6. `confirmDelete()` — заменить `await axios.delete(\`/api/admin/pricing-tiers/scheduled/${effectiveFrom}\`);`
   на `await deleteScheduledPricingTier(effectiveFrom);`.
7. `extractErrorMessage` остаётся (импорт из `../../api/client`).

- [ ] **Step 3: Переписать `AdminSupplierPricesView.vue` на api/admin**

1. Удалить `import axios from 'axios';`.
2. Добавить: `import { getAdminSuppliers, updateAdminSupplier, type AdminSupplier } from '../../api/admin';`
3. Удалить локальный интерфейс `SupplierRow`; заменить использования на `AdminSupplier`
   (`suppliers: ref<AdminSupplier[]>([])`, параметр `save(s: AdminSupplier)`).
4. `load()` — заменить тело: `suppliers.value = await getAdminSuppliers();`.
5. `save()` — заменить `axios.patch(...)` на:
   `await updateAdminSupplier(s.id, { cost_rub: s.cost_rub, quality_score: s.quality_score, is_active: s.is_active });`

- [ ] **Step 4: Переписать `AdminPricingTiersView.spec.ts` на мок api/admin**

Эталон паттерна — `app/tests/Frontend/AdminBillingViewActions.spec.ts`. Ключевые правки:

1. Убрать `import axios from 'axios';` и `vi.mock('axios');`.
2. Добавить partial-мок:

   ```ts
   vi.mock('../../resources/js/api/admin', async (importOriginal) => {
       const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
       return { ...orig, getPricingTiers: vi.fn(), createPricingTiers: vi.fn(), deleteScheduledPricingTier: vi.fn() };
   });
   const adminApi = await import('../../resources/js/api/admin');
   ```

3. Добавить хелпер ошибки (копия из эталона):

   ```ts
   function makeAxiosError(message: string, status = 422): unknown {
       return Object.assign(new Error(message), { isAxiosError: true, response: { status, data: { message } } });
   }
   ```

4. `mockTiers` — оставить (это `AdminPricingTier[]`).
5. Первый `describe` `beforeEach`:

   ```ts
   vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} });
   vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' });
   vi.mocked(adminApi.deleteScheduledPricingTier).mockResolvedValue(undefined);
   ```

6. Тест `submits POST ...` → `expect(adminApi.createPricingTiers).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ tier_no: 7, leads_in_tier: null })]));`
7. Тест `confirmDelete triggers DELETE ...` → `expect(adminApi.deleteScheduledPricingTier).toHaveBeenCalledWith('2026-06-01');` (`window.confirm = vi.fn(() => true)` — оставить, T5 уберёт).
8. `describe` error handling — убрать `axios.isAxiosError` блок; в каждом тесте заменить
   `(axios.X as any).mockRejectedValue({response:...})` на `vi.mocked(adminApi.fn).mockRejectedValue(makeAxiosError('...', status))`,
   а `(axios.get as any).mockResolvedValue(...)` на `vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} })`.
   `afterEach(() => vi.clearAllMocks())` — оставить.

- [ ] **Step 5: Переписать `AdminSupplierPricesView.spec.ts` на мок api/admin**

Аналогично Step 4:

1. Убрать axios; `vi.mock('../../resources/js/api/admin', ...)` с `getAdminSuppliers`/`updateAdminSupplier` как `vi.fn()`.
2. `makeAxiosError` хелпер.
3. `beforeEach`: `vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue(mockSuppliers);`
   `vi.mocked(adminApi.updateAdminSupplier).mockResolvedValue(mockSuppliers[0]);`
4. Тест `save() fires PATCH ...` → `expect(adminApi.updateAdminSupplier).toHaveBeenCalledWith(1, { cost_rub: '2.00', quality_score: '1.00', is_active: true });`
5. Error-тесты → `mockRejectedValue(makeAxiosError(...))`; `load() ... rejects` → `vi.mocked(adminApi.getAdminSuppliers).mockRejectedValue(makeAxiosError('Database connection lost', 500))`.

- [ ] **Step 6: Прогнать оба spec-файла**

Run: `cd app && npx vitest run tests/Frontend/AdminPricingTiersView.spec.ts tests/Frontend/AdminSupplierPricesView.spec.ts`
Expected: PASS (все тесты обоих файлов).

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

Run: `cd app && npx vue-tsc --noEmit -p tsconfig.json && npm run lint:vue`
Expected: 0 ошибок (в т.ч. `import axios` удалён из обеих вьюх).

- [ ] **Step 8: Commit**

```bash
git add app/resources/js/api/admin.ts app/resources/js/views/admin/AdminPricingTiersView.vue \
  app/resources/js/views/admin/AdminSupplierPricesView.vue \
  app/tests/Frontend/AdminPricingTiersView.spec.ts app/tests/Frontend/AdminSupplierPricesView.spec.ts
git commit -m "refactor(admin): G3 — pricing-tiers/suppliers вьюхи на типизированный api/admin.ts"
```

---

## Task 4: G7 — AdminPricingTiers effective_from через date-picker

**Контекст:** Сейчас `effective_from` новой сетки жёстко = 1-е число следующего месяца (МСК):
backend `AdminPricingTiersController@store:92` хардкодит `startOfMonth()->addMonth()`, frontend
показывает `nextMonthStart` в кнопке и заголовке диалога. G7 — дать админу выбрать дату.

**Files:**

- Modify: `app/app/Http/Controllers/Api/AdminPricingTiersController.php`
- Modify: `app/resources/js/api/admin.ts`
- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue`
- Modify: `app/tests/Feature/Admin/AdminPricingTiersControllerTest.php`
- Modify: `app/tests/Frontend/AdminPricingTiersView.spec.ts`

- [ ] **Step 1: Написать падающий Pest-тест** — добавить в `AdminPricingTiersControllerTest.php`

```php
it('store accepts a custom effective_from date', function (): void {
    $custom = \Illuminate\Support\Carbon::now('Europe/Moscow')->addMonths(3)->toDateString();

    $response = $this->postJson('/api/admin/pricing-tiers', [
        'tiers' => validTiersPayload(),
        'effective_from' => $custom,
    ]);

    $response->assertCreated()->assertJson(['effective_from' => $custom]);
    expect(\App\Models\PricingTier::where('effective_from', $custom)->count())->toBe(7);
});

it('store rejects effective_from in the past', function (): void {
    $past = \Illuminate\Support\Carbon::now('Europe/Moscow')->subDay()->toDateString();

    $this->postJson('/api/admin/pricing-tiers', [
        'tiers' => validTiersPayload(),
        'effective_from' => $past,
    ])->assertStatus(422);
});
```

Примечание: если в файле нет хелпера `validTiersPayload()` — переиспользовать массив тиров из
существующего теста store (7 строк `tier_no`/`leads_in_tier`/`price_rub`); вынести в локальную
функцию-хелпер в начале файла либо инлайнить массив в обоих новых тестах.

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

Run: `cd app && php artisan test --filter=AdminPricingTiersControllerTest`
Expected: FAIL — `effective_from` сейчас игнорируется (первый тест: дата = next-month, не custom;
второй: 201 вместо 422).

- [ ] **Step 3: Backend — принять `effective_from` в `store()`**

В `AdminPricingTiersController@store`:

1. Перед `$request->validate([...])` вычислить `$todayMsk = Carbon::now('Europe/Moscow')->toDateString();`
2. В массив правил добавить:
   `'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after:'.$todayMsk],`
3. Заменить строку `$effectiveFrom = Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();` на:

   ```php
   $effectiveFrom = $request->input('effective_from')
       ?? Carbon::now('Europe/Moscow')->startOfMonth()->addMonth()->toDateString();
   ```

   (`$todayMsk` из шага 1 переиспользуется правилом валидации; вычислять до `validate`.)

- [ ] **Step 4: Прогнать Pest — убедиться, что проходит**

Run: `cd app && php artisan test --filter=AdminPricingTiersControllerTest`
Expected: PASS (старые тесты store без `effective_from` → дефолт next-month; 2 новых → custom/422).

- [ ] **Step 5: api/admin.ts — `createPricingTiers` принимает `effectiveFrom`**

Изменить сигнатуру (расширение T3-функции):

```ts
export async function createPricingTiers(
    tiers: PricingTierEditorRow[],
    effectiveFrom?: string,
): Promise<{ effective_from: string }> {
    await ensureCsrfCookie();
    const payload: { tiers: PricingTierEditorRow[]; effective_from?: string } = { tiers };
    if (effectiveFrom) payload.effective_from = effectiveFrom;
    const { data } = await apiClient.post<{ effective_from: string }>('/api/admin/pricing-tiers', payload);
    return data;
}
```

- [ ] **Step 6: Frontend — date-picker в редакторе сетки**

В `AdminPricingTiersView.vue`:

1. Добавить ref после `nextMonthStart` computed:
   `const effectiveFrom = ref<string>(nextMonthStart.value);`
2. Добавить computed для `min` (завтра):

   ```ts
   const minEffectiveFrom = computed(() => {
       const d = new Date();
       d.setDate(d.getDate() + 1);
       return d.toISOString().slice(0, 10);
   });
   ```

3. В диалоге-редакторе перед `<table class="editor-table">` добавить поле:

   ```vue
   <v-text-field
       v-model="effectiveFrom"
       type="date"
       label="Дата вступления в силу"
       :min="minEffectiveFrom"
       density="compact"
       class="mb-3"
       style="max-width: 240px"
       data-testid="effective-from-input"
   />
   ```

4. Заголовок диалога: `Новая сетка (effective_from = {{ effectiveFrom }})` (вместо `nextMonthStart`).
   Кнопку открытия редактора `Редактировать сетку (с {{ nextMonthStart }})` — оставить
   `nextMonthStart` (это дефолтная подсказка до открытия диалога).
5. `submit()` — передать дату: `await createPricingTiers(editor.value, effectiveFrom.value);`.
6. `successMessage` в `submit()` — использовать `effectiveFrom.value` вместо `nextMonthStart.value`.
7. `defineExpose` — добавить `effectiveFrom`.

- [ ] **Step 7: Написать Vitest для date-picker** — добавить в первый `describe` `AdminPricingTiersView.spec.ts`:

```ts
    it('редактор содержит поле даты effective_from с дефолтом = след. месяц', async () => {
        const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
        await new Promise((r) => setTimeout(r, 50));
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (wrapper.vm as any).editorOpen = true;
        await wrapper.vm.$nextTick();
        expect(wrapper.find('[data-testid="effective-from-input"]').exists()).toBe(true);
    });

    it('submit передаёт выбранную effective_from в createPricingTiers', async () => {
        const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
        await new Promise((r) => setTimeout(r, 50));
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        (wrapper.vm as any).effectiveFrom = '2026-09-01';
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        await (wrapper.vm as any).submit();
        expect(adminApi.createPricingTiers).toHaveBeenCalledWith(expect.any(Array), '2026-09-01');
    });
```

- [ ] **Step 8: Прогнать FE-тест**

Run: `cd app && npx vitest run tests/Frontend/AdminPricingTiersView.spec.ts`
Expected: PASS.

- [ ] **Step 9: Lint + type-check + Pint**

Run: `cd app && npx vue-tsc --noEmit -p tsconfig.json && npm run lint:vue && composer pint -- --dirty`
Expected: 0 ошибок.

- [ ] **Step 10: Commit**

```bash
git add app/app/Http/Controllers/Api/AdminPricingTiersController.php app/resources/js/api/admin.ts \
  app/resources/js/views/admin/AdminPricingTiersView.vue \
  app/tests/Feature/Admin/AdminPricingTiersControllerTest.php app/tests/Frontend/AdminPricingTiersView.spec.ts
git commit -m "feat(admin): G7 — выбор effective_from тарифной сетки через date-picker"
```

---

## Task 5: G10 — AdminPricingTiers `window.confirm` → `v-dialog`

**Контекст:** `AdminPricingTiersView@confirmDelete` использует браузерный `window.confirm()` для
подтверждения удаления запланированного набора тиров. Браузерный `confirm` блокирует UI и не
доступен ассистивным технологиям — заменить на `v-dialog`. (Аудит назвал эпик «AdminBilling
confirm()», но в `AdminBillingView` `confirm()` уже нет — Sprint 3D G4 заменил row-actions на
`v-dialog`'и; фактический оставшийся браузерный confirm в админ-биллинге — здесь, в pricing-tiers.)

**Files:**

- Modify: `app/resources/js/views/admin/AdminPricingTiersView.vue`
- Modify: `app/tests/Frontend/AdminPricingTiersView.spec.ts`

- [ ] **Step 1: Написать падающие тесты** — заменить в первом `describe` тест
`confirmDelete triggers DELETE to /scheduled/{date}` на два теста:

```ts
    it('confirmDelete открывает диалог подтверждения, DELETE не вызывается сразу', async () => {
        const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
        await new Promise((r) => setTimeout(r, 50));
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        vm.confirmDelete('2026-06-01');
        expect(vm.deleteDialogOpen).toBe(true);
        expect(vm.deleteTarget).toBe('2026-06-01');
        expect(adminApi.deleteScheduledPricingTier).not.toHaveBeenCalled();
    });

    it('performDelete вызывает deleteScheduledPricingTier для выбранной даты', async () => {
        const wrapper = mount(AdminPricingTiersView, { global: { plugins: [vuetify] } });
        await new Promise((r) => setTimeout(r, 50));
        // eslint-disable-next-line @typescript-eslint/no-explicit-any
        const vm = wrapper.vm as any;
        vm.confirmDelete('2026-06-01');
        await vm.performDelete();
        expect(adminApi.deleteScheduledPricingTier).toHaveBeenCalledWith('2026-06-01');
        expect(vm.deleteDialogOpen).toBe(false);
    });
```

В error-handling `describe` тест `confirmDelete() shows errorMessage when ... rejects` —
переименовать вызов: после `vm.confirmDelete('2026-06-01')` вызывать `await vm.performDelete()`
(ошибку проверять после `performDelete`). Убрать `window.confirm = vi.fn(() => true)` из всех
тестов этого файла (больше не нужен).

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

Run: `cd app && npx vitest run tests/Frontend/AdminPricingTiersView.spec.ts`
Expected: FAIL — `deleteDialogOpen`/`deleteTarget`/`performDelete` ещё не существуют.

- [ ] **Step 3: Заменить `window.confirm` на `v-dialog`-flow**

В `AdminPricingTiersView.vue`:

1. Добавить state: `const deleteDialogOpen = ref(false);` и `const deleteTarget = ref<string | null>(null);`
2. Заменить функцию `confirmDelete` — теперь только открывает диалог:

   ```ts
   function confirmDelete(effectiveFrom: string): void {
       deleteTarget.value = effectiveFrom;
       deleteDialogOpen.value = true;
   }
   ```

3. Добавить `performDelete` — фактическое удаление (тело — бывший `confirmDelete` без `window.confirm`):

   ```ts
   async function performDelete(): Promise<void> {
       const effectiveFrom = deleteTarget.value;
       if (effectiveFrom === null) return;
       deleteDialogOpen.value = false;
       errorMessage.value = null;
       successMessage.value = null;
       try {
           await deleteScheduledPricingTier(effectiveFrom);
           successMessage.value = `Удалено: запланированный набор с ${effectiveFrom}.`;
           successToastOpen.value = true;
           await load();
       } catch (err) {
           errorMessage.value = extractErrorMessage(err, 'Не удалось удалить запланированный набор.');
       } finally {
           deleteTarget.value = null;
       }
   }
   ```

4. В `<template>` после диалога-редактора добавить confirm-диалог:

   ```vue
   <v-dialog v-model="deleteDialogOpen" max-width="440">
       <v-card>
           <v-card-title>Удалить запланированный набор?</v-card-title>
           <v-card-text>
               Запланированная сетка с <strong>{{ deleteTarget }}</strong> будет удалена.
               Действие необратимо.
           </v-card-text>
           <v-card-actions>
               <v-spacer />
               <v-btn @click="deleteDialogOpen = false">Отмена</v-btn>
               <v-btn color="error" data-testid="confirm-delete-btn" @click="performDelete">Удалить</v-btn>
           </v-card-actions>
       </v-card>
   </v-dialog>
   ```

5. `defineExpose` — добавить `deleteDialogOpen`, `deleteTarget`, `performDelete`.

- [ ] **Step 4: Прогнать FE-тест — убедиться, что проходит**

Run: `cd app && npx vitest run tests/Frontend/AdminPricingTiersView.spec.ts`
Expected: PASS.

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

Run: `cd app && npx vue-tsc --noEmit -p tsconfig.json && npm run lint:vue`
Expected: 0 ошибок (в т.ч. `window.confirm` удалён из вьюхи).

- [ ] **Step 6: Commit**

```bash
git add app/resources/js/views/admin/AdminPricingTiersView.vue app/tests/Frontend/AdminPricingTiersView.spec.ts
git commit -m "feat(admin): G10 — браузерный confirm() удаления сетки → v-dialog"
```

---

## Финал

После всех 5 задач — финальный holistic review всей реализации, затем полная регрессия
(`/regression full`: Pest --parallel, Vitest, Vite build, vue-tsc, ESLint, Pint, Larastan,
markdownlint, cspell, lychee, gitleaks) и `superpowers:finishing-a-development-branch`.

**Ожидаемые изменения относительно базы `345d14d`:** 5 feat/refactor-коммитов + этот plan-коммит.
Файлы: `BalanceCard.vue`, `BillingView.vue`, `mockBilling.ts` (удалён), `api/admin.ts`,
`AdminPricingTiersView.vue`, `AdminSupplierPricesView.vue`, `AdminPricingTiersController.php`,

- 5 spec-файлов (1 новый `BalanceCard.spec.ts`). БД/schema — без изменений.
