Some checks failed
Backend CI / test (push) Waiting to run
Backend CI / build (push) Blocked by required conditions
Deploy Admin Panel / Deploy Admin Panel (push) Waiting to run
Deploy Admin Panel / Admin Panel Verification (push) Blocked by required conditions
Deploy Mnemo Cards / Deploy Backend (push) Waiting to run
Deploy Mnemo Cards / Deploy Web App (push) Blocked by required conditions
Deploy Mnemo Cards / Final Verification (push) Blocked by required conditions
Mobile App CI / test (push) Has been cancelled
Web App CI / test (push) Has been cancelled
Deploy Telegram Bot / Deploy Telegram Bot (push) Has been cancelled
Mobile App CI / build-android (push) Has been cancelled
Mobile App CI / build-ios (push) Has been cancelled
Web App CI / build (push) Has been cancelled
571 lines
20 KiB
TypeScript
571 lines
20 KiB
TypeScript
import { useState } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { toast } from 'sonner'
|
|
import { testsApi, isTestsApiError } from '@/api/tests'
|
|
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
|
|
import type { TestDto, PaginatedResponse } from '@/types/models'
|
|
import type { Question } from '@/types/questions'
|
|
import { questionFromJson, questionToJson } from '@/types/questions'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog'
|
|
import {
|
|
AlertDialog,
|
|
AlertDialogAction,
|
|
AlertDialogCancel,
|
|
AlertDialogContent,
|
|
AlertDialogDescription,
|
|
AlertDialogFooter,
|
|
AlertDialogHeader,
|
|
AlertDialogTitle,
|
|
} from '@/components/ui/alert-dialog'
|
|
import { Label } from '@/components/ui/label'
|
|
import { ImageUpload } from '@/components/ui/image-upload'
|
|
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
|
|
import { TestQuestionsManager } from '@/components/TestQuestionsManager'
|
|
import { QuestionEditorDialog } from '@/components/QuestionEditorDialog'
|
|
|
|
export default function TestsPage() {
|
|
const queryClient = useQueryClient()
|
|
const [page, setPage] = useState(1)
|
|
const [search, setSearch] = useState('')
|
|
const [selectedTest, setSelectedTest] = useState<TestDto | null>(null)
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
|
const [testToDelete, setTestToDelete] = useState<TestDto | null>(null)
|
|
const [isQuestionDialogOpen, setIsQuestionDialogOpen] = useState(false)
|
|
const [editingQuestion, setEditingQuestion] = useState<{
|
|
question: Question | null
|
|
index: number
|
|
} | null>(null)
|
|
|
|
// Form state
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
color: '',
|
|
cover: undefined as string | undefined,
|
|
version: '',
|
|
time: '',
|
|
timeSubtitle: '',
|
|
questions: [] as Question[],
|
|
})
|
|
|
|
const limit = 20
|
|
|
|
// Fetch tests
|
|
const { data, isLoading, error } = useQuery<PaginatedResponse<TestDto>>({
|
|
queryKey: ['tests', page, search],
|
|
queryFn: () => testsApi.getTests({ page, limit, search }),
|
|
retry: (failureCount, error) => {
|
|
// Don't retry on client errors (4xx)
|
|
if (isTestsApiError(error) && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
|
|
return false
|
|
}
|
|
return failureCount < 2
|
|
},
|
|
})
|
|
|
|
// Mutations
|
|
const createMutation = useMutation({
|
|
mutationFn: (test: TestDto) => testsApi.upsertTest(test),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['tests'] })
|
|
toast.success('Test created successfully')
|
|
closeDialog()
|
|
},
|
|
onError: (error: unknown) => {
|
|
const errorMessage = isTestsApiError(error)
|
|
? error.message
|
|
: getDetailedErrorMessage(error, 'create', 'test')
|
|
toast.error(errorMessage)
|
|
console.error('Error creating test:', error)
|
|
},
|
|
})
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: (test: TestDto) => testsApi.upsertTest(test),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['tests'] })
|
|
toast.success('Test updated successfully')
|
|
closeDialog()
|
|
},
|
|
onError: (error: unknown) => {
|
|
const errorMessage = isTestsApiError(error)
|
|
? error.message
|
|
: getDetailedErrorMessage(error, 'update', 'test')
|
|
toast.error(errorMessage)
|
|
console.error('Error updating test:', error)
|
|
},
|
|
})
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (testId: string) => testsApi.deleteTest(testId),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ['tests'] })
|
|
toast.success('Test deleted successfully')
|
|
setIsDeleteDialogOpen(false)
|
|
setTestToDelete(null)
|
|
},
|
|
onError: (error: unknown) => {
|
|
const errorMessage = isTestsApiError(error)
|
|
? error.message
|
|
: getDetailedErrorMessage(error, 'delete', 'test')
|
|
toast.error(errorMessage)
|
|
console.error('Error deleting test:', error)
|
|
},
|
|
})
|
|
|
|
const openCreateDialog = () => {
|
|
setSelectedTest(null)
|
|
setFormData({
|
|
name: '',
|
|
color: '',
|
|
cover: undefined,
|
|
version: '',
|
|
time: '',
|
|
timeSubtitle: '',
|
|
questions: [],
|
|
})
|
|
setIsDialogOpen(true)
|
|
}
|
|
|
|
const openEditDialog = async (test: TestDto) => {
|
|
try {
|
|
// Load full test data if we only have preview
|
|
const fullTest = test.id ? await testsApi.getTest(test.id) : test
|
|
setSelectedTest(fullTest)
|
|
|
|
// Преобразуем вопросы из JSON в Question объекты
|
|
const questions: Question[] = (fullTest.questions || []).map((q: any) => {
|
|
try {
|
|
return questionFromJson(q)
|
|
} catch (e) {
|
|
console.error('Error parsing question:', e, q)
|
|
return null
|
|
}
|
|
}).filter((q): q is Question => q !== null)
|
|
|
|
setFormData({
|
|
name: fullTest.name || '',
|
|
color: fullTest.color || '',
|
|
cover: fullTest.cover,
|
|
version: fullTest.version || '',
|
|
time: fullTest.time || '',
|
|
timeSubtitle: fullTest.timeSubtitle || '',
|
|
questions,
|
|
})
|
|
setIsDialogOpen(true)
|
|
} catch (error) {
|
|
const errorMessage = isTestsApiError(error)
|
|
? error.message
|
|
: getDetailedErrorMessage(error, 'load', `test "${test.id}"`)
|
|
toast.error(errorMessage)
|
|
console.error('Error loading test details:', error)
|
|
}
|
|
}
|
|
|
|
const closeDialog = () => {
|
|
setIsDialogOpen(false)
|
|
setSelectedTest(null)
|
|
}
|
|
|
|
const handleSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
|
|
if (!formData.name.trim()) {
|
|
toast.error('Name is required')
|
|
return
|
|
}
|
|
|
|
// Преобразуем вопросы в JSON формат для отправки на бэкенд
|
|
const questionsJson = formData.questions.map((q) => questionToJson(q))
|
|
|
|
const testData: TestDto = {
|
|
id: selectedTest?.id,
|
|
name: formData.name.trim(),
|
|
color: formData.color.trim() || undefined,
|
|
cover: formData.cover || undefined,
|
|
version: formData.version.trim() || undefined,
|
|
time: formData.time.trim() || undefined,
|
|
timeSubtitle: formData.timeSubtitle.trim() || undefined,
|
|
questions: questionsJson as any,
|
|
}
|
|
|
|
if (selectedTest) {
|
|
updateMutation.mutate(testData)
|
|
} else {
|
|
createMutation.mutate(testData)
|
|
}
|
|
}
|
|
|
|
const handleDelete = (test: TestDto) => {
|
|
if (!test.id) {
|
|
toast.error('Test ID is required for deletion')
|
|
return
|
|
}
|
|
setTestToDelete(test)
|
|
setIsDeleteDialogOpen(true)
|
|
}
|
|
|
|
const confirmDelete = () => {
|
|
if (testToDelete?.id) {
|
|
deleteMutation.mutate(testToDelete.id)
|
|
}
|
|
}
|
|
|
|
const handleSearch = (value: string) => {
|
|
setSearch(value)
|
|
setPage(1) // Reset to first page when searching
|
|
}
|
|
|
|
if (error) {
|
|
const errorMessage = isTestsApiError(error)
|
|
? error.message
|
|
: formatApiError(error)
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">Tests Management</h1>
|
|
<p className="text-muted-foreground">Error loading tests</p>
|
|
</div>
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="space-y-2">
|
|
<p className="text-red-500 font-medium">Failed to load tests</p>
|
|
<p className="text-sm text-muted-foreground">{errorMessage}</p>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => queryClient.invalidateQueries({ queryKey: ['tests'] })}
|
|
className="mt-2"
|
|
>
|
|
Retry
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">Tests Management</h1>
|
|
<p className="text-muted-foreground">
|
|
View, create, edit and delete game tests
|
|
</p>
|
|
</div>
|
|
<Button onClick={openCreateDialog}>
|
|
<Plus className="mr-2 h-4 w-4" />
|
|
Add Test
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-center space-x-2">
|
|
<Search className="h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search tests..."
|
|
value={search}
|
|
onChange={(e) => handleSearch(e.target.value)}
|
|
className="max-w-sm"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Tests Table */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>All Tests ({data?.total || 0})</CardTitle>
|
|
<CardDescription>
|
|
Manage game tests in the system
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{isLoading ? (
|
|
<div className="text-center py-8">Loading tests...</div>
|
|
) : (
|
|
<>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>ID</TableHead>
|
|
<TableHead>Name</TableHead>
|
|
<TableHead>Packs</TableHead>
|
|
<TableHead>Questions</TableHead>
|
|
<TableHead>Version</TableHead>
|
|
<TableHead>Time</TableHead>
|
|
<TableHead>Actions</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{data?.items.map((test) => (
|
|
<TableRow key={test.id || Math.random()}>
|
|
<TableCell className="font-mono text-sm">{test.id || 'N/A'}</TableCell>
|
|
<TableCell className="font-medium">{test.name}</TableCell>
|
|
<TableCell>
|
|
{test.packs && test.packs.length > 0 ? (
|
|
<div className="flex flex-wrap gap-1">
|
|
{test.packs.map((pack) => (
|
|
<span
|
|
key={pack.id}
|
|
className="inline-flex items-center px-2 py-1 rounded-md text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
|
>
|
|
{pack.title}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span className="text-muted-foreground text-sm">No packs</span>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
{typeof test.questions === 'number'
|
|
? test.questions
|
|
: test.questions?.length || 0}
|
|
</TableCell>
|
|
<TableCell>{test.version || 'N/A'}</TableCell>
|
|
<TableCell>{test.time || 'N/A'}</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => openEditDialog(test)}
|
|
>
|
|
<Edit className="h-4 w-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDelete(test)}
|
|
disabled={!test.id}
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
|
|
{/* Pagination */}
|
|
{data && data.totalPages > 1 && (
|
|
<div className="flex items-center justify-between mt-4">
|
|
<div className="text-sm text-muted-foreground">
|
|
Showing {((page - 1) * limit) + 1} to {Math.min(page * limit, data.total)} of {data.total} tests
|
|
</div>
|
|
<div className="flex items-center space-x-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(page - 1)}
|
|
disabled={page <= 1}
|
|
>
|
|
<ChevronLeft className="h-4 w-4" />
|
|
Previous
|
|
</Button>
|
|
<span className="text-sm">
|
|
Page {page} of {data.totalPages}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage(page + 1)}
|
|
disabled={page >= data.totalPages}
|
|
>
|
|
Next
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Create/Edit Dialog */}
|
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
|
<DialogContent className="max-w-6xl max-h-[95vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{selectedTest ? 'Edit Test' : 'Create New Test'}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{selectedTest ? 'Update the test information' : 'Add a new test to the system'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<form onSubmit={handleSubmit}>
|
|
<div className="grid gap-4 py-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="name">Name *</Label>
|
|
<Input
|
|
id="name"
|
|
value={formData.name}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
|
placeholder="Test name"
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="color">Color</Label>
|
|
<Input
|
|
id="color"
|
|
value={formData.color}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, color: e.target.value }))}
|
|
placeholder="#FF0000"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="version">Version</Label>
|
|
<Input
|
|
id="version"
|
|
value={formData.version}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, version: e.target.value }))}
|
|
placeholder="1.0.0"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="time">Time</Label>
|
|
<Input
|
|
id="time"
|
|
value={formData.time}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, time: e.target.value }))}
|
|
placeholder="e.g. 5 min"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="timeSubtitle">Time Subtitle</Label>
|
|
<Input
|
|
id="timeSubtitle"
|
|
value={formData.timeSubtitle}
|
|
onChange={(e) => setFormData(prev => ({ ...prev, timeSubtitle: e.target.value }))}
|
|
placeholder="e.g. per question"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<ImageUpload
|
|
label="Cover Image"
|
|
value={formData.cover}
|
|
onChange={(value) => setFormData(prev => ({ ...prev, cover: value }))}
|
|
disabled={createMutation.isPending || updateMutation.isPending}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<TestQuestionsManager
|
|
questions={formData.questions}
|
|
onChange={(questions) =>
|
|
setFormData((prev) => ({ ...prev, questions }))
|
|
}
|
|
onEdit={(question, index) => {
|
|
setEditingQuestion({ question, index })
|
|
setIsQuestionDialogOpen(true)
|
|
}}
|
|
disabled={createMutation.isPending || updateMutation.isPending}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setEditingQuestion({ question: null, index: -1 })
|
|
setIsQuestionDialogOpen(true)
|
|
}}
|
|
disabled={createMutation.isPending || updateMutation.isPending}
|
|
>
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
Add Question
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button type="button" variant="outline" onClick={closeDialog}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
|
{createMutation.isPending || updateMutation.isPending ? 'Saving...' : (selectedTest ? 'Update' : 'Create')}
|
|
</Button>
|
|
</DialogFooter>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
|
<AlertDialogContent>
|
|
<AlertDialogHeader>
|
|
<AlertDialogTitle>Delete Test</AlertDialogTitle>
|
|
<AlertDialogDescription>
|
|
Are you sure you want to delete the test "{testToDelete?.name}"? This action cannot be undone.
|
|
</AlertDialogDescription>
|
|
</AlertDialogHeader>
|
|
<AlertDialogFooter>
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
<AlertDialogAction
|
|
onClick={confirmDelete}
|
|
className="bg-red-600 hover:bg-red-700"
|
|
disabled={deleteMutation.isPending}
|
|
>
|
|
{deleteMutation.isPending ? 'Deleting...' : 'Delete'}
|
|
</AlertDialogAction>
|
|
</AlertDialogFooter>
|
|
</AlertDialogContent>
|
|
</AlertDialog>
|
|
|
|
{/* Question Editor Dialog */}
|
|
<QuestionEditorDialog
|
|
open={isQuestionDialogOpen}
|
|
question={editingQuestion?.question || null}
|
|
onSave={(question) => {
|
|
if (editingQuestion) {
|
|
const newQuestions = [...formData.questions]
|
|
if (editingQuestion.index >= 0) {
|
|
// Редактирование существующего вопроса
|
|
newQuestions[editingQuestion.index] = question
|
|
} else {
|
|
// Добавление нового вопроса
|
|
newQuestions.push(question)
|
|
}
|
|
setFormData((prev) => ({ ...prev, questions: newQuestions }))
|
|
}
|
|
setIsQuestionDialogOpen(false)
|
|
setEditingQuestion(null)
|
|
}}
|
|
onClose={() => {
|
|
setIsQuestionDialogOpen(false)
|
|
setEditingQuestion(null)
|
|
}}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|