This commit is contained in:
Kirill
2026-05-24 17:07:46 +05:00
parent 80e3cd1b30
commit 4b89c42a72
5 changed files with 1046 additions and 5705 deletions
@@ -0,0 +1,539 @@
# Admin Test Checklist Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an admin page with a manual testing checklist where each check can be marked as passed/failed with auto-timestamp.
**Architecture:** Hardcoded checklist items in shared constants, Prisma model for results, Fastify API route (admin-only), React admin page with MUI Accordion grouped by section.
**Tech Stack:** Prisma (SQLite), Fastify, React + MUI, @tanstack/react-query, apiClient (axios)
---
### Task 1: Prisma Migration — ChecklistResult Model
**Files:**
- Modify: `server/prisma/schema.prisma` (add model at end)
- [ ] **Step 1: Add ChecklistResult model to schema**
Append to `server/prisma/schema.prisma` after the last model:
```prisma
/// Результат ручной проверки тест-чеклиста
model ChecklistResult {
id String @id @default(cuid())
itemKey String @unique
passed Boolean
checkedAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
- [ ] **Step 2: Run migration**
```bash
cd /mnt/d/my_projects/shop/server && npx prisma migrate dev --name add_checklist_result
```
Expected: Migration created and applied successfully.
- [ ] **Step 3: Commit**
```bash
git add server/prisma/schema.prisma server/prisma/migrations/
git commit -m "feat: add ChecklistResult model for manual test checklist"
```
---
### Task 2: Shared Constants — Checklist Items
**Files:**
- Create: `shared/constants/test-checklist-items.js`
- Create: `shared/constants/test-checklist-items.d.ts`
- [ ] **Step 1: Create the JS file with all checklist items**
Create `shared/constants/test-checklist-items.js`:
```js
export const TEST_CHECKLIST_ITEMS = Object.freeze([
// Авторизация
{ key: 'auth.register-email', section: 'Авторизация', action: 'Зарегистрироваться по email', expectedResult: 'Код приходит на почту, аккаунт создаётся' },
{ key: 'auth.login-password', section: 'Авторизация', action: 'Войти по паролю', expectedResult: 'Корректный пароль пускает, неправильный — ошибка' },
{ key: 'auth.oauth-vk', section: 'Авторизация', action: 'Войти через OAuth VK', expectedResult: 'Редирект на VK, callback, авторизация успешна' },
{ key: 'auth.oauth-yandex', section: 'Авторизация', action: 'Войти через OAuth Yandex', expectedResult: 'Редирект на Yandex, callback, авторизация успешна' },
{ key: 'auth.reset-password', section: 'Авторизация', action: 'Сбросить пароль', expectedResult: 'Письмо приходит, ссылка работает, пароль меняется' },
{ key: 'auth.logout', section: 'Авторизация', action: 'Выйти из аккаунта', expectedResult: 'Сессия очищается, редирект на страницу входа' },
// Каталог и товары
{ key: 'catalog.homepage', section: 'Каталог и товары', action: 'Открыть главную страницу', expectedResult: 'Слайдер грузится, товары отображаются' },
{ key: 'catalog.filters', section: 'Каталог и товары', action: 'Применить фильтры', expectedResult: 'Фильтры по категории, цене, материалам работают' },
{ key: 'catalog.product-page', section: 'Каталог и товары', action: 'Открыть страницу товара', expectedResult: 'Фото, описание, цена, кнопка "В корзину" отображаются' },
{ key: 'catalog.seo', section: 'Каталог и товары', action: 'Проверить SEO-метаданные', expectedResult: 'Title, meta, slug корректные' },
// Корзина
{ key: 'cart.add', section: 'Корзина', action: 'Добавить товар в корзину', expectedResult: 'Счётчик корзины обновляется' },
{ key: 'cart.change-qty', section: 'Корзина', action: 'Изменить количество товара', expectedResult: 'Сумма пересчитывается' },
{ key: 'cart.remove', section: 'Корзина', action: 'Удалить товар из корзины', expectedResult: 'Товар убирается, сумма пересчитывается' },
// Чекаут
{ key: 'checkout.address', section: 'Чекаут', action: 'Выбрать адрес доставки', expectedResult: 'Можно выбрать из сохранённых или добавить новый' },
{ key: 'checkout.delivery', section: 'Чекаут', action: 'Выбрать способ доставки', expectedResult: 'Почта, OZON, Яндекс, 5post — доступны' },
{ key: 'checkout.payment', section: 'Чекаут', action: 'Выбрать способ оплаты', expectedResult: 'Онлайн / при получении — доступны' },
{ key: 'checkout.comment', section: 'Чекаут', action: 'Добавить комментарий к заказу', expectedResult: 'Поле работает, текст сохраняется' },
{ key: 'checkout.create', section: 'Чекаут', action: 'Создать заказ', expectedResult: 'Заказ создаётся, статус DRAFT' },
// Оплата
{ key: 'payment.yookassa', section: 'Оплата', action: 'Оплатить через ЮKassa', expectedResult: 'Редирект на оплату, webhook обрабатывается' },
{ key: 'payment.status', section: 'Оплата', action: 'Проверить статус платежа', expectedResult: 'Статус обновляется после webhook' },
// Профиль пользователя
{ key: 'profile.avatar', section: 'Профиль пользователя', action: 'Управление аватаром', expectedResult: 'Загрузка, отображение, удаление работают' },
{ key: 'profile.settings', section: 'Профиль пользователя', action: 'Изменить настройки профиля', expectedResult: 'Email, имя, способы входа обновляются' },
{ key: 'profile.addresses', section: 'Профиль пользователя', action: 'Управление адресами', expectedResult: 'Добавление, редактирование, удаление, по умолчанию' },
{ key: 'profile.orders', section: 'Профиль пользователя', action: 'Просмотр заказов', expectedResult: 'Список, детали, статусы отображаются' },
{ key: 'profile.messages', section: 'Профиль пользователя', action: 'Сообщения по заказу', expectedResult: 'Отправка, получение, read state работают' },
{ key: 'profile.notifications', section: 'Профиль пользователя', action: 'Настройки уведомлений', expectedResult: 'Вкл/выкл каналов работают' },
{ key: 'profile.delete-account', section: 'Профиль пользователя', action: 'Удалить аккаунт', expectedResult: 'Данные удаляются' },
// Админ — Товары
{ key: 'admin-products.list', section: 'Админ — Товары', action: 'Открыть список товаров', expectedResult: 'Пагинация, поиск работают' },
{ key: 'admin-products.create', section: 'Админ — Товары', action: 'Создать товар', expectedResult: 'Все поля сохраняются, фото загружаются, публикация работает' },
{ key: 'admin-products.edit', section: 'Админ — Товары', action: 'Редактировать товар', expectedResult: 'Изменения сохраняются' },
{ key: 'admin-products.delete', section: 'Админ — Товары', action: 'Удалить товар', expectedResult: 'Товар удаляется' },
{ key: 'admin-products.images', section: 'Админ — Товары', action: 'Управление изображениями товара', expectedResult: 'Добавление, сортировка, удаление работают' },
// Админ — Категории
{ key: 'admin-categories.crud', section: 'Админ — Категории', action: 'CRUD категорий', expectedResult: 'Создание, редактирование, удаление, сортировка работают' },
// Админ — Заказы
{ key: 'admin-orders.list', section: 'Админ — Заказы', action: 'Открыть список заказов', expectedResult: 'Фильтрация по статусу, внимание отображается' },
{ key: 'admin-orders.details', section: 'Админ — Заказы', action: 'Открыть детали заказа', expectedResult: 'Состав, статус, смена статуса работают' },
{ key: 'admin-orders.messages', section: 'Админ — Заказы', action: 'Ответить на сообщение заказа', expectedResult: 'Сообщение отправляется пользователю' },
// Админ — Отзывы
{ key: 'admin-reviews.list', section: 'Админ — Отзывы', action: 'Открыть список отзывов', expectedResult: 'Фильтрация pending/approved/rejected работает' },
{ key: 'admin-reviews.moderate', section: 'Админ — Отзывы', action: 'Модерировать отзыв', expectedResult: 'Approve/reject работают' },
// Админ — Пользователи
{ key: 'admin-users.list', section: 'Админ — Пользователи', action: 'Открыть список пользователей', expectedResult: 'Email, дата регистрации отображаются' },
{ key: 'admin-users.orders', section: 'Админ — Пользователи', action: 'Просмотр заказов пользователя', expectedResult: 'Заказы пользователя отображаются' },
// Админ — Галерея
{ key: 'admin-gallery.upload', section: 'Админ — Галерея', action: 'Управление галереей', expectedResult: 'Загрузка, удаление, использование в слайдере работают' },
// Админ — Настройки
{ key: 'admin-settings.notifications', section: 'Админ — Настройки', action: 'Настройки уведомлений админа', expectedResult: 'Email, telegram настраиваются' },
// Инфо-страницы
{ key: 'info.pages', section: 'Инфо-страницы', action: 'Открыть инфо-страницы', expectedResult: 'Доставка, оплата, как заказать, статусы заказов отображаются' },
{ key: 'info.legal', section: 'Инфо-страницы', action: 'Открыть юридические страницы', expectedResult: 'Политика конфиденциальности, условия использования отображаются' },
// SSE / Realtime
{ key: 'sse.notifications', section: 'SSE / Realtime', action: 'Проверить SSE-уведомления', expectedResult: 'Уведомления приходят в реальном времени' },
])
```
- [ ] **Step 2: Create the TypeScript declaration file**
Create `shared/constants/test-checklist-items.d.ts`:
```ts
export interface TestChecklistItem {
key: string
section: string
action: string
expectedResult: string
}
export declare const TEST_CHECKLIST_ITEMS: readonly TestChecklistItem[]
```
- [ ] **Step 3: Commit**
```bash
git add shared/constants/test-checklist-items.js shared/constants/test-checklist-items.d.ts
git commit -m "feat: add test checklist items shared constants"
```
---
### Task 3: Server API Route
**Files:**
- Create: `server/src/routes/api/admin/test-checklist.js`
- Modify: `server/src/routes/api.js` (register new route)
- [ ] **Step 1: Create the API route file**
Create `server/src/routes/api/admin/test-checklist.js`:
```js
import { prisma } from '../../../lib/prisma.js'
export async function registerAdminTestChecklistRoutes(fastify) {
fastify.get('/api/admin/test-checklist', { preHandler: [fastify.verifyAdmin] }, async () => {
const results = await prisma.checklistResult.findMany()
const resultMap = {}
for (const r of results) {
resultMap[r.itemKey] = { passed: r.passed, checkedAt: r.checkedAt.toISOString() }
}
return { results: resultMap }
})
fastify.patch('/api/admin/test-checklist', { preHandler: [fastify.verifyAdmin] }, async (request) => {
const { itemKey, passed } = request.body || {}
if (!itemKey || typeof passed !== 'boolean') {
return fastify.httpErrors.badRequest('itemKey and passed (boolean) are required')
}
const result = await prisma.checklistResult.upsert({
where: { itemKey },
create: { itemKey, passed },
update: { passed, checkedAt: new Date() },
})
return { result: { itemKey: result.itemKey, passed: result.passed, checkedAt: result.checkedAt.toISOString() } }
})
fastify.post('/api/admin/test-checklist/reset', { preHandler: [fastify.verifyAdmin] }, async () => {
await prisma.checklistResult.deleteMany({})
return { ok: true }
})
}
```
- [ ] **Step 2: Register the route in api.js**
Read `server/src/routes/api.js` to find where other admin routes are registered (look for imports and calls like `registerAdminNotificationRoutes`).
Add import and registration following the existing pattern. Example:
```js
import { registerAdminTestChecklistRoutes } from './api/admin/test-checklist.js'
// ... inside the registerApiRoutes function:
await registerAdminTestChecklistRoutes(fastify)
```
- [ ] **Step 3: Commit**
```bash
git add server/src/routes/api/admin/test-checklist.js server/src/routes/api.js
git commit -m "feat: add admin test-checklist API routes"
```
---
### Task 4: Client — API Layer
**Files:**
- Create: `client/src/entities/test-checklist/api/test-checklist-api.ts`
- [ ] **Step 1: Create the API functions**
Create `client/src/entities/test-checklist/api/test-checklist-api.ts`:
```ts
import { apiClient } from '@/shared/api/client'
export interface ChecklistResultDto {
passed: boolean
checkedAt: string
}
export interface TestChecklistResponse {
results: Record<string, ChecklistResultDto>
}
export async function fetchTestChecklistResults(): Promise<TestChecklistResponse> {
const { data } = await apiClient.get<TestChecklistResponse>('admin/test-checklist')
return data
}
export async function updateTestChecklistItem(itemKey: string, passed: boolean): Promise<{ itemKey: string; passed: boolean; checkedAt: string }> {
const { data } = await apiClient.patch<{ result: { itemKey: string; passed: boolean; checkedAt: string } }>('admin/test-checklist', { itemKey, passed })
return data.result
}
export async function resetTestChecklist(): Promise<void> {
await apiClient.post('admin/test-checklist/reset')
}
```
- [ ] **Step 2: Commit**
```bash
git add client/src/entities/test-checklist/api/test-checklist-api.ts
git commit -m "feat: add test-checklist API client functions"
```
---
### Task 5: Client — Admin Test Checklist Page
**Files:**
- Create: `client/src/pages/admin-test-checklist/ui/AdminTestChecklistPage.tsx`
- Create: `client/src/pages/admin-test-checklist/index.ts`
- [ ] **Step 1: Create the page component**
Create `client/src/pages/admin-test-checklist/ui/AdminTestChecklistPage.tsx`:
```tsx
import { useMemo, useState } from 'react'
import Accordion from '@mui/material/Accordion'
import AccordionDetails from '@mui/material/AccordionDetails'
import AccordionSummary from '@mui/material/AccordionSummary'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import Checkbox from '@mui/material/Checkbox'
import CircularProgress from '@mui/material/CircularProgress'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogContentText from '@mui/material/DialogContentText'
import DialogTitle from '@mui/material/DialogTitle'
import Stack from '@mui/material/Stack'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import Typography from '@mui/material/Typography'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { TEST_CHECKLIST_ITEMS } from '@shared/constants/test-checklist-items'
import { fetchTestChecklistResults, resetTestChecklist, updateTestChecklistItem } from '@/entities/test-checklist/api/test-checklist-api'
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
export function AdminTestChecklistPage() {
const queryClient = useQueryClient()
const [confirmOpen, setConfirmOpen] = useState(false)
const [expanded, setExpanded] = useState<string | false>(false)
const { data, isLoading } = useQuery({
queryKey: ['admin', 'test-checklist'],
queryFn: fetchTestChecklistResults,
})
const updateMutation = useMutation({
mutationFn: ({ itemKey, passed }: { itemKey: string; passed: boolean }) => updateTestChecklistItem(itemKey, passed),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'test-checklist'] })
},
})
const resetMutation = useMutation({
mutationFn: resetTestChecklist,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'test-checklist'] })
setConfirmOpen(false)
},
})
const sections = useMemo(() => {
const map = new Map<string, typeof TEST_CHECKLIST_ITEMS>()
for (const item of TEST_CHECKLIST_ITEMS) {
const existing = map.get(item.section) || []
existing.push(item)
map.set(item.section, existing)
}
return Array.from(map.entries())
}, [])
const results = data?.results ?? {}
const total = TEST_CHECKLIST_ITEMS.length
const passedCount = TEST_CHECKLIST_ITEMS.filter((i) => results[i.key]?.passed).length
return (
<Box>
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 3 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
Тест-чеклист
</Typography>
<Typography variant="body2" color="text.secondary">
Пройдено: {passedCount} из {total}
</Typography>
</Box>
<Button variant="outlined" color="warning" onClick={() => setConfirmOpen(true)} disabled={resetMutation.isPending}>
Сбросить все
</Button>
</Stack>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
) : (
sections.map(([section, items]) => (
<Accordion key={section} expanded={expanded === section} onChange={(_, isExpanded) => setExpanded(isExpanded ? section : false)}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>{section}</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0 }}>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell padding="checkbox" sx={{ width: 56 }} />
<TableCell sx={{ fontWeight: 600 }}>Действие</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Ожидаемый результат</TableCell>
<TableCell sx={{ fontWeight: 600, whiteSpace: 'nowrap' }}>Дата проверки</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item) => {
const r = results[item.key]
return (
<TableRow key={item.key} hover>
<TableCell padding="checkbox">
<Checkbox
checked={r?.passed ?? false}
onChange={(_, checked) => updateMutation.mutate({ itemKey: item.key, passed: checked })}
color="success"
/>
</TableCell>
<TableCell>{item.action}</TableCell>
<TableCell>{item.expectedResult}</TableCell>
<TableCell sx={{ whiteSpace: 'nowrap', color: r ? 'text.primary' : 'text.disabled' }}>
{r ? formatDate(r.checkedAt) : '—'}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</TableContainer>
</AccordionDetails>
</Accordion>
))
)}
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}>
<DialogTitle>Сбросить все проверки?</DialogTitle>
<DialogContent>
<DialogContentText>Все отметки будут удалены. Это действие нельзя отменить.</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmOpen(false)}>Отмена</Button>
<Button color="warning" onClick={() => resetMutation.mutate()} autoFocus>
Сбросить
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
```
- [ ] **Step 2: Create the barrel export**
Create `client/src/pages/admin-test-checklist/index.ts`:
```ts
export { AdminTestChecklistPage } from './ui/AdminTestChecklistPage'
```
- [ ] **Step 3: Commit**
```bash
git add client/src/pages/admin-test-checklist/
git commit -m "feat: add admin test checklist page"
```
---
### Task 6: Client — Routing & Navigation
**Files:**
- Modify: `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx` (add nav item + route)
- [ ] **Step 1: Add nav item and route to AdminLayoutPage**
In `client/src/pages/admin-layout/ui/AdminLayoutPage.tsx`:
1. Add import for `ClipboardCheck` from `lucide-react`:
```tsx
import { Bell, ClipboardCheck, Image, LayoutGrid, ListOrdered, MessageSquare, Settings, Store, Users } from 'lucide-react'
```
2. Add import for the page:
```tsx
import { AdminTestChecklistPage } from '@/pages/admin-test-checklist'
```
3. Add nav item to `navItems` array (after настройки):
```tsx
{ to: '/admin/test-checklist', label: 'Тест-чеклист', icon: <ClipboardCheck /> },
```
4. Add route to `<Routes>` (before the catch-all `*`):
```tsx
<Route path="test-checklist" element={<AdminTestChecklistPage />} />
```
- [ ] **Step 2: Commit**
```bash
git add client/src/pages/admin-layout/ui/AdminLayoutPage.tsx
git commit -m "feat: add test-checklist to admin navigation and routing"
```
---
### Task 7: Verification
- [ ] **Step 1: Run server tests**
```bash
cd /mnt/d/my_projects/shop/server && npm test
```
Expected: All tests pass.
- [ ] **Step 2: Run client lint**
```bash
cd /mnt/d/my_projects/shop/client && npm run lint
```
Expected: No errors.
- [ ] **Step 3: Run client format check**
```bash
cd /mnt/d/my_projects/shop/client && npm run format:check
```
Expected: All files formatted correctly.
- [ ] **Step 4: Run client tests**
```bash
cd /mnt/d/my_projects/shop/client && npm test
```
Expected: All tests pass.
- [ ] **Step 5: Run client build**
```bash
cd /mnt/d/my_projects/shop/client && npm run build
```
Expected: Build succeeds with no type errors.
@@ -0,0 +1,507 @@
# Test Checklist v2 — 3-State Status + Error Comments Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add "failed with error" state to checklist items with a mandatory error comment dialog.
**Architecture:** Add `comment String?` field to `ChecklistResult` model. Update PATCH API to accept optional comment. Replace checkbox with 3-state status selector (passed/failed/not-checked). Show error comment dialog when marking as failed.
**Tech Stack:** Prisma (SQLite), Fastify, React + MUI, @tanstack/react-query, apiClient (axios)
---
### Task 1: Prisma Migration — Add comment field
**Files:**
- Modify: `server/prisma/schema.prisma`
- [ ] **Step 1: Add comment field to ChecklistResult model**
In `server/prisma/schema.prisma`, find the `ChecklistResult` model and add `comment String?` after `passed`:
```prisma
/// Результат ручной проверки тест-чеклиста
model ChecklistResult {
id String @id @default(cuid())
itemKey String @unique
passed Boolean
comment String?
checkedAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
- [ ] **Step 2: Run migration**
```bash
cd /mnt/d/my_projects/shop/server && npx prisma migrate dev --name add_checklist_comment
```
Expected: Migration created and applied successfully.
- [ ] **Step 3: Commit**
```bash
git add server/prisma/schema.prisma server/prisma/migrations/
git commit -m "feat: add comment field to ChecklistResult for error descriptions"
```
---
### Task 2: Server API — Support comment in PATCH and GET
**Files:**
- Modify: `server/src/routes/api/admin/test-checklist.js`
- [ ] **Step 1: Update PATCH to accept and store comment**
Replace the PATCH handler in `server/src/routes/api/admin/test-checklist.js`:
```js
fastify.patch('/api/admin/test-checklist', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
const { itemKey, passed, comment } = request.body || {}
if (!itemKey || typeof passed !== 'boolean') {
return reply.code(400).send({ error: 'itemKey и passed (boolean) обязательны' })
}
if (comment !== undefined && typeof comment !== 'string') {
return reply.code(400).send({ error: 'comment должен быть строкой' })
}
if (comment !== undefined && comment.length > 2000) {
return reply.code(400).send({ error: 'Комментарий слишком длинный (макс. 2000 символов)' })
}
const result = await prisma.checklistResult.upsert({
where: { itemKey },
create: { itemKey, passed, comment: passed ? null : comment || null },
update: { passed, comment: passed ? null : comment ?? undefined, checkedAt: new Date() },
})
return { result: { itemKey: result.itemKey, passed: result.passed, comment: result.comment, checkedAt: result.checkedAt.toISOString() } }
})
```
- [ ] **Step 2: Update GET to include comment in response**
Replace the GET handler's result mapping:
```js
fastify.get('/api/admin/test-checklist', { preHandler: [fastify.verifyAdmin] }, async (request, reply) => {
const results = await prisma.checklistResult.findMany()
const resultMap = {}
for (const r of results) {
resultMap[r.itemKey] = { passed: r.passed, comment: r.comment, checkedAt: r.checkedAt.toISOString() }
}
return { results: resultMap }
})
```
- [ ] **Step 3: Commit**
```bash
git add server/src/routes/api/admin/test-checklist.js
git commit -m "feat: support comment field in test-checklist API"
```
---
### Task 3: Client API Layer — Add comment to types and functions
**Files:**
- Modify: `client/src/entities/test-checklist/api/test-checklist-api.ts`
- [ ] **Step 1: Update types and update function**
Replace the entire file content:
```ts
import { apiClient } from '@/shared/api/client'
export type ChecklistResultDto = {
passed: boolean
comment: string | null
checkedAt: string
}
export type TestChecklistResponse = {
results: Record<string, ChecklistResultDto>
}
export type UpdateChecklistItemResponse = {
itemKey: string
passed: boolean
comment: string | null
checkedAt: string
}
export async function fetchTestChecklistResults(): Promise<TestChecklistResponse> {
const { data } = await apiClient.get<TestChecklistResponse>('admin/test-checklist')
return data
}
export async function updateTestChecklistItem(
itemKey: string,
passed: boolean,
comment?: string | null,
): Promise<UpdateChecklistItemResponse> {
const { data } = await apiClient.patch<{ result: UpdateChecklistItemResponse }>('admin/test-checklist', {
itemKey,
passed,
comment: passed ? null : comment ?? null,
})
return data.result
}
export async function resetTestChecklist(): Promise<void> {
await apiClient.post('admin/test-checklist/reset')
}
```
- [ ] **Step 2: Commit**
```bash
git add client/src/entities/test-checklist/api/test-checklist-api.ts
git commit -m "feat: add comment to test-checklist API client types and function"
```
---
### Task 4: Client Page — 3-state status + error comment dialog
**Files:**
- Modify: `client/src/pages/admin-test-checklist/ui/AdminTestChecklistPage.tsx`
- [ ] **Step 1: Replace the entire page component**
Replace the entire file content:
```tsx
import { useCallback, useMemo, useState } from 'react'
import Alert from '@mui/material/Alert'
import Accordion from '@mui/material/Accordion'
import AccordionDetails from '@mui/material/AccordionDetails'
import AccordionSummary from '@mui/material/AccordionSummary'
import Box from '@mui/material/Box'
import Button from '@mui/material/Button'
import CircularProgress from '@mui/material/CircularProgress'
import Dialog from '@mui/material/Dialog'
import DialogActions from '@mui/material/DialogActions'
import DialogContent from '@mui/material/DialogContent'
import DialogTitle from '@mui/material/DialogTitle'
import IconButton from '@mui/material/IconButton'
import Stack from '@mui/material/Stack'
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import TextField from '@mui/material/TextField'
import Tooltip from '@mui/material/Tooltip'
import Typography from '@mui/material/Typography'
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
import CancelIcon from '@mui/icons-material/Cancel'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { TEST_CHECKLIST_ITEMS } from '@shared/constants/test-checklist-items'
import {
fetchTestChecklistResults,
resetTestChecklist,
updateTestChecklistItem,
} from '@/entities/test-checklist/api/test-checklist-api'
type Status = 'passed' | 'failed' | 'unchecked'
function statusFromResult(r: { passed: boolean; comment: string | null } | undefined): Status {
if (!r) return 'unchecked'
return r.passed ? 'passed' : 'failed'
}
function formatDate(iso: string): string {
return new Date(iso).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
function StatusIcon({ status, onClick }: { status: Status; onClick: () => void }) {
const icons = {
passed: <CheckCircleIcon sx={{ color: 'success.main' }} />,
failed: <CancelIcon sx={{ color: 'error.main' }} />,
unchecked: <Box sx={{ width: 24, height: 24, borderRadius: '50%', border: 2, borderColor: 'action.disabled' }} />,
}
return (
<Tooltip title={status === 'passed' ? 'Пройдено' : status === 'failed' ? 'Не пройдено' : 'Не проверено'}>
<IconButton size="small" onClick={onClick} sx={{ p: 0.5 }}>
{icons[status]}
</IconButton>
</Tooltip>
)
}
export function AdminTestChecklistPage() {
const queryClient = useQueryClient()
const [confirmOpen, setConfirmOpen] = useState(false)
const [expanded, setExpanded] = useState<string | false>(false)
const [errorDialogOpen, setErrorDialogOpen] = useState(false)
const [errorItemKey, setErrorItemKey] = useState<string | null>(null)
const [errorComment, setErrorComment] = useState('')
const [errorCommentError, setErrorCommentError] = useState(false)
const { data, isLoading, isError } = useQuery({
queryKey: ['admin', 'test-checklist'],
queryFn: fetchTestChecklistResults,
})
const updateMutation = useMutation({
mutationFn: ({ itemKey, passed, comment }: { itemKey: string; passed: boolean; comment?: string | null }) =>
updateTestChecklistItem(itemKey, passed, comment),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'test-checklist'] })
},
})
const resetMutation = useMutation({
mutationFn: resetTestChecklist,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'test-checklist'] })
setConfirmOpen(false)
},
})
const sections = useMemo(() => {
const map = new Map<string, (typeof TEST_CHECKLIST_ITEMS)[number][]>()
for (const item of TEST_CHECKLIST_ITEMS) {
const existing = map.get(item.section) || []
existing.push(item)
map.set(item.section, existing)
}
return Array.from(map.entries())
}, [])
const results = data?.results ?? {}
const total = TEST_CHECKLIST_ITEMS.length
const passedCount = TEST_CHECKLIST_ITEMS.filter((i) => results[i.key]?.passed).length
const handleStatusClick = useCallback(
(itemKey: string) => {
const current = statusFromResult(results[itemKey])
if (current === 'failed') {
// Clicking on failed → reset to unchecked
updateMutation.mutate({ itemKey, passed: false, comment: null })
} else if (current === 'unchecked') {
// Clicking on unchecked → mark as passed
updateMutation.mutate({ itemKey, passed: true })
} else {
// Clicking on passed → open error dialog
setErrorItemKey(itemKey)
setErrorComment('')
setErrorCommentError(false)
setErrorDialogOpen(true)
}
},
[results, updateMutation],
)
const handleSaveError = () => {
if (!errorItemKey) return
if (!errorComment.trim()) {
setErrorCommentError(true)
return
}
updateMutation.mutate({ itemKey: errorItemKey, passed: false, comment: errorComment.trim() })
setErrorDialogOpen(false)
setErrorItemKey(null)
setErrorComment('')
}
const errorItem = errorItemKey ? TEST_CHECKLIST_ITEMS.find((i) => i.key === errorItemKey) : null
return (
<Box>
<Stack direction="row" sx={{ mb: 3, justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 700 }}>
Тест-чеклист
</Typography>
<Typography variant="body2" color="text.secondary">
Пройдено: {passedCount} из {total}
</Typography>
</Box>
<Button
variant="outlined"
color="warning"
onClick={() => setConfirmOpen(true)}
disabled={resetMutation.isPending}
>
Сбросить все
</Button>
</Stack>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
<CircularProgress />
</Box>
) : isError ? (
<Alert severity="error">Не удалось загрузить чеклист.</Alert>
) : (
sections.map(([section, items]) => (
<Accordion
key={section}
expanded={expanded === section}
onChange={(_, isExpanded) => setExpanded(isExpanded ? section : false)}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography sx={{ fontWeight: 600 }}>{section}</Typography>
</AccordionSummary>
<AccordionDetails sx={{ px: 0 }}>
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell sx={{ width: 56 }} />
<TableCell sx={{ fontWeight: 600 }}>Действие</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Ожидаемый результат</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Комментарий</TableCell>
<TableCell sx={{ fontWeight: 600, whiteSpace: 'nowrap' }}>Дата проверки</TableCell>
</TableRow>
</TableHead>
<TableBody>
{items.map((item) => {
const r = results[item.key]
const status = statusFromResult(r)
return (
<TableRow key={item.key} hover>
<TableCell>
<StatusIcon status={status} onClick={() => handleStatusClick(item.key)} />
</TableCell>
<TableCell>{item.action}</TableCell>
<TableCell>{item.expectedResult}</TableCell>
<TableCell sx={{ maxWidth: 200 }}>
{r?.comment ? (
<Tooltip title={r.comment} arrow>
<Typography
variant="body2"
color="error.main"
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
cursor: 'pointer',
}}
>
{r.comment}
</Typography>
</Tooltip>
) : (
<Typography variant="body2" color="text.disabled">
</Typography>
)}
</TableCell>
<TableCell sx={{ whiteSpace: 'nowrap', color: r ? 'text.primary' : 'text.disabled' }}>
{r ? formatDate(r.checkedAt) : '—'}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</TableContainer>
</AccordionDetails>
</Accordion>
))
)}
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}>
<DialogTitle>Сбросить все проверки?</DialogTitle>
<DialogContent>
<Typography>Все отметки будут удалены. Это действие нельзя отменить.</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmOpen(false)}>Отмена</Button>
<Button color="warning" onClick={() => resetMutation.mutate()}>
Сбросить
</Button>
</DialogActions>
</Dialog>
<Dialog open={errorDialogOpen} onClose={() => setErrorDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
Описание ошибки {errorItem?.action}
</DialogTitle>
<DialogContent>
<TextField
autoFocus
fullWidth
multiline
rows={4}
value={errorComment}
onChange={(e) => {
setErrorComment(e.target.value)
if (errorCommentError) setErrorCommentError(false)
}}
placeholder="Опишите, что пошло не так..."
error={errorCommentError}
helperText={errorCommentError ? 'Обязательное поле' : ''}
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setErrorDialogOpen(false)}>Отмена</Button>
<Button color="error" onClick={handleSaveError}>
Сохранить
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
```
- [ ] **Step 2: Commit**
```bash
git add client/src/pages/admin-test-checklist/ui/AdminTestChecklistPage.tsx
git commit -m "feat: add 3-state status and error comment dialog to test checklist"
```
---
### Task 5: Verification
- [ ] **Step 1: Run server tests**
```bash
cd /mnt/d/my_projects/shop/server && npm test
```
Expected: All tests pass (pre-existing failures unrelated).
- [ ] **Step 2: Run client lint**
```bash
cd /mnt/d/my_projects/shop/client && npm run lint
```
Expected: 0 errors.
- [ ] **Step 3: Run client format check**
```bash
cd /mnt/d/my_projects/shop/client && npm run format:check
```
Expected: All files formatted correctly.
- [ ] **Step 4: Run client build**
```bash
cd /mnt/d/my_projects/shop/client && npm run build
```
Expected: Build succeeds with no type errors.