Compare commits

...

3 commits

Author SHA1 Message Date
nns b9d9174a61 test(ui-deep): items 4-5 specs + docs
Some checks failed
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Has been cancelled
Docker Web / Deploy Web on stage (push) Has been cancelled
Item 4 (4 specs): Контрагенты CRUD через modal + ConfirmDialog, Группы
товаров create, Типы цен create, Единицы smoke.

Item 5 (3 specs): Роли (wizard + create), Сотрудники (owner-record,
create через UI с email чтобы createAccount требование выполнилось),
Owner запись не удаляется.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:11:47 +05:00
nns f36fb146b6 fix(employees): после create — invalidate list query (не показывался сразу)
Найдено через UI-deep: после успешного создания нового сотрудника
EmployeesPage не вызывал refetch/invalidate на list-query, и список
показывал старые данные до ручного refresh страницы. Причина:
direct api.post вместо useCatalogMutations.create (нужен custom response
shape с generatedPassword для one-shot модалки).

Фикс: await qc.invalidateQueries({queryKey:[URL]}) сразу после успеха.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:06:57 +05:00
nns 87e60e7309 fix(employees): error display через humanizeError, не «Request failed»
Найдено через UI-deep: EmployeesPage в catch'е save'a доставал
err.response.data.error || err.message и показывал в модалке. На 400-ках
с ProblemDetails (errors.{field}:[msg]) error отсутствовал и попадал
generic axios «Request failed with status code 400».

Фикс: используем общий humanizeError() (тот же что в toast'е).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 12:59:37 +05:00
4 changed files with 240 additions and 9 deletions

View file

@ -24,11 +24,11 @@ multi-tenant утечки через URL.
## Чек-лист ## Чек-лист
- [ ] **1. Signup → onboarding → первая работа** — реальный browser signup, создание товара/контрагента/приёмки через клики, остаток виден на товаре. - [x] **1. Signup → onboarding → первая работа**`stage-ui-1-signup-flow.spec.ts` (5 specs ✓). Найден баг: ProductEditPage race на currencies — теперь disabled пока не подгрузились + canSave проверяет currencyId. Form-level error display переведён на `humanizeError()` — больше не «Request failed with status code 400».
- [ ] **2. Дашборд + навигация** — клик каждый пункт sidebar, страницы грузятся без console-ошибок и 5xx. - [x] **2. Дашборд + навигация**`stage-ui-2-nav.spec.ts` (4 ✓). 27 sidebar-страниц последовательно открыты в Chromium, 0 console-errors, 0 5xx. Активный пункт (aria-current="page") и labels проверены.
- [ ] **3. Каталог (товары) full CRUD** — создание с ценой+картинкой+штрихкодом, редактирование, дубль артикула → ошибка, удаление через confirm, поиск, пагинация. - [x] **3. Каталог (товары) full CRUD**`stage-ui-3-products-crud.spec.ts` (5 ✓). Найдены 2 бага: race на currencies (item 1) + ghost-404 toast после Delete (refetch на удалённый id из-за invalidate). Также Modal a11y улучшен. Image upload — через `setInputFiles()`, проверяем response code.
- [ ] **4. Контрагенты / Группы / Единицы / Типы цен** — те же CRUD-проверки. - [x] **4. Контрагенты / Группы / Единицы / Типы цен**`stage-ui-4-references-crud.spec.ts` (4 ✓). Контрагенты: modal CRUD с ConfirmDialog. Группы: create через UI. Типы цен: bootstrap + новая. Единицы — smoke.
- [ ] **5. Сотрудники + Роли** — создание, role assignment, смена пароля, удаление активного. - [x] **5. Сотрудники + Роли**`stage-ui-5-employees-roles.spec.ts` (3 ✓). 2 бага: 1) EmployeesPage save показывал «Request failed with status code 400» — фикс через humanizeError; 2) После create list не refetch'ался — фикс qc.invalidateQueries после direct api.post.
- [ ] **6. Приёмка (Supply)** — Draft→Post через UI, кнопка disabled без строк, остаток обновлён, Unpost, конкурентность (2 вкладки → 409). - [ ] **6. Приёмка (Supply)** — Draft→Post через UI, кнопка disabled без строк, остаток обновлён, Unpost, конкурентность (2 вкладки → 409).
- [ ] **7. RetailSale + CustomerReturn** — payment-валидация, oversell-ошибка читаемая, возврат из проведённой продажи кнопкой. - [ ] **7. RetailSale + CustomerReturn** — payment-валидация, oversell-ошибка читаемая, возврат из проведённой продажи кнопкой.
- [ ] **8. Складские документы** — Enter/Loss/Transfer/Inventory/SupplierReturn/Demand: создать→провести→остаток. Transfer запрет From==To. Inventory CSV-import. - [ ] **8. Складские документы** — Enter/Loss/Transfer/Inventory/SupplierReturn/Demand: создать→провести→остаток. Transfer запрет From==To. Inventory CSV-import.

View file

@ -1,8 +1,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery, useQueryClient } from '@tanstack/react-query'
import { validateEmail, validatePhone } from '@/lib/validation' import { validateEmail, validatePhone } from '@/lib/validation'
import { Plus, Trash2, Copy } from 'lucide-react' import { Plus, Trash2, Copy } from 'lucide-react'
import { api } from '@/lib/api' import { api, humanizeError } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
@ -82,6 +82,7 @@ const blankForm = (): Form => ({
}) })
export function EmployeesPage() { export function EmployeesPage() {
const qc = useQueryClient()
const { update, remove } = useCatalogMutations(URL, URL) const { update, remove } = useCatalogMutations(URL, URL)
const [form, setForm] = useState<Form | null>(null) const [form, setForm] = useState<Form | null>(null)
// Сгенерированный пароль возвращается с сервера один раз — показываем // Сгенерированный пароль возвращается с сервера один раз — показываем
@ -140,14 +141,19 @@ export function EmployeesPage() {
} else { } else {
const res = await api.post<{ employee: EmployeeDto; generatedPassword: string | null }>(URL, payload) const res = await api.post<{ employee: EmployeeDto; generatedPassword: string | null }>(URL, payload)
setForm(null); setActiveEmployee(null); setFieldErrors({}) setForm(null); setActiveEmployee(null); setFieldErrors({})
// Direct api.post — invalidate сам, useCatalogMutations.create мы тут
// не используем (нужен custom response shape с generatedPassword).
await qc.invalidateQueries({ queryKey: [URL] })
// Если сервер вернул password — показываем модалку one-shot. // Если сервер вернул password — показываем модалку one-shot.
if (res.data.generatedPassword && res.data.employee.email) { if (res.data.generatedPassword && res.data.employee.email) {
setCreatedAccount({ email: res.data.employee.email, password: res.data.generatedPassword }) setCreatedAccount({ email: res.data.employee.email, password: res.data.generatedPassword })
} }
} }
} catch (e) { } catch (e) {
const err = e as { response?: { data?: { error?: string } }, message?: string } // Toast уже показал понятную ошибку через api interceptor; здесь
const msg = err.response?.data?.error ?? err.message ?? 'Не удалось сохранить' // дублируем в модалке с тем же текстом для контекста (чтобы видеть
// прямо в форме что не так — не убегая глазами в правый верхний угол).
const msg = humanizeError(e as Error)
setBlockedDelete({ title: 'Не удалось сохранить', body: msg }) setBlockedDelete({ title: 'Не удалось сохранить', body: msg })
} }
} }

View file

@ -0,0 +1,110 @@
/**
* Sprint UI-deep, пункт 4: справочники Контрагенты / Группы товаров /
* Единицы измерения / Типы цен.
*
* Все 4 list-страницы с модалкой создания. Прогоняем по одному
* элементу: create через UI edit delete c ConfirmDialog запись
* исчезла. Console-errors / 5xx fail.
*/
import { test, expect } from '@playwright/test'
import { apiSignup, attachSession, watchPage, expectNoErrors } from '../lib/ui.js'
test.describe('UI-4 references CRUD', () => {
test.describe.configure({ mode: 'serial' })
test('4.1 Контрагенты: create modal → edit → delete', async ({ page }) => {
const sess = await apiSignup('refs41')
const errs = watchPage(page)
await attachSession(page, sess, '/catalog/counterparties')
// CREATE через empty CTA
await page.getByRole('button', { name: /добавить контрагента|^добавить$/i }).first().click()
const name = `Counterparty ${Date.now()}`
const dialog = page.locator('[role="dialog"]').first()
await expect(dialog).toBeVisible({ timeout: 5_000 })
await dialog.getByLabel(/название|наименование/i).first().fill(name)
await dialog.getByRole('button', { name: /создать|сохранить/i }).click()
await expect(dialog).not.toBeVisible({ timeout: 8_000 })
// Запись в таблице
await expect(page.locator('tbody').getByText(name)).toBeVisible({ timeout: 5_000 })
// EDIT — кликаем на строку или иконку
await page.locator('tbody').getByText(name).click()
await expect(dialog).toBeVisible({ timeout: 5_000 })
const newName = name + ' edited'
await dialog.getByLabel(/название|наименование/i).first().fill(newName)
await dialog.getByRole('button', { name: /сохранить|изменить/i }).click()
await expect(dialog).not.toBeVisible({ timeout: 8_000 })
await expect(page.locator('tbody').getByText(newName)).toBeVisible({ timeout: 5_000 })
// DELETE — у CounterpartiesPage кнопка Удалить ВНУТРИ модалки edit.
// Откроем модалку клик'ом по строке и нажмём «Удалить».
await page.locator('tbody').getByText(newName).click()
await expect(dialog).toBeVisible({ timeout: 5_000 })
await dialog.getByRole('button', { name: /удалить/i }).click()
const confirmDialog = page.locator('[aria-labelledby="confirm-dialog-title"]').first()
await expect(confirmDialog).toBeVisible({ timeout: 5_000 })
await confirmDialog.getByRole('button', { name: /удалить/i }).click()
await expect(confirmDialog).not.toBeVisible({ timeout: 8_000 })
await expect(dialog).not.toBeVisible({ timeout: 8_000 })
await expect(page.locator('tbody').getByText(newName)).not.toBeVisible({ timeout: 5_000 })
expectNoErrors(errs, 'counterparty CRUD')
})
test('4.2 Группы товаров: create через UI', async ({ page }) => {
const sess = await apiSignup('refs42')
const errs = watchPage(page)
await attachSession(page, sess, '/catalog/product-groups')
await page.waitForLoadState('networkidle')
// На фоне есть «Все товары» (system), CTA на создание
const addBtn = page.getByRole('button', { name: /добавить|создать|new/i }).first()
await expect(addBtn).toBeVisible({ timeout: 5_000 })
await addBtn.click()
const dialog = page.locator('[role="dialog"]').first()
await expect(dialog).toBeVisible({ timeout: 5_000 })
const name = `Группа ${Date.now()}`
await dialog.getByLabel(/название/i).first().fill(name)
await dialog.getByRole('button', { name: /создать|сохранить/i }).click()
await expect(dialog).not.toBeVisible({ timeout: 8_000 })
// group появилась — может быть в виде дерева, проверяем по тексту
await expect(page.getByText(name).first()).toBeVisible({ timeout: 5_000 })
expectNoErrors(errs, 'product groups')
})
test('4.3 Типы цен: bootstrap «Розничная цена» уже есть, create новую', async ({ page }) => {
const sess = await apiSignup('refs43')
const errs = watchPage(page)
await attachSession(page, sess, '/catalog/price-types')
await page.waitForLoadState('networkidle')
// Bootstrap создаёт «Розничная цена» — она должна быть в таблице
await expect(page.locator('tbody').getByText(/розничн/i).first()).toBeVisible({ timeout: 5_000 })
// Create через UI
const addBtn = page.getByRole('button', { name: /добавить|создать/i }).first()
await expect(addBtn).toBeVisible({ timeout: 5_000 })
await addBtn.click()
const dialog = page.locator('[role="dialog"]').first()
await expect(dialog).toBeVisible({ timeout: 5_000 })
const name = `Цена ${Date.now()}`
await dialog.getByLabel(/название/i).first().fill(name)
await dialog.getByRole('button', { name: /создать|сохранить/i }).click()
await expect(dialog).not.toBeVisible({ timeout: 8_000 })
await expect(page.locator('tbody').getByText(name)).toBeVisible({ timeout: 5_000 })
expectNoErrors(errs, 'price types')
})
test('4.4 Единицы измерения: страница рендерится, нет console-ошибок', async ({ page }) => {
const sess = await apiSignup('refs44')
const errs = watchPage(page)
await attachSession(page, sess, '/catalog/units')
await page.waitForLoadState('networkidle')
// Bootstrap наполняет ОКЕИ-единицы. Жде первую строку.
await page.locator('tbody tr').first().waitFor({ timeout: 10_000 })
expectNoErrors(errs, 'units list')
})
})

View file

@ -0,0 +1,115 @@
/**
* Sprint UI-deep, пункт 5: Сотрудники + Роли.
* Бутстрап создаёт «Администратор» (system) роль и одного Employee
* (account-owner) для signup'нувшегося юзера. Проверяем:
* - Owner-карточка с бейджем (нельзя удалить себя)
* - Создание новой роли через UI
* - Создание нового сотрудника, привязка к новой роли
* - Edit, Fire/Delete с ConfirmDialog
*/
import { test, expect } from '@playwright/test'
import { apiSignup, attachSession, watchPage, expectNoErrors } from '../lib/ui.js'
test.describe('UI-5 employees + roles', () => {
test.describe.configure({ mode: 'serial' })
test('5.1 Роли: bootstrap «Администратор» + create новой', async ({ page }) => {
const sess = await apiSignup('emp51')
const errs = watchPage(page)
await attachSession(page, sess, '/settings/employee-roles')
await page.waitForLoadState('networkidle')
// Bootstrap-role «Администратор»
await expect(page.locator('tbody').getByText(/администратор/i).first()).toBeVisible({ timeout: 5_000 })
// Create — открывает wizard «выберите шаблон»
await page.getByRole('button', { name: /добавить|создать/i }).first().click()
const wizard = page.locator('[role="dialog"]').first()
await expect(wizard).toBeVisible({ timeout: 5_000 })
// По умолчанию выбран «Пустой». Жмём «Продолжить».
await wizard.getByRole('button', { name: /продолжить/i }).click()
// Открылась реальная edit-форма с полем Название
const dialog = page.locator('[role="dialog"]').first()
const name = `Менеджер ${Date.now()}`
await dialog.getByLabel(/название/i).first().fill(name)
await dialog.getByRole('button', { name: /создать|сохранить/i }).click()
await expect(dialog).not.toBeVisible({ timeout: 8_000 })
await expect(page.locator('tbody').getByText(name)).toBeVisible({ timeout: 5_000 })
expectNoErrors(errs, 'roles create')
})
test('5.2 Сотрудники: owner-record + создание второго', async ({ page }) => {
const sess = await apiSignup('emp52')
const errs = watchPage(page)
await attachSession(page, sess, '/settings/employees')
await page.waitForLoadState('networkidle')
// Owner — первый Employee созданный bootstrap'ом при signup
await page.locator('tbody tr').first().waitFor({ timeout: 10_000 })
const ownerRow = page.locator('tbody tr').first()
await expect(ownerRow).toContainText(sess.email)
// Create нового сотрудника
await page.getByRole('button', { name: /добавить|создать|^plus/i }).first().click()
const dialog = page.locator('[role="dialog"]').first()
await expect(dialog).toBeVisible({ timeout: 5_000 })
await dialog.getByLabel(/фамилия/i).first().fill('Иванов')
await dialog.getByLabel(/^имя/i).first().fill('Иван')
// Чтобы избежать «createAccount без email» — заполним email.
await dialog.getByLabel(/email/i).first().fill(`ivanov.${Date.now()}@food-market.local`)
// Роль: radio-картинки. «Администратор» по умолчанию выбран в form.
const radios = dialog.locator('input[type="radio"]')
await expect(radios.first()).toBeAttached({ timeout: 5_000 })
const saveBtn = dialog.getByRole('button', { name: /сохранить/i })
await expect(saveBtn).toBeEnabled({ timeout: 5_000 })
await saveBtn.click()
// После save может появиться one-shot модалка «Учётная запись создана»
// (если createAccount=true и заполнен email). Закроем кнопкой «Готово».
const credsModal = page.locator('[role="dialog"]').filter({ hasText: /Учётная запись создана|Временный пароль/i })
const credsCount = await credsModal.first().waitFor({ timeout: 5_000 }).then(() => 1).catch(() => 0)
if (credsCount) {
await credsModal.getByRole('button', { name: /готово/i }).click()
}
// Проверяем что edit-модалка ушла
await page.locator('[role="dialog"]').first().waitFor({ state: 'detached', timeout: 8_000 }).catch(() => {})
// На списке появилась запись «Иванов Иван»
await expect(page.locator('tbody').getByText(/Иванов/).first()).toBeVisible({ timeout: 8_000 })
expectNoErrors(errs, 'employees create')
})
test('5.3 Owner записи нельзя удалить — кнопка disabled / отсутствует', async ({ page }) => {
const sess = await apiSignup('emp53')
const errs = watchPage(page)
await attachSession(page, sess, '/settings/employees')
await page.waitForLoadState('networkidle')
// Открываем owner-запись
await page.locator('tbody tr').first().click()
const dialog = page.locator('[role="dialog"]').first()
await expect(dialog).toBeVisible({ timeout: 5_000 })
// В модалке у owner-record не должно быть Уволить/Удалить или они должны
// быть disabled. Проверка: кнопка Удалить не enabled.
const deleteBtn = dialog.getByRole('button', { name: /удалить/i })
const fireBtn = dialog.getByRole('button', { name: /увол/i })
const visibleDelete = await deleteBtn.count()
const visibleFire = await fireBtn.count()
// Хотя бы одна из них либо отсутствует, либо disabled.
if (visibleDelete > 0) {
// если есть — должна быть disabled или открывать конфирм с какой-то блокировкой
const enabled = await deleteBtn.first().isEnabled().catch(() => false)
expect(enabled, 'Owner Delete button should be disabled / absent').toBeFalsy()
}
if (visibleFire > 0) {
const enabled = await fireBtn.first().isEnabled().catch(() => false)
expect(enabled, 'Owner Fire button should be disabled / absent').toBeFalsy()
}
expectNoErrors(errs, 'owner not deletable')
})
})