fix(admin): G2 — error/success handling in AdminSupplierPricesView save

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-15 09:26:50 +03:00
parent 0047aa4ccd
commit e0bbf4d134
2 changed files with 151 additions and 10 deletions
@@ -1,6 +1,20 @@
<template>
<div class="admin-supplier-prices-view">
<h1 class="text-h4 mb-6">Цены поставщиков (закупка)</h1>
<v-alert
v-if="fetchError"
type="warning"
variant="tonal"
class="mb-4"
density="compact"
data-testid="suppliers-fetch-error"
closable
@click:close="fetchError = null"
>
{{ fetchError }}
</v-alert>
<v-card elevation="1">
<v-data-table :headers="headers" :items="suppliers" density="comfortable" class="numeric-tnum">
<template #[`item.cost_rub`]="{ item }">
@@ -38,27 +52,52 @@
/>
</template>
<template #[`item.actions`]="{ item }">
<v-btn size="small" color="primary" :loading="!!saving[item.id]" @click="save(item)">
Сохранить
</v-btn>
<div class="d-flex flex-column align-end ga-1">
<v-btn size="small" color="primary" :loading="!!saving[item.id]" @click="save(item)">
Сохранить
</v-btn>
<v-tooltip v-if="errorMessages[item.id]" location="left">
<template #activator="{ props: tooltipProps }">
<v-icon
v-bind="tooltipProps"
color="error"
size="small"
:data-testid="`supplier-error-${item.id}`"
>
mdi-alert-circle
</v-icon>
</template>
<span>{{ errorMessages[item.id] }}</span>
</v-tooltip>
</div>
</template>
</v-data-table>
</v-card>
<v-snackbar
v-model="successToastOpen"
:timeout="3000"
color="success"
location="bottom right"
data-testid="supplier-success-toast"
>
{{ successToastText }}
</v-snackbar>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive } from 'vue';
import axios from 'axios';
import { extractErrorMessage } from '../../api/client';
/**
* SaaS-admin → Цены поставщиков (Plan 4 Task 10).
* SaaS-admin → Цены поставщиков (Plan 4 Task 10, Sprint 1 G2 error handling).
*
* Backend: AdminSuppliersController (GET/PATCH).
* Палитра Forest + JetBrains Mono для tnum-цифр.
*
* defineExpose ниже — для Vitest unit-тестов (`load`/`save`/`suppliers`/
* `saving`). На прод-сборку это не влияет.
* defineExpose ниже — для Vitest unit-тестов.
*/
interface SupplierRow {
@@ -72,6 +111,10 @@ interface SupplierRow {
const suppliers = ref<SupplierRow[]>([]);
const saving = reactive<Record<number, boolean>>({});
const errorMessages = reactive<Record<number, string>>({});
const fetchError = ref<string | null>(null);
const successToastOpen = ref(false);
const successToastText = ref('');
const headers = [
{ title: 'Code', key: 'code', sortable: false, width: 80 },
@@ -83,18 +126,28 @@ const headers = [
];
async function load(): Promise<void> {
const { data } = await axios.get('/api/admin/suppliers');
suppliers.value = data.data;
fetchError.value = null;
try {
const { data } = await axios.get('/api/admin/suppliers');
suppliers.value = data.data;
} catch (err) {
fetchError.value = extractErrorMessage(err, 'Не удалось загрузить список поставщиков.');
}
}
async function save(s: SupplierRow): Promise<void> {
saving[s.id] = true;
delete errorMessages[s.id]; // очистить предыдущую ошибку перед retry
try {
await axios.patch(`/api/admin/suppliers/${s.id}`, {
cost_rub: s.cost_rub,
quality_score: s.quality_score,
is_active: s.is_active,
});
successToastText.value = `Сохранено: ${s.name} (${s.code}).`;
successToastOpen.value = true;
} catch (err) {
errorMessages[s.id] = extractErrorMessage(err, 'Не удалось сохранить изменения.');
} finally {
saving[s.id] = false;
}
@@ -102,7 +155,7 @@ async function save(s: SupplierRow): Promise<void> {
onMounted(load);
defineExpose({ load, save, suppliers, saving });
defineExpose({ load, save, suppliers, saving, errorMessages, fetchError, successToastOpen, successToastText });
</script>
<style scoped>
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import axios from 'axios';
@@ -80,3 +80,91 @@ describe('AdminSupplierPricesView', () => {
}
});
});
describe('AdminSupplierPricesView error handling (Sprint 1 G2)', () => {
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);
});
afterEach(() => {
// Cleanup mockReturnValue to prevent leak into other describe blocks (review I-1).
vi.clearAllMocks();
});
it('save() shows per-row errorMessage when axios.patch rejects', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(axios.get as any).mockResolvedValue({
data: {
data: [
{ id: 1, code: 'B1', name: 'Supplier 1', cost_rub: '120.00', quality_score: '8.50', is_active: true },
],
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(axios.patch as any).mockRejectedValue({
response: { status: 422, data: { message: 'cost_rub must be non-negative' } },
});
const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
const row = { id: 1, code: 'B1', name: 'Supplier 1', cost_rub: '-5.00', quality_score: '8.50', is_active: true };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).save(row);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.errorMessages[1]).toContain('cost_rub');
expect(vm.saving[1]).toBe(false);
});
it('save() shows successMessage on 200', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(axios.get as any).mockResolvedValue({
data: {
data: [
{ id: 2, code: 'B2', name: 'Supplier 2', cost_rub: '100.00', quality_score: '9.00', is_active: true },
],
},
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(axios.patch as any).mockResolvedValue({ data: { ok: true } });
const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
const row = { id: 2, code: 'B2', name: 'Supplier 2', cost_rub: '110.00', quality_score: '9.00', is_active: true };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).save(row);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.successToastOpen).toBe(true);
expect(vm.successToastText).toContain('Supplier 2');
expect(vm.errorMessages[2]).toBeUndefined();
expect(vm.saving[2]).toBe(false);
});
it('save() clears previous error on successful retry', async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(axios.get as any).mockResolvedValue({
data: { data: [{ id: 3, code: 'B3', name: 'Supplier 3', cost_rub: '100.00', quality_score: '8.00', is_active: true }] },
});
// First call fails
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(axios.patch as any).mockRejectedValueOnce({
response: { status: 500, data: { message: 'transient' } },
});
// Second call succeeds
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(axios.patch as any).mockResolvedValueOnce({ data: { ok: true } });
const wrapper = mount(AdminSupplierPricesView, { global: { plugins: [vuetify] } });
await new Promise((r) => setTimeout(r, 50));
const row = { id: 3, code: 'B3', name: 'Supplier 3', cost_rub: '100.00', quality_score: '8.00', is_active: true };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).save(row);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((wrapper.vm as any).errorMessages[3]).toContain('transient');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await (wrapper.vm as any).save(row);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((wrapper.vm as any).errorMessages[3]).toBeUndefined();
});
});