Compare commits
No commits in common. "b9d9174a6197a4999e3606f9200fae43bcbeb4d2" and "64cc5b0d108c26f46ea653080f8197826d66a61a" have entirely different histories.
b9d9174a61
...
64cc5b0d10
|
|
@ -24,11 +24,11 @@ multi-tenant утечки через URL.
|
||||||
|
|
||||||
## Чек-лист
|
## Чек-лист
|
||||||
|
|
||||||
- [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».
|
- [ ] **1. Signup → onboarding → первая работа** — реальный browser signup, создание товара/контрагента/приёмки через клики, остаток виден на товаре.
|
||||||
- [x] **2. Дашборд + навигация** — `stage-ui-2-nav.spec.ts` (4 ✓). 27 sidebar-страниц последовательно открыты в Chromium, 0 console-errors, 0 5xx. Активный пункт (aria-current="page") и labels проверены.
|
- [ ] **2. Дашборд + навигация** — клик каждый пункт sidebar, страницы грузятся без console-ошибок и 5xx.
|
||||||
- [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.
|
- [ ] **3. Каталог (товары) full CRUD** — создание с ценой+картинкой+штрихкодом, редактирование, дубль артикула → ошибка, удаление через confirm, поиск, пагинация.
|
||||||
- [x] **4. Контрагенты / Группы / Единицы / Типы цен** — `stage-ui-4-references-crud.spec.ts` (4 ✓). Контрагенты: modal CRUD с ConfirmDialog. Группы: create через UI. Типы цен: bootstrap + новая. Единицы — smoke.
|
- [ ] **4. Контрагенты / Группы / Единицы / Типы цен** — те же CRUD-проверки.
|
||||||
- [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.
|
- [ ] **5. Сотрудники + Роли** — создание, role assignment, смена пароля, удаление активного.
|
||||||
- [ ] **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.
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery } 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, humanizeError } from '@/lib/api'
|
import { api } 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,7 +82,6 @@ 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)
|
||||||
// Сгенерированный пароль возвращается с сервера один раз — показываем
|
// Сгенерированный пароль возвращается с сервера один раз — показываем
|
||||||
|
|
@ -141,19 +140,14 @@ 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) {
|
||||||
// Toast уже показал понятную ошибку через api interceptor; здесь
|
const err = e as { response?: { data?: { error?: string } }, message?: string }
|
||||||
// дублируем в модалке с тем же текстом для контекста (чтобы видеть
|
const msg = err.response?.data?.error ?? err.message ?? 'Не удалось сохранить'
|
||||||
// прямо в форме что не так — не убегая глазами в правый верхний угол).
|
|
||||||
const msg = humanizeError(e as Error)
|
|
||||||
setBlockedDelete({ title: 'Не удалось сохранить', body: msg })
|
setBlockedDelete({ title: 'Не удалось сохранить', body: msg })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
/**
|
|
||||||
* 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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,115 +0,0 @@
|
||||||
/**
|
|
||||||
* 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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Loading…
Reference in a new issue