Три группы накопившихся 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 плана постановки в боевой). Содержание ветки не трогает.
33 KiB
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
- Удалить строку импорта:
import { MOCK_PENDING } from '../composables/mockBilling'; - Удалить блок
v-alert(<v-alert v-if="MOCK_PENDING" ...>...</v-alert>— целиком, ~12 строк внутри<template v-else-if="wallet">перед<BalanceCard ...>). - В 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
-
Удалить
import axios from 'axios';. -
Добавить:
import { getPricingTiers, createPricingTiers, deleteScheduledPricingTier, type AdminPricingTier, type PricingTierEditorRow } from '../../api/admin'; -
Удалить локальные интерфейсы
TierиEditorRow; заменить их использования наAdminPricingTierиPricingTierEditorRowсоответственно (active: ref<AdminPricingTier[]>([]),scheduled: ref<Record<string, AdminPricingTier[]>>({}),editor: ref<PricingTierEditorRow[]>(...),defaultEditor: PricingTierEditorRow[]). -
load()— заменить тело:const data = await getPricingTiers(); active.value = data.active; scheduled.value = data.scheduled; -
submit()— заменитьawait axios.post('/api/admin/pricing-tiers', { tiers: editor.value });наawait createPricingTiers(editor.value);. -
confirmDelete()— заменитьawait axios.delete(\/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);наawait deleteScheduledPricingTier(effectiveFrom);`. -
extractErrorMessageостаётся (импорт из../../api/client).
- Step 3: Переписать
AdminSupplierPricesView.vueна api/admin
- Удалить
import axios from 'axios';. - Добавить:
import { getAdminSuppliers, updateAdminSupplier, type AdminSupplier } from '../../api/admin'; - Удалить локальный интерфейс
SupplierRow; заменить использования наAdminSupplier(suppliers: ref<AdminSupplier[]>([]), параметрsave(s: AdminSupplier)). load()— заменить тело:suppliers.value = await getAdminSuppliers();.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. Ключевые правки:
-
Убрать
import axios from 'axios';иvi.mock('axios');. -
Добавить 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'); -
Добавить хелпер ошибки (копия из эталона):
function makeAxiosError(message: string, status = 422): unknown { return Object.assign(new Error(message), { isAxiosError: true, response: { status, data: { message } } }); } -
mockTiers— оставить (этоAdminPricingTier[]). -
Первый
describebeforeEach:vi.mocked(adminApi.getPricingTiers).mockResolvedValue({ active: mockTiers, scheduled: {} }); vi.mocked(adminApi.createPricingTiers).mockResolvedValue({ effective_from: '2026-06-01' }); vi.mocked(adminApi.deleteScheduledPricingTier).mockResolvedValue(undefined); -
Тест
submits POST ...→expect(adminApi.createPricingTiers).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ tier_no: 7, leads_in_tier: null })])); -
Тест
confirmDelete triggers DELETE ...→expect(adminApi.deleteScheduledPricingTier).toHaveBeenCalledWith('2026-06-01');(window.confirm = vi.fn(() => true)— оставить, T5 уберёт). -
describeerror 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:
- Убрать axios;
vi.mock('../../resources/js/api/admin', ...)сgetAdminSuppliers/updateAdminSupplierкакvi.fn(). makeAxiosErrorхелпер.beforeEach:vi.mocked(adminApi.getAdminSuppliers).mockResolvedValue(mockSuppliers);vi.mocked(adminApi.updateAdminSupplier).mockResolvedValue(mockSuppliers[0]);- Тест
save() fires PATCH ...→expect(adminApi.updateAdminSupplier).toHaveBeenCalledWith(1, { cost_rub: '2.00', quality_score: '1.00', is_active: true }); - 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
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:
-
Перед
$request->validate([...])вычислить$todayMsk = Carbon::now('Europe/Moscow')->toDateString(); -
В массив правил добавить:
'effective_from' => ['sometimes', 'date_format:Y-m-d', 'after:'.$todayMsk], -
Заменить строку
$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:
-
Добавить ref после
nextMonthStartcomputed:const effectiveFrom = ref<string>(nextMonthStart.value); -
Добавить computed для
min(завтра):const minEffectiveFrom = computed(() => { const d = new Date(); d.setDate(d.getDate() + 1); return d.toISOString().slice(0, 10); }); -
В диалоге-редакторе перед
<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" /> -
Заголовок диалога:
Новая сетка (effective_from = {{ effectiveFrom }})(вместоnextMonthStart). Кнопку открытия редактораРедактировать сетку (с {{ nextMonthStart }})— оставитьnextMonthStart(это дефолтная подсказка до открытия диалога). -
submit()— передать дату:await createPricingTiers(editor.value, effectiveFrom.value);. -
successMessageвsubmit()— использоватьeffectiveFrom.valueвместоnextMonthStart.value. -
defineExpose— добавитьeffectiveFrom.
- Step 7: Написать Vitest для date-picker — добавить в первый
describeAdminPricingTiersView.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.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}на два теста:
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:
-
Добавить state:
const deleteDialogOpen = ref(false);иconst deleteTarget = ref<string | null>(null); -
Заменить функцию
confirmDelete— теперь только открывает диалог:function confirmDelete(effectiveFrom: string): void { deleteTarget.value = effectiveFrom; deleteDialogOpen.value = true; } -
Добавить
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; } } -
В
<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> -
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 — без изменений.