init + api use
This commit is contained in:
320
src/pages/chains/ChainFormPage.tsx
Normal file
320
src/pages/chains/ChainFormPage.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
Button,
|
||||
Input,
|
||||
VStack,
|
||||
HStack,
|
||||
Text,
|
||||
Field,
|
||||
Badge,
|
||||
IconButton,
|
||||
Flex,
|
||||
} from '@chakra-ui/react'
|
||||
import {
|
||||
useGetChainQuery,
|
||||
useGetTasksQuery,
|
||||
useCreateChainMutation,
|
||||
useUpdateChainMutation,
|
||||
} from '../../__data__/api/api'
|
||||
import { URLs } from '../../__data__/urls'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||
import { toaster } from '../../components/ui/toaster'
|
||||
import type { ChallengeTask } from '../../types/challenge'
|
||||
|
||||
export const ChainFormPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const isEdit = !!id
|
||||
|
||||
const { data: chain, isLoading: isLoadingChain, error: loadError } = useGetChainQuery(id!, {
|
||||
skip: !id,
|
||||
})
|
||||
const { data: allTasks, isLoading: isLoadingTasks } = useGetTasksQuery()
|
||||
const [createChain, { isLoading: isCreating }] = useCreateChainMutation()
|
||||
const [updateChain, { isLoading: isUpdating }] = useUpdateChainMutation()
|
||||
|
||||
const [name, setName] = useState('')
|
||||
const [selectedTasks, setSelectedTasks] = useState<ChallengeTask[]>([])
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (chain) {
|
||||
setName(chain.name)
|
||||
setSelectedTasks(chain.tasks)
|
||||
}
|
||||
}, [chain])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!name.trim()) {
|
||||
toaster.create({
|
||||
title: 'Ошибка валидации',
|
||||
description: 'Введите название цепочки',
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedTasks.length === 0) {
|
||||
toaster.create({
|
||||
title: 'Ошибка валидации',
|
||||
description: 'Добавьте хотя бы одно задание',
|
||||
type: 'error',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const taskIds = selectedTasks.map((task) => task.id)
|
||||
|
||||
if (isEdit && id) {
|
||||
await updateChain({
|
||||
id,
|
||||
data: {
|
||||
name: name.trim(),
|
||||
tasks: taskIds,
|
||||
},
|
||||
}).unwrap()
|
||||
toaster.create({
|
||||
title: 'Успешно',
|
||||
description: 'Цепочка обновлена',
|
||||
type: 'success',
|
||||
})
|
||||
} else {
|
||||
await createChain({
|
||||
name: name.trim(),
|
||||
tasks: taskIds,
|
||||
}).unwrap()
|
||||
toaster.create({
|
||||
title: 'Успешно',
|
||||
description: 'Цепочка создана',
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
navigate(URLs.chains)
|
||||
} catch (err: any) {
|
||||
toaster.create({
|
||||
title: 'Ошибка',
|
||||
description: err?.data?.error?.message || 'Не удалось сохранить цепочку',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTask = (task: ChallengeTask) => {
|
||||
if (!selectedTasks.find((t) => t.id === task.id)) {
|
||||
setSelectedTasks([...selectedTasks, task])
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveTask = (taskId: string) => {
|
||||
setSelectedTasks(selectedTasks.filter((t) => t.id !== taskId))
|
||||
}
|
||||
|
||||
const handleMoveUp = (index: number) => {
|
||||
if (index === 0) return
|
||||
const newTasks = [...selectedTasks]
|
||||
;[newTasks[index - 1], newTasks[index]] = [newTasks[index], newTasks[index - 1]]
|
||||
setSelectedTasks(newTasks)
|
||||
}
|
||||
|
||||
const handleMoveDown = (index: number) => {
|
||||
if (index === selectedTasks.length - 1) return
|
||||
const newTasks = [...selectedTasks]
|
||||
;[newTasks[index], newTasks[index + 1]] = [newTasks[index + 1], newTasks[index]]
|
||||
setSelectedTasks(newTasks)
|
||||
}
|
||||
|
||||
if (isEdit && isLoadingChain) {
|
||||
return <LoadingSpinner message="Загрузка цепочки..." />
|
||||
}
|
||||
|
||||
if (isEdit && loadError) {
|
||||
return <ErrorAlert message="Не удалось загрузить цепочку" />
|
||||
}
|
||||
|
||||
if (isLoadingTasks) {
|
||||
return <LoadingSpinner message="Загрузка заданий..." />
|
||||
}
|
||||
|
||||
if (!allTasks) {
|
||||
return <ErrorAlert message="Не удалось загрузить список заданий" />
|
||||
}
|
||||
|
||||
const isLoading = isCreating || isUpdating
|
||||
|
||||
const availableTasks = allTasks.filter(
|
||||
(task) =>
|
||||
!selectedTasks.find((t) => t.id === task.id) &&
|
||||
task.title.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Heading mb={6}>{isEdit ? 'Редактировать цепочку' : 'Создать цепочку'}</Heading>
|
||||
|
||||
<Box
|
||||
as="form"
|
||||
onSubmit={handleSubmit}
|
||||
bg="white"
|
||||
p={6}
|
||||
borderRadius="lg"
|
||||
boxShadow="sm"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
>
|
||||
<VStack gap={6} align="stretch">
|
||||
{/* Name */}
|
||||
<Field.Root required>
|
||||
<Field.Label>Название цепочки</Field.Label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Введите название цепочки"
|
||||
maxLength={255}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</Field.Root>
|
||||
|
||||
{/* Selected Tasks */}
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={3}>
|
||||
Задания в цепочке ({selectedTasks.length})
|
||||
</Text>
|
||||
{selectedTasks.length === 0 ? (
|
||||
<Box
|
||||
p={6}
|
||||
borderWidth="2px"
|
||||
borderStyle="dashed"
|
||||
borderColor="gray.200"
|
||||
borderRadius="md"
|
||||
textAlign="center"
|
||||
>
|
||||
<Text color="gray.500">Добавьте задания из списка ниже</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<VStack gap={2} align="stretch">
|
||||
{selectedTasks.map((task, index) => (
|
||||
<Flex
|
||||
key={task.id}
|
||||
p={3}
|
||||
bg="teal.50"
|
||||
borderWidth="1px"
|
||||
borderColor="teal.200"
|
||||
borderRadius="md"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
>
|
||||
<HStack gap={3} flex={1}>
|
||||
<Badge colorPalette="teal" variant="solid">
|
||||
#{index + 1}
|
||||
</Badge>
|
||||
<Text fontWeight="medium">{task.title}</Text>
|
||||
</HStack>
|
||||
<HStack gap={1}>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleMoveUp(index)}
|
||||
disabled={index === 0 || isLoading}
|
||||
aria-label="Move up"
|
||||
>
|
||||
↑
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleMoveDown(index)}
|
||||
disabled={index === selectedTasks.length - 1 || isLoading}
|
||||
aria-label="Move down"
|
||||
>
|
||||
↓
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorPalette="red"
|
||||
onClick={() => handleRemoveTask(task.id)}
|
||||
disabled={isLoading}
|
||||
aria-label="Remove"
|
||||
>
|
||||
✕
|
||||
</IconButton>
|
||||
</HStack>
|
||||
</Flex>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Available Tasks */}
|
||||
<Box>
|
||||
<Text fontWeight="bold" mb={3}>
|
||||
Доступные задания
|
||||
</Text>
|
||||
<Input
|
||||
placeholder="Поиск заданий..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
mb={3}
|
||||
/>
|
||||
{availableTasks.length === 0 ? (
|
||||
<Box
|
||||
p={6}
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
borderRadius="md"
|
||||
textAlign="center"
|
||||
>
|
||||
<Text color="gray.500">
|
||||
{allTasks.length === selectedTasks.length
|
||||
? 'Все задания уже добавлены'
|
||||
: 'Ничего не найдено'}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<VStack gap={2} align="stretch" maxH="400px" overflowY="auto">
|
||||
{availableTasks.map((task) => (
|
||||
<Flex
|
||||
key={task.id}
|
||||
p={3}
|
||||
bg="gray.50"
|
||||
borderWidth="1px"
|
||||
borderColor="gray.200"
|
||||
borderRadius="md"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
cursor="pointer"
|
||||
_hover={{ bg: 'gray.100' }}
|
||||
onClick={() => handleAddTask(task)}
|
||||
>
|
||||
<Text>{task.title}</Text>
|
||||
<Button size="sm" colorPalette="teal" variant="ghost">
|
||||
+ Добавить
|
||||
</Button>
|
||||
</Flex>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Actions */}
|
||||
<HStack gap={3} justify="flex-end">
|
||||
<Button variant="outline" onClick={() => navigate(URLs.chains)} disabled={isLoading}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button type="submit" colorPalette="teal" loading={isLoading}>
|
||||
{isEdit ? 'Сохранить изменения' : 'Создать цепочку'}
|
||||
</Button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
166
src/pages/chains/ChainsListPage.tsx
Normal file
166
src/pages/chains/ChainsListPage.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Box,
|
||||
Heading,
|
||||
Button,
|
||||
Table,
|
||||
Flex,
|
||||
Input,
|
||||
HStack,
|
||||
Text,
|
||||
Badge,
|
||||
} from '@chakra-ui/react'
|
||||
import { useGetChainsQuery, useDeleteChainMutation } from '../../__data__/api/api'
|
||||
import { URLs } from '../../__data__/urls'
|
||||
import { LoadingSpinner } from '../../components/LoadingSpinner'
|
||||
import { ErrorAlert } from '../../components/ErrorAlert'
|
||||
import { EmptyState } from '../../components/EmptyState'
|
||||
import { ConfirmDialog } from '../../components/ConfirmDialog'
|
||||
import type { ChallengeChain } from '../../types/challenge'
|
||||
import { toaster } from '../../components/ui/toaster'
|
||||
|
||||
export const ChainsListPage: React.FC = () => {
|
||||
const navigate = useNavigate()
|
||||
const { data: chains, isLoading, error, refetch } = useGetChainsQuery()
|
||||
const [deleteChain, { isLoading: isDeleting }] = useDeleteChainMutation()
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [chainToDelete, setChainToDelete] = useState<ChallengeChain | null>(null)
|
||||
|
||||
const handleDeleteChain = async () => {
|
||||
if (!chainToDelete) return
|
||||
|
||||
try {
|
||||
await deleteChain(chainToDelete.id).unwrap()
|
||||
toaster.create({
|
||||
title: 'Успешно',
|
||||
description: 'Цепочка удалена',
|
||||
type: 'success',
|
||||
})
|
||||
setChainToDelete(null)
|
||||
} catch (err) {
|
||||
toaster.create({
|
||||
title: 'Ошибка',
|
||||
description: 'Не удалось удалить цепочку',
|
||||
type: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSpinner message="Загрузка цепочек..." />
|
||||
}
|
||||
|
||||
if (error || !chains) {
|
||||
return <ErrorAlert message="Не удалось загрузить список цепочек" onRetry={refetch} />
|
||||
}
|
||||
|
||||
const filteredChains = chains.filter((chain) =>
|
||||
chain.name.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Flex justify="space-between" align="center" mb={6}>
|
||||
<Heading>Цепочки заданий</Heading>
|
||||
<Button colorPalette="teal" onClick={() => navigate(URLs.chainNew)}>
|
||||
+ Создать цепочку
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{chains.length > 0 && (
|
||||
<Box mb={4}>
|
||||
<Input
|
||||
placeholder="Поиск по названию..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
maxW="400px"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{filteredChains.length === 0 && chains.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Нет цепочек"
|
||||
description="Создайте первую цепочку заданий"
|
||||
actionLabel="Создать цепочку"
|
||||
onAction={() => navigate(URLs.chainNew)}
|
||||
/>
|
||||
) : filteredChains.length === 0 ? (
|
||||
<EmptyState
|
||||
title="Ничего не найдено"
|
||||
description={`По запросу "${searchQuery}" ничего не найдено`}
|
||||
/>
|
||||
) : (
|
||||
<Box bg="white" borderRadius="lg" boxShadow="sm" borderWidth="1px" borderColor="gray.200" overflowX="auto">
|
||||
<Table.Root size="sm">
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader>Название</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Количество заданий</Table.ColumnHeader>
|
||||
<Table.ColumnHeader>Дата создания</Table.ColumnHeader>
|
||||
<Table.ColumnHeader textAlign="right">Действия</Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{filteredChains.map((chain) => (
|
||||
<Table.Row key={chain.id}>
|
||||
<Table.Cell fontWeight="medium">{chain.name}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Badge colorPalette="teal" variant="subtle">
|
||||
{chain.tasks.length} заданий
|
||||
</Badge>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
{formatDate(chain.createdAt)}
|
||||
</Text>
|
||||
</Table.Cell>
|
||||
<Table.Cell textAlign="right">
|
||||
<HStack gap={2} justify="flex-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => navigate(URLs.chainEdit(chain.id))}
|
||||
>
|
||||
Редактировать
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
colorPalette="red"
|
||||
onClick={() => setChainToDelete(chain)}
|
||||
>
|
||||
Удалить
|
||||
</Button>
|
||||
</HStack>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
isOpen={!!chainToDelete}
|
||||
onClose={() => setChainToDelete(null)}
|
||||
onConfirm={handleDeleteChain}
|
||||
title="Удалить цепочку"
|
||||
message={`Вы уверены, что хотите удалить цепочку "${chainToDelete?.name}"? Это действие нельзя отменить.`}
|
||||
confirmLabel="Удалить"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user