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>
This commit is contained in:
@@ -2,6 +2,19 @@
|
||||
<div class="admin-pricing-tiers-view">
|
||||
<h1 class="text-h4 mb-6">Тарифная сетка</h1>
|
||||
|
||||
<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>
|
||||
|
||||
<v-card class="mb-6" elevation="1">
|
||||
<v-card-title>
|
||||
Текущая активная сетка
|
||||
@@ -88,21 +101,31 @@
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-snackbar
|
||||
v-model="successToastOpen"
|
||||
:timeout="4000"
|
||||
color="success"
|
||||
location="bottom right"
|
||||
data-testid="pricing-success-toast"
|
||||
>
|
||||
{{ successMessage }}
|
||||
</v-snackbar>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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).
|
||||
* SaaS-admin → Тарифная сетка (Plan 4 Task 9, Sprint 1 G1 error handling).
|
||||
*
|
||||
* Backend: AdminPricingTiersController (GET/POST/DELETE).
|
||||
* Палитра Forest + JetBrains Mono для tnum-цифр.
|
||||
*
|
||||
* defineExpose ниже — для Vitest unit-тестов (`load`/`submit`/`confirmDelete`/
|
||||
* `editorOpen`/`active`/`scheduled`/`editor`). На прод-сборку это не влияет.
|
||||
* defineExpose ниже — для Vitest unit-тестов.
|
||||
*/
|
||||
|
||||
interface Tier {
|
||||
@@ -122,6 +145,11 @@ 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' },
|
||||
@@ -149,17 +177,28 @@ const nextMonthStart = computed(() => {
|
||||
const hasScheduled = computed(() => Object.keys(scheduled.value).length > 0);
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const { data } = await axios.get('/api/admin/pricing-tiers');
|
||||
active.value = data.data.active;
|
||||
scheduled.value = data.data.scheduled || {};
|
||||
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;
|
||||
}
|
||||
@@ -169,13 +208,33 @@ async function confirmDelete(effectiveFrom: string): Promise<void> {
|
||||
if (!window.confirm(`Удалить запланированный набор с ${effectiveFrom}?`)) {
|
||||
return;
|
||||
}
|
||||
await axios.delete(`/api/admin/pricing-tiers/scheduled/${effectiveFrom}`);
|
||||
await load();
|
||||
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 });
|
||||
defineExpose({
|
||||
load,
|
||||
submit,
|
||||
confirmDelete,
|
||||
editorOpen,
|
||||
active,
|
||||
scheduled,
|
||||
editor,
|
||||
errorMessage,
|
||||
successMessage,
|
||||
successToastOpen,
|
||||
saving,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -78,3 +78,81 @@ describe('AdminPricingTiersView', () => {
|
||||
expect(axios.delete).toHaveBeenCalledWith('/api/admin/pricing-tiers/scheduled/2026-06-01');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AdminPricingTiersView error handling (Sprint 1 G1)', () => {
|
||||
beforeEach(() => {
|
||||
// axios.isAxiosError is auto-mocked as vi.fn() by vi.mock('axios').
|
||||
// We need it to return true so extractErrorMessage() can read response.data.message.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(axios.isAxiosError as any).mockReturnValue(true);
|
||||
});
|
||||
|
||||
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));
|
||||
// Open editor first (submit is called from dialog) so we can verify it stays open on error.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(wrapper.vm as any).editorOpen = true;
|
||||
// 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('Удалено');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user