Add isActive field to challenge chains and update localization; implement functionality to toggle chain status in the UI, enhancing task management and user experience.

This commit is contained in:
2025-12-10 12:02:11 +03:00
parent 4e1b290f99
commit 173954f685
8 changed files with 109 additions and 10 deletions

View File

@@ -74,6 +74,8 @@
"challenge.admin.chains.button.add": "+ Add", "challenge.admin.chains.button.add": "+ Add",
"challenge.admin.chains.button.save": "Save changes", "challenge.admin.chains.button.save": "Save changes",
"challenge.admin.chains.button.create": "Create chain", "challenge.admin.chains.button.create": "Create chain",
"challenge.admin.chains.field.isActive": "Active for students",
"challenge.admin.chains.field.isActive.helper": "If disabled, the chain will not appear in the user-facing list.",
"challenge.admin.chains.list.title": "Task Chains", "challenge.admin.chains.list.title": "Task Chains",
"challenge.admin.chains.list.create.button": "+ Create Chain", "challenge.admin.chains.list.create.button": "+ Create Chain",
"challenge.admin.chains.list.search.placeholder": "Search by name...", "challenge.admin.chains.list.search.placeholder": "Search by name...",
@@ -84,8 +86,11 @@
"challenge.admin.chains.list.table.name": "Name", "challenge.admin.chains.list.table.name": "Name",
"challenge.admin.chains.list.table.tasks.count": "Number of tasks", "challenge.admin.chains.list.table.tasks.count": "Number of tasks",
"challenge.admin.chains.list.table.created": "Created date", "challenge.admin.chains.list.table.created": "Created date",
"challenge.admin.chains.list.table.status": "Status",
"challenge.admin.chains.list.table.actions": "Actions", "challenge.admin.chains.list.table.actions": "Actions",
"challenge.admin.chains.list.badge.tasks": "tasks", "challenge.admin.chains.list.badge.tasks": "tasks",
"challenge.admin.chains.list.status.active": "Active",
"challenge.admin.chains.list.status.inactive": "Inactive",
"challenge.admin.chains.list.button.edit": "Edit", "challenge.admin.chains.list.button.edit": "Edit",
"challenge.admin.chains.list.button.delete": "Delete", "challenge.admin.chains.list.button.delete": "Delete",
"challenge.admin.chains.deleted": "Chain deleted", "challenge.admin.chains.deleted": "Chain deleted",

View File

@@ -73,6 +73,8 @@
"challenge.admin.chains.button.add": "+ Добавить", "challenge.admin.chains.button.add": "+ Добавить",
"challenge.admin.chains.button.save": "Сохранить изменения", "challenge.admin.chains.button.save": "Сохранить изменения",
"challenge.admin.chains.button.create": "Создать цепочку", "challenge.admin.chains.button.create": "Создать цепочку",
"challenge.admin.chains.field.isActive": "Активна для студентов",
"challenge.admin.chains.field.isActive.helper": "Если выключить, цепочка не будет отображаться в пользовательском списке.",
"challenge.admin.chains.list.title": "Цепочки заданий", "challenge.admin.chains.list.title": "Цепочки заданий",
"challenge.admin.chains.list.create.button": "+ Создать цепочку", "challenge.admin.chains.list.create.button": "+ Создать цепочку",
"challenge.admin.chains.list.search.placeholder": "Поиск по названию...", "challenge.admin.chains.list.search.placeholder": "Поиск по названию...",
@@ -83,8 +85,11 @@
"challenge.admin.chains.list.table.name": "Название", "challenge.admin.chains.list.table.name": "Название",
"challenge.admin.chains.list.table.tasks.count": "Количество заданий", "challenge.admin.chains.list.table.tasks.count": "Количество заданий",
"challenge.admin.chains.list.table.created": "Дата создания", "challenge.admin.chains.list.table.created": "Дата создания",
"challenge.admin.chains.list.table.status": "Статус",
"challenge.admin.chains.list.table.actions": "Действия", "challenge.admin.chains.list.table.actions": "Действия",
"challenge.admin.chains.list.badge.tasks": "заданий", "challenge.admin.chains.list.badge.tasks": "заданий",
"challenge.admin.chains.list.status.active": "Включена",
"challenge.admin.chains.list.status.inactive": "Выключена",
"challenge.admin.chains.list.button.edit": "Редактировать", "challenge.admin.chains.list.button.edit": "Редактировать",
"challenge.admin.chains.list.button.delete": "Удалить", "challenge.admin.chains.list.button.delete": "Удалить",
"challenge.admin.chains.deleted": "Цепочка удалена", "challenge.admin.chains.deleted": "Цепочка удалена",

View File

@@ -77,7 +77,7 @@ export const api = createApi({
// Chains // Chains
getChains: builder.query<ChallengeChain[], void>({ getChains: builder.query<ChallengeChain[], void>({
query: () => '/challenge/chains', query: () => '/challenge/chains/admin',
transformResponse: (response: { body: ChallengeChain[] }) => response.body, transformResponse: (response: { body: ChallengeChain[] }) => response.body,
providesTags: ['Chain'], providesTags: ['Chain'],
}), }),

View File

@@ -42,11 +42,13 @@ export const ChainFormPage: React.FC = () => {
const [name, setName] = useState('') const [name, setName] = useState('')
const [selectedTasks, setSelectedTasks] = useState<ChallengeTask[]>([]) const [selectedTasks, setSelectedTasks] = useState<ChallengeTask[]>([])
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [isActive, setIsActive] = useState(true)
useEffect(() => { useEffect(() => {
if (chain) { if (chain) {
setName(chain.name) setName(chain.name)
setSelectedTasks(chain.tasks) setSelectedTasks(chain.tasks)
setIsActive(chain.isActive !== false)
} }
}, [chain]) }, [chain])
@@ -80,6 +82,7 @@ export const ChainFormPage: React.FC = () => {
data: { data: {
name: name.trim(), name: name.trim(),
taskIds: taskIds, taskIds: taskIds,
isActive,
}, },
}).unwrap() }).unwrap()
toaster.create({ toaster.create({
@@ -91,6 +94,7 @@ export const ChainFormPage: React.FC = () => {
await createChain({ await createChain({
name: name.trim(), name: name.trim(),
taskIds: taskIds, taskIds: taskIds,
isActive,
}).unwrap() }).unwrap()
toaster.create({ toaster.create({
title: t('challenge.admin.common.success'), title: t('challenge.admin.common.success'),
@@ -191,6 +195,25 @@ export const ChainFormPage: React.FC = () => {
/> />
</Field.Root> </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 */} {/* Selected Tasks */}
<Box> <Box>
<Text fontWeight="bold" mb={3}> <Text fontWeight="bold" mb={3}>

View File

@@ -12,7 +12,7 @@ import {
Text, Text,
Badge, Badge,
} from '@chakra-ui/react' } from '@chakra-ui/react'
import { useGetChainsQuery, useDeleteChainMutation } from '../../__data__/api/api' import { useGetChainsQuery, useDeleteChainMutation, useUpdateChainMutation } from '../../__data__/api/api'
import { URLs } from '../../__data__/urls' import { URLs } from '../../__data__/urls'
import { LoadingSpinner } from '../../components/LoadingSpinner' import { LoadingSpinner } from '../../components/LoadingSpinner'
import { ErrorAlert } from '../../components/ErrorAlert' import { ErrorAlert } from '../../components/ErrorAlert'
@@ -29,6 +29,8 @@ export const ChainsListPage: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [chainToDelete, setChainToDelete] = useState<ChallengeChain | null>(null) const [chainToDelete, setChainToDelete] = useState<ChallengeChain | null>(null)
const [updatingChainId, setUpdatingChainId] = useState<string | null>(null)
const [updateChain] = useUpdateChainMutation()
const handleDeleteChain = async () => { const handleDeleteChain = async () => {
if (!chainToDelete) return if (!chainToDelete) return
@@ -50,6 +52,30 @@ export const ChainsListPage: React.FC = () => {
} }
} }
const handleToggleActive = async (chain: ChallengeChain, nextValue: boolean) => {
setUpdatingChainId(chain.id)
try {
await updateChain({
id: chain.id,
data: { isActive: nextValue },
}).unwrap()
toaster.create({
title: t('challenge.admin.common.success'),
description: t('challenge.admin.chains.updated'),
type: 'success',
})
} catch {
toaster.create({
title: t('challenge.admin.common.error'),
description: t('challenge.admin.chains.save.error'),
type: 'error',
})
} finally {
setUpdatingChainId(null)
}
}
if (isLoading) { if (isLoading) {
return <LoadingSpinner message={t('challenge.admin.chains.list.loading')} /> return <LoadingSpinner message={t('challenge.admin.chains.list.loading')} />
} }
@@ -110,6 +136,7 @@ export const ChainsListPage: React.FC = () => {
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.name')}</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.chains.list.table.name')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.tasks.count')}</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.chains.list.table.tasks.count')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.created')}</Table.ColumnHeader> <Table.ColumnHeader>{t('challenge.admin.chains.list.table.created')}</Table.ColumnHeader>
<Table.ColumnHeader>{t('challenge.admin.chains.list.table.status')}</Table.ColumnHeader>
<Table.ColumnHeader textAlign="right">{t('challenge.admin.chains.list.table.actions')}</Table.ColumnHeader> <Table.ColumnHeader textAlign="right">{t('challenge.admin.chains.list.table.actions')}</Table.ColumnHeader>
</Table.Row> </Table.Row>
</Table.Header> </Table.Header>
@@ -127,6 +154,25 @@ export const ChainsListPage: React.FC = () => {
{formatDate(chain.createdAt)} {formatDate(chain.createdAt)}
</Text> </Text>
</Table.Cell> </Table.Cell>
<Table.Cell>
<HStack gap={3} justify="flex-start">
<Badge colorPalette={chain.isActive ? 'green' : 'gray'} variant="subtle">
{chain.isActive
? t('challenge.admin.chains.list.status.active')
: t('challenge.admin.chains.list.status.inactive')}
</Badge>
<Button
size="xs"
variant="outline"
onClick={() => handleToggleActive(chain, !chain.isActive)}
isDisabled={updatingChainId === chain.id}
>
{chain.isActive
? t('challenge.admin.chains.list.status.inactive')
: t('challenge.admin.chains.list.status.active')}
</Button>
</HStack>
</Table.Cell>
<Table.Cell textAlign="right"> <Table.Cell textAlign="right">
<HStack gap={2} justify="flex-end"> <HStack gap={2} justify="flex-end">
<Button <Button

View File

@@ -27,6 +27,7 @@ export interface ChallengeChain {
id: string id: string
name: string name: string
tasks: ChallengeTask[] // Populated tasks: ChallengeTask[] // Populated
isActive: boolean
createdAt: string createdAt: string
updatedAt: string updatedAt: string
} }
@@ -132,11 +133,13 @@ export interface UpdateTaskRequest {
export interface CreateChainRequest { export interface CreateChainRequest {
name: string name: string
taskIds: string[] // Array of task IDs taskIds: string[] // Array of task IDs
isActive?: boolean
} }
export interface UpdateChainRequest { export interface UpdateChainRequest {
name?: string name?: string
taskIds?: string[] taskIds?: string[]
isActive?: boolean
} }
// ========== Stats v2 Types ========== // ========== Stats v2 Types ==========

View File

@@ -21,6 +21,7 @@
"updatedAt": "2024-11-05T11:00:00.000Z" "updatedAt": "2024-11-05T11:00:00.000Z"
} }
], ],
"isActive": true,
"createdAt": "2024-11-01T09:00:00.000Z", "createdAt": "2024-11-01T09:00:00.000Z",
"updatedAt": "2024-11-05T12:00:00.000Z" "updatedAt": "2024-11-05T12:00:00.000Z"
}, },
@@ -38,6 +39,7 @@
"updatedAt": "2024-11-03T09:15:00.000Z" "updatedAt": "2024-11-03T09:15:00.000Z"
} }
], ],
"isActive": false,
"createdAt": "2024-11-03T08:00:00.000Z", "createdAt": "2024-11-03T08:00:00.000Z",
"updatedAt": "2024-11-03T09:30:00.000Z" "updatedAt": "2024-11-03T09:30:00.000Z"
}, },
@@ -63,6 +65,7 @@
"updatedAt": "2024-11-04T14:20:00.000Z" "updatedAt": "2024-11-04T14:20:00.000Z"
} }
], ],
"isActive": true,
"createdAt": "2024-11-02T11:00:00.000Z", "createdAt": "2024-11-02T11:00:00.000Z",
"updatedAt": "2024-11-04T15:00:00.000Z" "updatedAt": "2024-11-04T15:00:00.000Z"
} }

View File

@@ -184,8 +184,15 @@ router.delete('/challenge/task/:id', (req, res) => {
// ============= CHAINS ============= // ============= CHAINS =============
// GET /api/challenge/chains // GET /api/challenge/chains (user-facing list: only active chains)
router.get('/challenge/chains', (req, res) => { router.get('/challenge/chains', (req, res) => {
const chains = getChains();
const activeChains = chains.filter(c => c.isActive !== false);
respond(res, activeChains);
});
// GET /api/challenge/chains/admin (admin list: all chains)
router.get('/challenge/chains/admin', (req, res) => {
const chains = getChains(); const chains = getChains();
respond(res, chains); respond(res, chains);
}); });
@@ -204,17 +211,17 @@ router.get('/challenge/chain/:id', (req, res) => {
// POST /api/challenge/chain // POST /api/challenge/chain
router.post('/challenge/chain', (req, res) => { router.post('/challenge/chain', (req, res) => {
const { name, tasks } = req.body; const { name, taskIds, isActive } = req.body;
if (!name || !tasks || !Array.isArray(tasks)) { if (!name || !taskIds || !Array.isArray(taskIds)) {
return respondError(res, 'Name and tasks array are required'); return respondError(res, 'Name and taskIds array are required');
} }
const chains = getChains(); const chains = getChains();
const allTasks = getTasks(); const allTasks = getTasks();
// Populate tasks // Populate tasks
const populatedTasks = tasks.map(taskId => { const populatedTasks = taskIds.map(taskId => {
const task = allTasks.find(t => t.id === taskId); const task = allTasks.find(t => t.id === taskId);
return task ? { return task ? {
_id: task._id, _id: task._id,
@@ -231,6 +238,7 @@ router.post('/challenge/chain', (req, res) => {
id: `chain_${Date.now()}`, id: `chain_${Date.now()}`,
name, name,
tasks: populatedTasks, tasks: populatedTasks,
isActive: isActive !== undefined ? !!isActive : true,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };
@@ -253,14 +261,16 @@ router.put('/challenge/chain/:id', (req, res) => {
return respondError(res, 'Chain not found', 404); return respondError(res, 'Chain not found', 404);
} }
const { name, tasks } = req.body; const { name, taskIds, tasks, isActive } = req.body;
const chain = chains[chainIndex]; const chain = chains[chainIndex];
if (name) chain.name = name; if (name) chain.name = name;
if (tasks && Array.isArray(tasks)) { const effectiveTaskIds = Array.isArray(taskIds) ? taskIds : (Array.isArray(tasks) ? tasks : null);
if (effectiveTaskIds) {
const allTasks = getTasks(); const allTasks = getTasks();
const populatedTasks = tasks.map(taskId => { const populatedTasks = effectiveTaskIds.map(taskId => {
const task = allTasks.find(t => t.id === taskId); const task = allTasks.find(t => t.id === taskId);
return task ? { return task ? {
_id: task._id, _id: task._id,
@@ -275,6 +285,10 @@ router.put('/challenge/chain/:id', (req, res) => {
chain.tasks = populatedTasks; chain.tasks = populatedTasks;
} }
if (isActive !== undefined) {
chain.isActive = !!isActive;
}
chain.updatedAt = new Date().toISOString(); chain.updatedAt = new Date().toISOString();
respond(res, chain); respond(res, chain);