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
520 lines
19 KiB
TypeScript
520 lines
19 KiB
TypeScript
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>
|
||
)
|
||
}
|
||
|