sto-k-odnomu/admin/src/pages/PacksPage.tsx

571 lines
19 KiB
TypeScript
Raw Normal View History

2026-01-06 20:12:36 +00:00
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { packsApi, isPacksApiError } from '@/api/packs'
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils'
2026-01-07 14:10:14 +00:00
import type {
EditCardPackDto,
CardPackPreviewDto,
PaginatedResponse,
Question,
} from '@/types/models'
2026-01-06 20:12:36 +00:00
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 { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox'
import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
2026-01-07 14:10:14 +00:00
import { GameQuestionsManager } from '@/components/GameQuestionsManager'
import { GameQuestionEditorDialog } from '@/components/GameQuestionEditorDialog'
2026-01-06 20:12:36 +00:00
export default function PacksPage() {
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [showDisabled, setShowDisabled] = useState(false)
const [selectedPack, setSelectedPack] = useState<EditCardPackDto | null>(null)
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [packToDelete, setPackToDelete] = useState<CardPackPreviewDto | null>(null)
2026-01-07 13:59:18 +00:00
// Question editor state
const [isQuestionEditorOpen, setIsQuestionEditorOpen] = useState(false)
const [editingQuestion, setEditingQuestion] = useState<{
question: Question | null
index: number
} | null>(null)
2026-01-06 20:12:36 +00:00
// Form state
const [formData, setFormData] = useState({
2026-01-07 13:35:50 +00:00
name: '',
2026-01-06 20:12:36 +00:00
description: '',
2026-01-07 13:35:50 +00:00
category: '',
isPublic: true,
questions: [] as Question[],
2026-01-06 20:12:36 +00:00
})
const limit = 20
// Fetch packs
const { data, isLoading, error } = useQuery<PaginatedResponse<CardPackPreviewDto>>({
queryKey: ['packs', page, search, showDisabled],
queryFn: () => packsApi.getPacks({ page, limit, search, showDisabled }),
retry: (failureCount, error) => {
// Don't retry on client errors (4xx)
if (isPacksApiError(error) && error.statusCode && error.statusCode >= 400 && error.statusCode < 500) {
return false
}
return failureCount < 2
},
})
// Mutations
const createMutation = useMutation({
mutationFn: (pack: EditCardPackDto) => packsApi.upsertPack(pack),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['packs'] })
toast.success('Pack created successfully')
closeDialog()
},
onError: (error: unknown) => {
const errorMessage = isPacksApiError(error)
? error.message
: getDetailedErrorMessage(error, 'create', 'pack')
toast.error(errorMessage)
console.error('Error creating pack:', error)
},
})
const updateMutation = useMutation({
mutationFn: (pack: EditCardPackDto) => packsApi.upsertPack(pack),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['packs'] })
toast.success('Pack updated successfully')
closeDialog()
},
onError: (error: unknown) => {
const errorMessage = isPacksApiError(error)
? error.message
: getDetailedErrorMessage(error, 'update', 'pack')
toast.error(errorMessage)
console.error('Error updating pack:', error)
},
})
const deleteMutation = useMutation({
mutationFn: (packId: string) => packsApi.deletePack(packId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['packs'] })
toast.success('Pack deleted successfully')
setIsDeleteDialogOpen(false)
setPackToDelete(null)
},
onError: (error: unknown) => {
const errorMessage = isPacksApiError(error)
? error.message
: getDetailedErrorMessage(error, 'delete', 'pack')
toast.error(errorMessage)
console.error('Error deleting pack:', error)
},
})
const openCreateDialog = () => {
setSelectedPack(null)
setFormData({
2026-01-07 13:35:50 +00:00
name: '',
2026-01-06 20:12:36 +00:00
description: '',
2026-01-07 13:35:50 +00:00
category: '',
isPublic: true,
questions: [],
2026-01-06 20:12:36 +00:00
})
setIsDialogOpen(true)
}
const openEditDialog = async (pack: CardPackPreviewDto) => {
try {
const fullPack = await packsApi.getPack(pack.id)
setSelectedPack(fullPack)
2026-01-07 13:59:18 +00:00
2026-01-07 14:10:14 +00:00
// Convert questions from backend format (supports both 'question' and 'text' fields)
2026-01-07 13:59:18 +00:00
const questions: Question[] = Array.isArray(fullPack.questions)
2026-01-07 14:10:14 +00:00
? fullPack.questions.map((q: any) => ({
question: q.question || q.text || '',
answers: Array.isArray(q.answers)
? q.answers.map((a: any) => ({
text: a.text || '',
points: typeof a.points === 'number' ? a.points : 0,
}))
: [],
}))
2026-01-07 13:59:18 +00:00
: []
2026-01-06 20:12:36 +00:00
setFormData({
2026-01-07 13:35:50 +00:00
name: fullPack.name || '',
2026-01-06 20:12:36 +00:00
description: fullPack.description || '',
2026-01-07 13:35:50 +00:00
category: fullPack.category || '',
isPublic: fullPack.isPublic ?? true,
2026-01-07 13:59:18 +00:00
questions,
2026-01-06 20:12:36 +00:00
})
setIsDialogOpen(true)
} catch (error) {
const errorMessage = isPacksApiError(error)
? error.message
: getDetailedErrorMessage(error, 'load', `pack "${pack.id}"`)
toast.error(errorMessage)
console.error('Error loading pack details:', error)
}
}
const closeDialog = () => {
setIsDialogOpen(false)
setSelectedPack(null)
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
2026-01-07 13:35:50 +00:00
if (!formData.name.trim()) {
toast.error('Name is required')
return
}
if (!formData.description.trim()) {
toast.error('Description is required')
return
}
if (!formData.category.trim()) {
toast.error('Category is required')
2026-01-06 20:12:36 +00:00
return
}
2026-01-07 14:10:14 +00:00
// Questions are already in the correct format for backend
2026-01-06 20:12:36 +00:00
const packData: EditCardPackDto = {
// Only include id for updates, not for new packs
...(selectedPack && { id: selectedPack.id }),
2026-01-07 13:35:50 +00:00
name: formData.name.trim(),
description: formData.description.trim(),
category: formData.category.trim(),
isPublic: formData.isPublic,
2026-01-07 14:10:14 +00:00
questions: formData.questions,
2026-01-06 20:12:36 +00:00
}
if (selectedPack) {
updateMutation.mutate(packData)
} else {
createMutation.mutate(packData)
}
}
2026-01-07 13:59:18 +00:00
// Question editor handlers
const handleAddQuestion = () => {
setEditingQuestion({ question: null, index: -1 })
setIsQuestionEditorOpen(true)
}
const handleEditQuestion = (question: Question, index: number) => {
setEditingQuestion({ question, index })
setIsQuestionEditorOpen(true)
}
const handleSaveQuestion = (question: Question) => {
if (editingQuestion) {
const newQuestions = [...formData.questions]
if (editingQuestion.index >= 0) {
// Update existing question
newQuestions[editingQuestion.index] = question
} else {
// Add new question
newQuestions.push(question)
}
setFormData(prev => ({ ...prev, questions: newQuestions }))
}
setIsQuestionEditorOpen(false)
setEditingQuestion(null)
}
const handleCloseQuestionEditor = () => {
setIsQuestionEditorOpen(false)
setEditingQuestion(null)
}
const handleQuestionsChange = (questions: Question[]) => {
setFormData(prev => ({ ...prev, questions }))
}
2026-01-06 20:12:36 +00:00
const handleDelete = (pack: CardPackPreviewDto) => {
setPackToDelete(pack)
setIsDeleteDialogOpen(true)
}
const confirmDelete = () => {
if (packToDelete) {
deleteMutation.mutate(packToDelete.id)
}
}
const handleSearch = (value: string) => {
setSearch(value)
setPage(1) // Reset to first page when searching
}
if (error) {
const errorMessage = isPacksApiError(error)
? error.message
: formatApiError(error)
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Packs Management</h1>
<p className="text-muted-foreground">Error loading packs</p>
</div>
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<p className="text-red-500 font-medium">Failed to load packs</p>
<p className="text-sm text-muted-foreground">{errorMessage}</p>
<Button
variant="outline"
size="sm"
onClick={() => queryClient.invalidateQueries({ queryKey: ['packs'] })}
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">Packs Management</h1>
<p className="text-muted-foreground">
View, create, edit and delete card packs
</p>
</div>
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" />
Add Pack
</Button>
</div>
{/* Search and Filters */}
<Card>
<CardContent className="pt-6">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search packs..."
value={search}
onChange={(e) => handleSearch(e.target.value)}
className="max-w-sm"
/>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="showDisabled"
checked={showDisabled}
onCheckedChange={(checked) => setShowDisabled(checked as boolean)}
/>
<Label htmlFor="showDisabled">Show disabled packs</Label>
</div>
</div>
</CardContent>
</Card>
{/* Packs Table */}
<Card>
<CardHeader>
<CardTitle>All Packs ({data?.total || 0})</CardTitle>
<CardDescription>
Manage card packs and their contents
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Loading packs...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Title</TableHead>
<TableHead>Cards</TableHead>
<TableHead>Enabled</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
2026-01-09 16:44:33 +00:00
{(data?.items || []).map((pack) => (
2026-01-06 20:12:36 +00:00
<TableRow key={pack.id}>
<TableCell className="font-mono text-sm">{pack.id}</TableCell>
<TableCell>
2026-01-07 13:35:50 +00:00
<div className="font-medium">{pack.title}</div>
2026-01-06 20:12:36 +00:00
</TableCell>
<TableCell>{pack.cards}</TableCell>
<TableCell>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
pack.enabled
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
}`}>
{pack.enabled ? 'Enabled' : 'Disabled'}
</span>
</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => openEditDialog(pack)}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(pack)}
>
<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} packs
</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}>
2026-01-07 13:59:18 +00:00
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
2026-01-06 20:12:36 +00:00
<DialogHeader>
<DialogTitle>
{selectedPack ? 'Edit Pack' : 'Create New Pack'}
</DialogTitle>
<DialogDescription>
{selectedPack ? 'Update the pack information' : 'Add a new pack to the system'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
2026-01-07 13:35:50 +00:00
<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="Pack name"
required
/>
2026-01-06 20:12:36 +00:00
</div>
<div className="space-y-2">
2026-01-07 13:35:50 +00:00
<Label htmlFor="description">Description *</Label>
2026-01-06 20:12:36 +00:00
<Textarea
id="description"
value={formData.description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Pack description"
rows={3}
2026-01-07 13:35:50 +00:00
required
2026-01-06 20:12:36 +00:00
/>
</div>
2026-01-07 13:35:50 +00:00
<div className="space-y-2">
<Label htmlFor="category">Category *</Label>
<Input
id="category"
value={formData.category}
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
placeholder="Pack category"
required
/>
2026-01-06 20:12:36 +00:00
</div>
<div className="flex items-center space-x-2">
<Checkbox
2026-01-07 13:35:50 +00:00
id="isPublic"
checked={formData.isPublic}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isPublic: checked as boolean }))}
2026-01-06 20:12:36 +00:00
/>
2026-01-07 13:35:50 +00:00
<Label htmlFor="isPublic">Public</Label>
2026-01-06 20:12:36 +00:00
</div>
<div className="space-y-2">
2026-01-07 13:59:18 +00:00
<div className="flex items-center justify-between">
<Label>Questions</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddQuestion}
>
<Plus className="h-4 w-4 mr-2" />
Add Question
</Button>
</div>
2026-01-07 14:10:14 +00:00
<GameQuestionsManager
2026-01-07 13:59:18 +00:00
questions={formData.questions}
onChange={handleQuestionsChange}
onEdit={handleEditQuestion}
/>
2026-01-06 20:12:36 +00:00
</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...' : (selectedPack ? 'Update' : 'Create')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
2026-01-07 13:59:18 +00:00
{/* Question Editor Dialog */}
2026-01-07 14:10:14 +00:00
<GameQuestionEditorDialog
2026-01-07 13:59:18 +00:00
open={isQuestionEditorOpen}
question={editingQuestion?.question || null}
onSave={handleSaveQuestion}
onClose={handleCloseQuestionEditor}
/>
2026-01-06 20:12:36 +00:00
{/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Pack</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete the pack "{packToDelete?.title}"? This action cannot be undone and will remove all associated cards.
</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>
</div>
)
}