feat: add 3-state status and error comment dialog to test checklist
This commit is contained in:
@@ -1,17 +1,16 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
import Alert from '@mui/material/Alert'
|
import Alert from '@mui/material/Alert'
|
||||||
import Accordion from '@mui/material/Accordion'
|
import Accordion from '@mui/material/Accordion'
|
||||||
import AccordionDetails from '@mui/material/AccordionDetails'
|
import AccordionDetails from '@mui/material/AccordionDetails'
|
||||||
import AccordionSummary from '@mui/material/AccordionSummary'
|
import AccordionSummary from '@mui/material/AccordionSummary'
|
||||||
import Box from '@mui/material/Box'
|
import Box from '@mui/material/Box'
|
||||||
import Button from '@mui/material/Button'
|
import Button from '@mui/material/Button'
|
||||||
import Checkbox from '@mui/material/Checkbox'
|
|
||||||
import CircularProgress from '@mui/material/CircularProgress'
|
import CircularProgress from '@mui/material/CircularProgress'
|
||||||
import Dialog from '@mui/material/Dialog'
|
import Dialog from '@mui/material/Dialog'
|
||||||
import DialogActions from '@mui/material/DialogActions'
|
import DialogActions from '@mui/material/DialogActions'
|
||||||
import DialogContent from '@mui/material/DialogContent'
|
import DialogContent from '@mui/material/DialogContent'
|
||||||
import DialogContentText from '@mui/material/DialogContentText'
|
|
||||||
import DialogTitle from '@mui/material/DialogTitle'
|
import DialogTitle from '@mui/material/DialogTitle'
|
||||||
|
import IconButton from '@mui/material/IconButton'
|
||||||
import Stack from '@mui/material/Stack'
|
import Stack from '@mui/material/Stack'
|
||||||
import Table from '@mui/material/Table'
|
import Table from '@mui/material/Table'
|
||||||
import TableBody from '@mui/material/TableBody'
|
import TableBody from '@mui/material/TableBody'
|
||||||
@@ -19,7 +18,11 @@ import TableCell from '@mui/material/TableCell'
|
|||||||
import TableContainer from '@mui/material/TableContainer'
|
import TableContainer from '@mui/material/TableContainer'
|
||||||
import TableHead from '@mui/material/TableHead'
|
import TableHead from '@mui/material/TableHead'
|
||||||
import TableRow from '@mui/material/TableRow'
|
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 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 ExpandMoreIcon from '@mui/icons-material/ExpandMore'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { TEST_CHECKLIST_ITEMS } from '@shared/constants/test-checklist-items'
|
import { TEST_CHECKLIST_ITEMS } from '@shared/constants/test-checklist-items'
|
||||||
@@ -29,6 +32,13 @@ import {
|
|||||||
updateTestChecklistItem,
|
updateTestChecklistItem,
|
||||||
} from '@/entities/test-checklist/api/test-checklist-api'
|
} 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 {
|
function formatDate(iso: string): string {
|
||||||
return new Date(iso).toLocaleString('ru-RU', {
|
return new Date(iso).toLocaleString('ru-RU', {
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
@@ -39,10 +49,30 @@ function formatDate(iso: string): string {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
export function AdminTestChecklistPage() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||||
const [expanded, setExpanded] = useState<string | false>(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({
|
const { data, isLoading, isError } = useQuery({
|
||||||
queryKey: ['admin', 'test-checklist'],
|
queryKey: ['admin', 'test-checklist'],
|
||||||
@@ -50,7 +80,8 @@ export function AdminTestChecklistPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ itemKey, passed }: { itemKey: string; passed: boolean }) => updateTestChecklistItem(itemKey, passed),
|
mutationFn: ({ itemKey, passed, comment }: { itemKey: string; passed: boolean; comment?: string | null }) =>
|
||||||
|
updateTestChecklistItem(itemKey, passed, comment),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'test-checklist'] })
|
queryClient.invalidateQueries({ queryKey: ['admin', 'test-checklist'] })
|
||||||
},
|
},
|
||||||
@@ -79,6 +110,40 @@ export function AdminTestChecklistPage() {
|
|||||||
const total = TEST_CHECKLIST_ITEMS.length
|
const total = TEST_CHECKLIST_ITEMS.length
|
||||||
const passedCount = TEST_CHECKLIST_ITEMS.filter((i) => results[i.key]?.passed).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 (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Stack direction="row" sx={{ mb: 3, justifyContent: 'space-between', alignItems: 'center' }}>
|
<Stack direction="row" sx={{ mb: 3, justifyContent: 'space-between', alignItems: 'center' }}>
|
||||||
@@ -121,26 +186,46 @@ export function AdminTestChecklistPage() {
|
|||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell padding="checkbox" sx={{ width: 56 }} />
|
<TableCell sx={{ width: 56 }} />
|
||||||
<TableCell sx={{ fontWeight: 600 }}>Действие</TableCell>
|
<TableCell sx={{ fontWeight: 600 }}>Действие</TableCell>
|
||||||
<TableCell sx={{ fontWeight: 600 }}>Ожидаемый результат</TableCell>
|
<TableCell sx={{ fontWeight: 600 }}>Ожидаемый результат</TableCell>
|
||||||
|
<TableCell sx={{ fontWeight: 600 }}>Комментарий</TableCell>
|
||||||
<TableCell sx={{ fontWeight: 600, whiteSpace: 'nowrap' }}>Дата проверки</TableCell>
|
<TableCell sx={{ fontWeight: 600, whiteSpace: 'nowrap' }}>Дата проверки</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{items.map((item) => {
|
{items.map((item) => {
|
||||||
const r = results[item.key]
|
const r = results[item.key]
|
||||||
|
const status = statusFromResult(r)
|
||||||
return (
|
return (
|
||||||
<TableRow key={item.key} hover>
|
<TableRow key={item.key} hover>
|
||||||
<TableCell padding="checkbox">
|
<TableCell>
|
||||||
<Checkbox
|
<StatusIcon status={status} onClick={() => handleStatusClick(item.key)} />
|
||||||
checked={r?.passed ?? false}
|
|
||||||
onChange={(_, checked) => updateMutation.mutate({ itemKey: item.key, passed: checked })}
|
|
||||||
color="success"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{item.action}</TableCell>
|
<TableCell>{item.action}</TableCell>
|
||||||
<TableCell>{item.expectedResult}</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' }}>
|
<TableCell sx={{ whiteSpace: 'nowrap', color: r ? 'text.primary' : 'text.disabled' }}>
|
||||||
{r ? formatDate(r.checkedAt) : '—'}
|
{r ? formatDate(r.checkedAt) : '—'}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -158,7 +243,7 @@ export function AdminTestChecklistPage() {
|
|||||||
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}>
|
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}>
|
||||||
<DialogTitle>Сбросить все проверки?</DialogTitle>
|
<DialogTitle>Сбросить все проверки?</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogContentText>Все отметки будут удалены. Это действие нельзя отменить.</DialogContentText>
|
<Typography>Все отметки будут удалены. Это действие нельзя отменить.</Typography>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={() => setConfirmOpen(false)}>Отмена</Button>
|
<Button onClick={() => setConfirmOpen(false)}>Отмена</Button>
|
||||||
@@ -167,6 +252,35 @@ export function AdminTestChecklistPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</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>
|
</Box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user