mnemo_cards/mnemo_cards_admin/src/pages/TestsPage.tsx
Dmitry f6d68fb1fc
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
stuff
2025-12-17 22:54:48 +03:00

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>
)
}