пва
This commit is contained in:
@@ -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.
|
||||
Reference in New Issue
Block a user