mnemo_cards/mnemo_cards_admin/src/pages/TasksPage.tsx
Dmitry a527dccd71
Some checks failed
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
Deploy Admin Panel / Deploy Admin Panel (push) Has been cancelled
Deploy Admin Panel / Admin Panel Verification (push) Has been cancelled
fix
2026-01-10 21:53:23 +03:00

520 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner'
import { tasksApi } from '@/api/tasks'
import type { TaskDto, TaskFilters, PaginatedResponse, CreateTaskDto, UpdateTaskDto } from '@/types/models'
import type { AxiosError } from 'axios'
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,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { TaskStatusBadge } from '@/components/tasks/TaskStatusBadge'
import { TaskTypeBadge } from '@/components/tasks/TaskTypeBadge'
import { TaskDifficultyBadge } from '@/components/tasks/TaskDifficultyBadge'
import { TaskDetailsDialog } from '@/components/tasks/TaskDetailsDialog'
import { TaskForm } from '@/components/tasks/TaskForm'
import { Search, Plus, Edit, Trash2, Eye, ChevronLeft, ChevronRight } from 'lucide-react'
export default function TasksPage() {
const queryClient = useQueryClient()
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [filters, setFilters] = useState<TaskFilters>({})
const [selectedTask, setSelectedTask] = useState<TaskDto | null>(null)
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
const [isViewDialogOpen, setIsViewDialogOpen] = useState(false)
const [taskToDelete, setTaskToDelete] = useState<TaskDto | null>(null)
const limit = 20
// Fetch tasks with filters
const { data, isLoading, error } = useQuery<PaginatedResponse<TaskDto>>({
queryKey: ['tasks', page, search, filters],
queryFn: () =>
tasksApi.getTasks({
page,
limit,
search: search || undefined,
...filters,
}),
})
// Mutations
const createMutation = useMutation({
mutationFn: (taskData: Parameters<typeof tasksApi.createTask>[0]) =>
tasksApi.createTask(taskData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
toast.success('Задача успешно создана')
setIsCreateDialogOpen(false)
},
onError: (error: unknown) => {
const axiosError = error as AxiosError<{ message?: string }>
toast.error(
axiosError.response?.data?.message || 'Не удалось создать задачу'
)
},
})
const updateMutation = useMutation({
mutationFn: ({
taskId,
data,
}: {
taskId: string
data: Parameters<typeof tasksApi.updateTask>[1]
}) => tasksApi.updateTask(taskId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
toast.success('Задача успешно обновлена')
setIsEditDialogOpen(false)
setSelectedTask(null)
},
onError: (error: unknown) => {
const axiosError = error as AxiosError<{ message?: string }>
toast.error(
axiosError.response?.data?.message || 'Не удалось обновить задачу'
)
},
})
const deleteMutation = useMutation({
mutationFn: (taskId: string) => tasksApi.deleteTask(taskId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tasks'] })
toast.success('Задача успешно удалена')
setIsDeleteDialogOpen(false)
setTaskToDelete(null)
},
onError: (error: unknown) => {
const axiosError = error as AxiosError<{ message?: string }>
toast.error(
axiosError.response?.data?.message || 'Не удалось удалить задачу'
)
},
})
const handleCreate = async (
taskData: CreateTaskDto | UpdateTaskDto
) => {
// When creating, all required fields are present, so we can safely cast
await createMutation.mutateAsync(taskData as CreateTaskDto)
}
const handleEdit = (task: TaskDto) => {
setSelectedTask(task)
setIsEditDialogOpen(true)
}
const handleUpdate = async (
taskData: Parameters<typeof tasksApi.updateTask>[1]
) => {
if (!selectedTask?.id) {
toast.error('ID задачи не найден')
return
}
await updateMutation.mutateAsync({ taskId: selectedTask.id, data: taskData })
}
const handleDelete = (task: TaskDto) => {
setTaskToDelete(task)
setIsDeleteDialogOpen(true)
}
const confirmDelete = () => {
if (taskToDelete?.id) {
deleteMutation.mutate(taskToDelete.id)
}
}
const handleView = (task: TaskDto) => {
setSelectedTask(task)
setIsViewDialogOpen(true)
}
const handleFilterChange = <K extends keyof TaskFilters>(
key: K,
value: TaskFilters[K] | undefined
) => {
setFilters((prev) => {
const newFilters = { ...prev }
if (value === undefined || value === '') {
delete newFilters[key]
} else {
newFilters[key] = value
}
// Reset to first page when filters change
setPage(1)
return newFilters
})
}
const handleSearch = (value: string) => {
setSearch(value)
setPage(1) // Reset to first page when search changes
}
if (error) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Управление задачами</h1>
<p className="text-muted-foreground">Ошибка загрузки задач</p>
</div>
<Card>
<CardContent className="pt-6">
<p className="text-red-500">
Не удалось загрузить задачи. Попробуйте обновить страницу.
</p>
</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">Управление задачами</h1>
<p className="text-muted-foreground">
Просмотр и управление задачами пользователей
</p>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Создать задачу
</Button>
</div>
{/* Search and Filters */}
<Card>
<CardHeader>
<CardTitle>Поиск и фильтры</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Search */}
<div className="flex items-center space-x-2">
<Search className="h-4 w-4 text-muted-foreground" />
<Input
placeholder="Поиск по названию или описанию..."
value={search}
onChange={(e) => handleSearch(e.target.value)}
className="max-w-sm"
/>
</div>
{/* Filters */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Тип</label>
<Select
value={filters.type || ''}
onValueChange={(value) =>
handleFilterChange('type', value ? (value as TaskFilters['type']) : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder="Все типы" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Все типы</SelectItem>
<SelectItem value="app_internal">Внутреннее приложение</SelectItem>
<SelectItem value="external">Внешнее</SelectItem>
<SelectItem value="social">Социальное</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Сложность</label>
<Select
value={filters.difficulty || ''}
onValueChange={(value) =>
handleFilterChange('difficulty', value ? (value as TaskFilters['difficulty']) : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder="Все уровни" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Все уровни</SelectItem>
<SelectItem value="easy">Легкая</SelectItem>
<SelectItem value="medium">Средняя</SelectItem>
<SelectItem value="hard">Сложная</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Статус</label>
<Select
value={filters.status || ''}
onValueChange={(value) =>
handleFilterChange('status', value ? (value as TaskFilters['status']) : undefined)
}
>
<SelectTrigger>
<SelectValue placeholder="Все статусы" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Все статусы</SelectItem>
<SelectItem value="available">Доступна</SelectItem>
<SelectItem value="in_progress">В процессе</SelectItem>
<SelectItem value="completed">Завершена</SelectItem>
<SelectItem value="expired">Истекла</SelectItem>
<SelectItem value="failed">Провалена</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{/* Clear filters button */}
{(filters.type || filters.difficulty || filters.status) && (
<Button
variant="outline"
size="sm"
onClick={() => {
setFilters({})
setPage(1)
}}
>
Сбросить фильтры
</Button>
)}
</CardContent>
</Card>
{/* Tasks Table */}
<Card>
<CardHeader>
<CardTitle>Все задачи ({data?.total || 0})</CardTitle>
<CardDescription>Управление задачами пользователей</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8">Загрузка задач...</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Название</TableHead>
<TableHead>Тип</TableHead>
<TableHead>Сложность</TableHead>
<TableHead>Статус</TableHead>
<TableHead>Срок действия</TableHead>
<TableHead>Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data?.items && data.items.length > 0 ? (
data.items.map((task) => (
<TableRow key={task.id}>
<TableCell className="font-mono text-sm">
{task.id.slice(0, 8)}...
</TableCell>
<TableCell className="font-medium">{task.title}</TableCell>
<TableCell>
<TaskTypeBadge type={task.type} />
</TableCell>
<TableCell>
<TaskDifficultyBadge difficulty={task.difficulty} />
</TableCell>
<TableCell>
<TaskStatusBadge status={task.status} />
</TableCell>
<TableCell>
{new Date(task.expiresAt).toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})}
</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleView(task)}
title="Просмотр"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(task)}
title="Редактировать"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(task)}
title="Удалить"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
Задачи не найдены
</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">
Показано {((page - 1) * limit) + 1} до {Math.min(page * limit, data.total)} из{' '}
{data.total} задач
</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" />
Предыдущая
</Button>
<span className="text-sm">
Страница {page} из {data.totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setPage(page + 1)}
disabled={page >= data.totalPages}
>
Следующая
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Create Task Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Создать задачу</DialogTitle>
<DialogDescription>
Заполните форму для создания новой задачи
</DialogDescription>
</DialogHeader>
<TaskForm
onSave={handleCreate}
onCancel={() => setIsCreateDialogOpen(false)}
/>
</DialogContent>
</Dialog>
{/* Edit Task Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Редактировать задачу</DialogTitle>
<DialogDescription>
Обновите информацию о задаче
</DialogDescription>
</DialogHeader>
{selectedTask && (
<TaskForm
task={selectedTask}
onSave={handleUpdate}
onCancel={() => {
setIsEditDialogOpen(false)
setSelectedTask(null)
}}
/>
)}
</DialogContent>
</Dialog>
{/* View Task Dialog */}
{selectedTask && (
<TaskDetailsDialog
task={selectedTask}
open={isViewDialogOpen}
onClose={() => {
setIsViewDialogOpen(false)
setSelectedTask(null)
}}
/>
)}
{/* Delete Confirmation Dialog */}
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить задачу</AlertDialogTitle>
<AlertDialogDescription>
Вы уверены, что хотите удалить задачу "{taskToDelete?.title || taskToDelete?.id}"?
Это действие нельзя отменить.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Отмена</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700"
disabled={deleteMutation.isPending}
>
{deleteMutation.isPending ? 'Удаление...' : 'Удалить'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}