Files
shop-server/docs/superpowers/plans/2026-05-24-test-checklist-v2.md
T
2026-05-24 17:07:46 +05:00

17 KiB

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:

/// Результат ручной проверки тест-чеклиста
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
cd /mnt/d/my_projects/shop/server && npx prisma migrate dev --name add_checklist_comment

Expected: Migration created and applied successfully.

  • Step 3: Commit
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:

  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:

  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
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:

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
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:

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
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
cd /mnt/d/my_projects/shop/server && npm test

Expected: All tests pass (pre-existing failures unrelated).

  • Step 2: Run client lint
cd /mnt/d/my_projects/shop/client && npm run lint

Expected: 0 errors.

  • Step 3: Run client format check
cd /mnt/d/my_projects/shop/client && npm run format:check

Expected: All files formatted correctly.

  • Step 4: Run client build
cd /mnt/d/my_projects/shop/client && npm run build

Expected: Build succeeds with no type errors.