Files
portal/docs/superpowers/plans/2026-05-17-sprint5c-billing-admin.md
T
Дмитрий 8e8f06ebe7 chore: working tree cleanup pre-llm-first-router merge
Три группы накопившихся auto-правок (НЕ ручные):

1. markdownlint --fix auto-format (~25 .md в docs/superpowers/, docs/security/marketing-vet.md, docs/adr/015, docs/deploy/lkomega-runbook): MD031/MD032 (blank lines around fence/list) + MD004 (bullet markers `+`→`-`). Содержательных текстовых правок 3: ADR-015 bullet, sprint5d-cleanup bullet, router-discipline trailing space.

2. lefthook 2.1.6 → 2.1.8 (package.json + lock): patch-bump, авто-резолвил npm.

3. Observer runtime (docs/observer/): episodes-2026-05.jsonl +420 строк (текущая активность мозга), STATUS.md regen, .pii-counters / .read-counter тики, +2026-05-24-brain-retro.md note.

Цель — разблокировать merge feat/llm-first-router → main (этап 0 плана постановки в боевой). Содержание ветки не трогает.
2026-05-25 14:23:11 +03:00

33 KiB
Raw Blame History

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

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>) на:

<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>) на:

<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
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', ...):
    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
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
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 (в конец файла)

// === 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() — заменить тело:

    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-мок:

    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. Добавить хелпер ошибки (копия из эталона):

    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:

    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() ... rejectsvi.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
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

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(); на:

    $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-функции):

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 (завтра):

    const minEffectiveFrom = computed(() => {
        const d = new Date();
        d.setDate(d.getDate() + 1);
        return d.toISOString().slice(0, 10);
    });
    
  3. В диалоге-редакторе перед <table class="editor-table"> добавить поле:

    <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:
    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
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.confirmv-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} на два теста:

    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 — теперь только открывает диалог:

    function confirmDelete(effectiveFrom: string): void {
        deleteTarget.value = effectiveFrom;
        deleteDialogOpen.value = true;
    }
    
  3. Добавить performDelete — фактическое удаление (тело — бывший confirmDelete без window.confirm):

    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-диалог:

    <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
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 — без изменений.