363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
import React, { useState, useEffect } from 'react'
|
|
import { useNavigate, useParams } from 'react-router-dom'
|
|
import { useTranslation } from 'react-i18next'
|
|
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 { t } = useTranslation()
|
|
|
|
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('')
|
|
const [isActive, setIsActive] = useState(true)
|
|
|
|
useEffect(() => {
|
|
if (chain) {
|
|
setName(chain.name)
|
|
setSelectedTasks(chain.tasks)
|
|
setIsActive(chain.isActive !== false)
|
|
}
|
|
}, [chain])
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
|
|
if (!name.trim()) {
|
|
toaster.create({
|
|
title: t('challenge.admin.common.validation.error'),
|
|
description: t('challenge.admin.chains.validation.enter.name'),
|
|
type: 'error',
|
|
})
|
|
return
|
|
}
|
|
|
|
if (selectedTasks.length === 0) {
|
|
toaster.create({
|
|
title: t('challenge.admin.common.validation.error'),
|
|
description: t('challenge.admin.chains.validation.add.task'),
|
|
type: 'error',
|
|
})
|
|
return
|
|
}
|
|
|
|
try {
|
|
const taskIds = selectedTasks.map((task) => task.id)
|
|
|
|
if (isEdit && id) {
|
|
await updateChain({
|
|
id,
|
|
data: {
|
|
name: name.trim(),
|
|
taskIds: taskIds,
|
|
isActive,
|
|
},
|
|
}).unwrap()
|
|
toaster.create({
|
|
title: t('challenge.admin.common.success'),
|
|
description: t('challenge.admin.chains.updated'),
|
|
type: 'success',
|
|
})
|
|
} else {
|
|
await createChain({
|
|
name: name.trim(),
|
|
taskIds: taskIds,
|
|
isActive,
|
|
}).unwrap()
|
|
toaster.create({
|
|
title: t('challenge.admin.common.success'),
|
|
description: t('challenge.admin.chains.created'),
|
|
type: 'success',
|
|
})
|
|
}
|
|
navigate(URLs.chains)
|
|
} catch (err: unknown) {
|
|
const errorMessage =
|
|
(err && typeof err === 'object' && 'data' in err &&
|
|
err.data && typeof err.data === 'object' && 'error' in err.data &&
|
|
err.data.error && typeof err.data.error === 'object' && 'message' in err.data.error &&
|
|
typeof err.data.error.message === 'string')
|
|
? err.data.error.message
|
|
: t('challenge.admin.chains.save.error')
|
|
|
|
toaster.create({
|
|
title: t('challenge.admin.common.error'),
|
|
description: errorMessage,
|
|
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={t('challenge.admin.chains.loading')} />
|
|
}
|
|
|
|
if (isEdit && loadError) {
|
|
return <ErrorAlert message={t('challenge.admin.chains.load.error')} />
|
|
}
|
|
|
|
if (isLoadingTasks) {
|
|
return <LoadingSpinner message={t('challenge.admin.common.loading.tasks')} />
|
|
}
|
|
|
|
if (!allTasks) {
|
|
return <ErrorAlert message={t('challenge.admin.chains.tasks.load.error')} />
|
|
}
|
|
|
|
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 ? t('challenge.admin.chains.edit.title') : t('challenge.admin.chains.create.title')}</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>{t('challenge.admin.chains.field.name')}</Field.Label>
|
|
<Input
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder={t('challenge.admin.chains.field.name.placeholder')}
|
|
maxLength={255}
|
|
disabled={isLoading}
|
|
/>
|
|
</Field.Root>
|
|
|
|
{/* Active flag */}
|
|
<Field.Root>
|
|
<HStack justify="space-between" align="flex-start">
|
|
<HStack gap={2}>
|
|
<input
|
|
type="checkbox"
|
|
checked={isActive}
|
|
onChange={(e) => setIsActive(e.target.checked)}
|
|
disabled={isLoading}
|
|
style={{ cursor: isLoading ? 'not-allowed' : 'pointer' }}
|
|
/>
|
|
<Text>{t('challenge.admin.chains.field.isActive')}</Text>
|
|
</HStack>
|
|
<Text fontSize="sm" color="gray.500" maxW="md">
|
|
{t('challenge.admin.chains.field.isActive.helper')}
|
|
</Text>
|
|
</HStack>
|
|
</Field.Root>
|
|
|
|
{/* Selected Tasks */}
|
|
<Box>
|
|
<Text fontWeight="bold" mb={3}>
|
|
{t('challenge.admin.chains.selected.tasks')} ({selectedTasks.length})
|
|
</Text>
|
|
{selectedTasks.length === 0 ? (
|
|
<Box
|
|
p={6}
|
|
borderWidth="2px"
|
|
borderStyle="dashed"
|
|
borderColor="gray.200"
|
|
borderRadius="md"
|
|
textAlign="center"
|
|
>
|
|
<Text color="gray.500">{t('challenge.admin.chains.selected.tasks.empty')}</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={() => navigate(URLs.taskEdit(task.id))}
|
|
disabled={isLoading}
|
|
aria-label="Edit task"
|
|
>
|
|
✎
|
|
</IconButton>
|
|
<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}>
|
|
{t('challenge.admin.chains.available.tasks')}
|
|
</Text>
|
|
<Input
|
|
placeholder={t('challenge.admin.chains.search.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
|
|
? t('challenge.admin.chains.all.tasks.added')
|
|
: t('challenge.admin.common.not.found')}
|
|
</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">
|
|
{t('challenge.admin.chains.button.add')}
|
|
</Button>
|
|
</Flex>
|
|
))}
|
|
</VStack>
|
|
)}
|
|
</Box>
|
|
|
|
{/* Actions */}
|
|
<HStack gap={3} justify="flex-end">
|
|
<Button variant="outline" onClick={() => navigate(URLs.chains)} disabled={isLoading}>
|
|
{t('challenge.admin.common.cancel')}
|
|
</Button>
|
|
<Button type="submit" colorPalette="teal" disabled={isLoading}>
|
|
{isEdit ? t('challenge.admin.chains.button.save') : t('challenge.admin.chains.button.create')}
|
|
</Button>
|
|
</HStack>
|
|
</VStack>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|
|
|