diff --git a/mnemo_cards_admin/src/App.tsx b/mnemo_cards_admin/src/App.tsx index 6e3a86e..d494a43 100644 --- a/mnemo_cards_admin/src/App.tsx +++ b/mnemo_cards_admin/src/App.tsx @@ -6,6 +6,7 @@ import CardsPage from '@/pages/CardsPage' import PacksPage from '@/pages/PacksPage' import UsersPage from '@/pages/UsersPage' import TestsPage from '@/pages/TestsPage' +import TasksPage from '@/pages/TasksPage' import Layout from '@/components/layout/Layout' import { TokenRefreshProvider } from '@/components/TokenRefreshProvider' @@ -31,6 +32,7 @@ function App() { } /> } /> } /> + } /> ) : ( diff --git a/mnemo_cards_admin/src/api/analytics.ts b/mnemo_cards_admin/src/api/analytics.ts index e218403..e6b5b91 100644 --- a/mnemo_cards_admin/src/api/analytics.ts +++ b/mnemo_cards_admin/src/api/analytics.ts @@ -1,4 +1,5 @@ import { adminApiClient } from './client' +import { tasksApi } from './tasks' export interface DashboardStats { users: number @@ -8,6 +9,13 @@ export interface DashboardStats { payments: number } +export interface TasksStats { + total: number + active: number + completed: number + expired: number +} + export interface RecentUser { id: number name?: string @@ -52,4 +60,34 @@ export const analyticsApi = { const response = await adminApiClient.get('/api/v2/admin/analytics/revenue/chart') return response.data }, + + // Get tasks statistics + getTasksStats: async (): Promise => { + try { + // Get all tasks counts by status + const [allTasks, availableTasks, inProgressTasks, completedTasks, expiredTasks] = await Promise.all([ + tasksApi.getTasks({ limit: 1, page: 1 }), + tasksApi.getTasks({ limit: 1, page: 1, status: 'available' }), + tasksApi.getTasks({ limit: 1, page: 1, status: 'in_progress' }), + tasksApi.getTasks({ limit: 1, page: 1, status: 'completed' }), + tasksApi.getTasks({ limit: 1, page: 1, status: 'expired' }), + ]) + + return { + total: allTasks.total, + active: (availableTasks.total || 0) + (inProgressTasks.total || 0), + completed: completedTasks.total || 0, + expired: expiredTasks.total || 0, + } + } catch (error) { + // If there's an error, return zeros + console.error('Error fetching tasks stats:', error) + return { + total: 0, + active: 0, + completed: 0, + expired: 0, + } + } + }, } diff --git a/mnemo_cards_admin/src/api/tasks.ts b/mnemo_cards_admin/src/api/tasks.ts new file mode 100644 index 0000000..12bde87 --- /dev/null +++ b/mnemo_cards_admin/src/api/tasks.ts @@ -0,0 +1,202 @@ +import { adminApiClient } from './client' +import type { TaskDto, CreateTaskDto, UpdateTaskDto, TaskFilters, PaginatedResponse } from '@/types/models' +import type { AxiosError } from 'axios' + +export interface TasksApiError { + message: string + statusCode?: number + field?: string + originalError?: unknown + name: 'TasksApiError' +} + +export function createTasksApiError( + message: string, + statusCode?: number, + field?: string, + originalError?: unknown +): TasksApiError { + return { + message, + statusCode, + field, + originalError, + name: 'TasksApiError', + } +} + +export function isTasksApiError(error: unknown): error is TasksApiError { + return ( + typeof error === 'object' && + error !== null && + 'name' in error && + error.name === 'TasksApiError' + ) +} + +export const tasksApi = { + // Get all tasks with pagination and filters + getTasks: async (params?: { + page?: number + limit?: number + } & TaskFilters): Promise> => { + try { + const response = await adminApiClient.get('/api/v2/admin/tasks', { + params: { + page: params?.page || 1, + limit: params?.limit || 20, + type: params?.type, + difficulty: params?.difficulty, + status: params?.status, + userId: params?.userId, + search: params?.search, + }, + }) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string }> + + // Check for validation errors (limit too high, etc.) + if (axiosError.response?.status === 400) { + const errorData = axiosError.response.data + if (errorData?.message?.includes('Limit')) { + throw createTasksApiError( + `Invalid limit: ${errorData.message}. Maximum allowed limit is 100.`, + axiosError.response.status, + 'limit', + error + ) + } + } + + throw createTasksApiError( + axiosError.response?.data?.message || + axiosError.response?.data?.error || + 'Failed to load tasks', + axiosError.response?.status, + axiosError.response?.data?.field, + error + ) + } + }, + + // Get task details by ID + getTask: async (taskId: string): Promise => { + try { + const response = await adminApiClient.get(`/api/v2/admin/tasks/${taskId}`) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string }> + + if (axiosError.response?.status === 404) { + throw createTasksApiError( + `Task not found: The task with ID "${taskId}" does not exist or has been deleted.`, + axiosError.response.status, + undefined, + error + ) + } + + throw createTasksApiError( + axiosError.response?.data?.message || + axiosError.response?.data?.error || + `Failed to load task ${taskId}`, + axiosError.response?.status, + undefined, + error + ) + } + }, + + // Create a new task + createTask: async (data: CreateTaskDto): Promise<{ success: boolean; task: TaskDto }> => { + try { + const response = await adminApiClient.post('/api/v2/admin/tasks', data) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }> + const baseMessage = axiosError.response?.data?.message || + axiosError.response?.data?.error || + 'Failed to create task' + + let fullMessage = baseMessage + if (axiosError.response?.data?.details) { + fullMessage += `. ${axiosError.response.data.details}` + } + + if (axiosError.response?.data?.field) { + fullMessage += ` (Field: ${axiosError.response.data.field})` + } + + throw createTasksApiError( + fullMessage, + axiosError.response?.status, + axiosError.response?.data?.field, + error + ) + } + }, + + // Update an existing task + updateTask: async (taskId: string, data: UpdateTaskDto): Promise<{ success: boolean; task: TaskDto }> => { + try { + const response = await adminApiClient.put(`/api/v2/admin/tasks/${taskId}`, data) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string; field?: string; details?: string }> + const baseMessage = axiosError.response?.data?.message || + axiosError.response?.data?.error || + 'Failed to update task' + + let fullMessage = baseMessage + if (axiosError.response?.data?.details) { + fullMessage += `. ${axiosError.response.data.details}` + } + + // Special handling for common errors + if (axiosError.response?.status === 404) { + fullMessage = `Task not found: The task you're trying to update (ID: ${taskId}) does not exist.` + } + + if (axiosError.response?.data?.field) { + fullMessage += ` (Field: ${axiosError.response.data.field})` + } + + throw createTasksApiError( + fullMessage, + axiosError.response?.status, + axiosError.response?.data?.field, + error + ) + } + }, + + // Delete a task + deleteTask: async (taskId: string): Promise<{ success: boolean; message: string }> => { + try { + const response = await adminApiClient.delete(`/api/v2/admin/tasks/${taskId}`) + return response.data + } catch (error) { + const axiosError = error as AxiosError<{ error?: string; message?: string }> + + if (axiosError.response?.status === 404) { + throw createTasksApiError( + `Task not found: The task with ID "${taskId}" does not exist and cannot be deleted.`, + axiosError.response.status, + undefined, + error + ) + } + + throw createTasksApiError( + axiosError.response?.data?.message || + axiosError.response?.data?.error || + `Failed to delete task ${taskId}`, + axiosError.response?.status, + undefined, + error + ) + } + }, +} + diff --git a/mnemo_cards_admin/src/components/layout/Layout.tsx b/mnemo_cards_admin/src/components/layout/Layout.tsx index 73db32c..7ae36dc 100644 --- a/mnemo_cards_admin/src/components/layout/Layout.tsx +++ b/mnemo_cards_admin/src/components/layout/Layout.tsx @@ -8,6 +8,7 @@ import { Package, Users, ClipboardList, + CheckSquare, LogOut, Menu, X @@ -24,6 +25,7 @@ const navigation = [ { name: 'Packs', href: '/packs', icon: Package }, { name: 'Tests', href: '/tests', icon: ClipboardList }, { name: 'Users', href: '/users', icon: Users }, + { name: 'Tasks', href: '/tasks', icon: CheckSquare }, ] export default function Layout({ children }: LayoutProps) { diff --git a/mnemo_cards_admin/src/components/tasks/TaskCard.tsx b/mnemo_cards_admin/src/components/tasks/TaskCard.tsx new file mode 100644 index 0000000..8a26355 --- /dev/null +++ b/mnemo_cards_admin/src/components/tasks/TaskCard.tsx @@ -0,0 +1,90 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' +import { TaskStatusBadge } from './TaskStatusBadge' +import { TaskTypeBadge } from './TaskTypeBadge' +import { TaskDifficultyBadge } from './TaskDifficultyBadge' +import { TaskRewardsList } from './TaskRewardsList' +import { cn } from '@/lib/utils' +import type { TaskDto } from '@/types/models' + +interface TaskCardProps { + task: TaskDto + onClick?: () => void + className?: string +} + +export function TaskCard({ task, onClick, className }: TaskCardProps) { + const formatDate = (dateString: string | undefined): string => { + if (!dateString) return 'Не указано' + try { + return new Date(dateString).toLocaleDateString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }) + } catch { + return dateString + } + } + + return ( + + +
+ {task.title} + +
+
+ + {/* Описание */} +

+ {task.description} +

+ + {/* Бейджи типа и сложности */} +
+ + +
+ + {/* Награды */} +
+ +
+ + {/* Даты */} +
+
Истекает: {formatDate(task.expiresAt)}
+ {task.completedAt && ( +
Завершена: {formatDate(task.completedAt)}
+ )} +
+ + {/* Теги (если есть) */} + {task.tags && task.tags.length > 0 && ( +
+ {task.tags.slice(0, 3).map((tag, index) => ( + + {tag} + + ))} + {task.tags.length > 3 && ( + + +{task.tags.length - 3} + + )} +
+ )} +
+
+ ) +} + diff --git a/mnemo_cards_admin/src/components/tasks/TaskDetailsDialog.tsx b/mnemo_cards_admin/src/components/tasks/TaskDetailsDialog.tsx new file mode 100644 index 0000000..0b52015 --- /dev/null +++ b/mnemo_cards_admin/src/components/tasks/TaskDetailsDialog.tsx @@ -0,0 +1,160 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog' +import { TaskStatusBadge } from './TaskStatusBadge' +import { TaskTypeBadge } from './TaskTypeBadge' +import { TaskDifficultyBadge } from './TaskDifficultyBadge' +import { TaskRewardsList } from './TaskRewardsList' +import { Badge } from '@/components/ui/badge' +import type { TaskDto } from '@/types/models' + +interface TaskDetailsDialogProps { + task: TaskDto + open: boolean + onClose: () => void +} + +export function TaskDetailsDialog({ + task, + open, + onClose, +}: TaskDetailsDialogProps) { + const formatDate = (dateString: string | undefined): string => { + if (!dateString) return 'Не указано' + try { + return new Date(dateString).toLocaleString('ru-RU', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) + } catch { + return dateString + } + } + + return ( + + + + {task.title} + {task.description} + + +
+ {/* Статус, тип и сложность */} +
+ + + +
+ + {/* Награды */} +
+

Награды

+ +
+ + {/* Даты */} +
+
+

Создана

+

+ {formatDate(task.createdAt)} +

+
+
+

Истекает

+

+ {formatDate(task.expiresAt)} +

+
+ {task.completedAt && ( +
+

Завершена

+

+ {formatDate(task.completedAt)} +

+
+ )} +
+ + {/* Инструкции */} + {task.instructions && ( +
+

Инструкции

+

+ {task.instructions} +

+
+ )} + + {/* Теги */} + {task.tags && task.tags.length > 0 && ( +
+

Теги

+
+ {task.tags.map((tag, index) => ( + + {tag} + + ))} +
+
+ )} + + {/* Изображение */} + {task.imageUrl && ( +
+

Изображение

+ {task.title} +
+ )} + + {/* Доказательство выполнения */} + {task.proofUrl && ( +
+

+ Доказательство выполнения +

+ + Открыть ссылку + +
+ )} + + {/* ID пользователя */} + {task.userId && ( +
+

ID пользователя

+

+ {task.userId} +

+
+ )} + + {/* ID задачи */} +
+

ID задачи

+

{task.id}

+
+
+
+
+ ) +} + diff --git a/mnemo_cards_admin/src/components/tasks/TaskDifficultyBadge.tsx b/mnemo_cards_admin/src/components/tasks/TaskDifficultyBadge.tsx new file mode 100644 index 0000000..6d6023b --- /dev/null +++ b/mnemo_cards_admin/src/components/tasks/TaskDifficultyBadge.tsx @@ -0,0 +1,37 @@ +import { Badge } from '@/components/ui/badge' +import { cn } from '@/lib/utils' +import type { TaskDto } from '@/types/models' + +interface TaskDifficultyBadgeProps { + difficulty: TaskDto['difficulty'] + className?: string +} + +export function TaskDifficultyBadge({ difficulty, className }: TaskDifficultyBadgeProps) { + const difficultyConfig = { + easy: { + label: 'Легкая', + className: 'bg-green-400 hover:bg-green-500 text-white border-transparent', + }, + medium: { + label: 'Средняя', + className: 'bg-yellow-500 hover:bg-yellow-600 text-white border-transparent', + }, + hard: { + label: 'Сложная', + className: 'bg-red-500 hover:bg-red-600 text-white border-transparent', + }, + } + + const config = difficultyConfig[difficulty] + + return ( + + {config.label} + + ) +} + diff --git a/mnemo_cards_admin/src/components/tasks/TaskForm.tsx b/mnemo_cards_admin/src/components/tasks/TaskForm.tsx new file mode 100644 index 0000000..9b8144e --- /dev/null +++ b/mnemo_cards_admin/src/components/tasks/TaskForm.tsx @@ -0,0 +1,619 @@ +import { useState, useCallback } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { ImageUpload } from '@/components/ui/image-upload' +import { Badge } from '@/components/ui/badge' +import { Plus, Trash2, X } from 'lucide-react' +import type { TaskDto, CreateTaskDto, UpdateTaskDto, TaskRewardDto } from '@/types/models' + +interface TaskFormProps { + task?: TaskDto + onSave: (data: CreateTaskDto | UpdateTaskDto) => Promise + onCancel: () => void +} + +interface FormErrors { + title?: string + description?: string + type?: string + difficulty?: string + expiresAt?: string + rewards?: string + rewardErrors?: Record +} + +interface RewardFormData { + type: 'xp' | 'coins' | 'achievement' + amount: number + achievementId: string +} + +export function TaskForm({ task, onSave, onCancel }: TaskFormProps) { + const [formData, setFormData] = useState({ + title: task?.title || '', + description: task?.description || '', + type: (task?.type || 'app_internal') as 'app_internal' | 'external' | 'social', + difficulty: (task?.difficulty || 'easy') as 'easy' | 'medium' | 'hard', + expiresAt: task?.expiresAt + ? new Date(task.expiresAt).toISOString().slice(0, 16) + : '', + instructions: task?.instructions || '', + tags: task?.tags || [], + imageUrl: task?.imageUrl || '', + userId: task?.userId || '', + }) + + const [rewards, setRewards] = useState(() => { + if (task?.rewards && task.rewards.length > 0) { + return task.rewards.map((r) => ({ + type: r.type, + amount: r.amount || 0, + achievementId: r.achievementId || '', + })) + } + return [{ type: 'xp', amount: 0, achievementId: '' }] + }) + + const [tagInput, setTagInput] = useState('') + const [errors, setErrors] = useState({}) + const [isSubmitting, setIsSubmitting] = useState(false) + + // Валидация формы + const validateForm = useCallback((): boolean => { + const newErrors: FormErrors = {} + + // Валидация названия + if (!formData.title.trim()) { + newErrors.title = 'Название обязательно для заполнения' + } else if (formData.title.trim().length < 3) { + newErrors.title = 'Название должно содержать минимум 3 символа' + } + + // Валидация описания + if (!formData.description.trim()) { + newErrors.description = 'Описание обязательно для заполнения' + } else if (formData.description.trim().length < 10) { + newErrors.description = 'Описание должно содержать минимум 10 символов' + } + + // Валидация типа + if (!formData.type) { + newErrors.type = 'Тип задачи обязателен для заполнения' + } + + // Валидация сложности + if (!formData.difficulty) { + newErrors.difficulty = 'Сложность обязательна для заполнения' + } + + // Валидация срока действия + if (!formData.expiresAt) { + newErrors.expiresAt = 'Срок действия обязателен для заполнения' + } else { + const expiresDate = new Date(formData.expiresAt) + const now = new Date() + if (expiresDate <= now) { + newErrors.expiresAt = 'Срок действия должна быть в будущем' + } + } + + // Валидация наград + if (rewards.length === 0) { + newErrors.rewards = 'Необходимо указать хотя бы одну награду' + } else { + const rewardErrors: Record = {} + rewards.forEach((reward, index) => { + if (reward.type === 'achievement') { + if (!reward.achievementId.trim()) { + rewardErrors[index] = { + achievementId: 'Для достижения необходимо указать ID', + } + } + } else { + if (reward.amount <= 0) { + rewardErrors[index] = { + amount: 'Количество должно быть больше 0', + } + } + } + }) + if (Object.keys(rewardErrors).length > 0) { + newErrors.rewardErrors = rewardErrors + } + } + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + }, [formData, rewards]) + + // Обработка изменений полей + const handleFieldChange = (field: keyof typeof formData, value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })) + // Очистка ошибки при изменении поля + if (errors[field as keyof FormErrors]) { + setErrors((prev) => ({ ...prev, [field]: undefined })) + } + } + + // Добавление награды + const handleAddReward = () => { + setRewards((prev) => [ + ...prev, + { type: 'xp', amount: 0, achievementId: '' }, + ]) + setErrors((prev) => ({ + ...prev, + rewardErrors: undefined, + rewards: undefined, + })) + } + + // Удаление награды + const handleRemoveReward = (index: number) => { + setRewards((prev) => { + const filtered = prev.filter((_, i) => i !== index) + // Если осталась только одна награда, очищаем ошибки + if (filtered.length === 0 || filtered.length === 1) { + setErrors((prevErrors) => ({ + ...prevErrors, + rewardErrors: undefined, + rewards: undefined, + })) + } else { + // Переиндексируем ошибки после удаления + setErrors((prevErrors) => { + if (!prevErrors.rewardErrors) return prevErrors + + const newRewardErrors: Record = {} + Object.entries(prevErrors.rewardErrors).forEach(([key, value]) => { + const oldIndex = parseInt(key) + if (oldIndex < index) { + // Индекс не изменился + newRewardErrors[oldIndex] = value + } else if (oldIndex > index) { + // Индекс уменьшился на 1 + newRewardErrors[oldIndex - 1] = value + } + // Индекс == index - пропускаем (удаленная награда) + }) + + return { + ...prevErrors, + rewardErrors: Object.keys(newRewardErrors).length > 0 ? newRewardErrors : undefined, + } + }) + } + return filtered + }) + } + + // Изменение награды + const handleRewardChange = ( + index: number, + field: keyof RewardFormData, + value: string | number + ) => { + setRewards((prev) => { + const updated = [...prev] + updated[index] = { ...updated[index], [field]: value } + return updated + }) + // Очистка ошибок при изменении награды + setErrors((prev) => { + const newErrors = { ...prev } + if (newErrors.rewardErrors) { + const updatedRewardErrors = { ...newErrors.rewardErrors } + delete updatedRewardErrors[index] + if (Object.keys(updatedRewardErrors).length === 0) { + delete newErrors.rewardErrors + } else { + newErrors.rewardErrors = updatedRewardErrors + } + } + return newErrors + }) + } + + // Добавление тега + const handleAddTag = () => { + const newTags = tagInput + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0 && !formData.tags.includes(t)) + + if (newTags.length > 0) { + setFormData((prev) => ({ + ...prev, + tags: [...prev.tags, ...newTags], + })) + setTagInput('') + } + } + + // Удаление тега + const handleRemoveTag = (tagToRemove: string) => { + setFormData((prev) => ({ + ...prev, + tags: prev.tags.filter((t) => t !== tagToRemove), + })) + } + + // Обработка Enter для добавления тега + const handleTagInputKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault() + handleAddTag() + } + } + + // Обработка отправки формы + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + if (!validateForm()) { + return + } + + setIsSubmitting(true) + + try { + // Преобразование наград в формат API + const apiRewards: TaskRewardDto[] = rewards.map((reward) => { + if (reward.type === 'achievement') { + return { + type: reward.type, + amount: 0, + achievementId: reward.achievementId.trim() || undefined, + } + } else { + return { + type: reward.type, + amount: reward.amount, + } + } + }) + + // Преобразование данных формы в формат API + const taskData: CreateTaskDto | UpdateTaskDto = { + title: formData.title.trim(), + description: formData.description.trim(), + type: formData.type, + difficulty: formData.difficulty, + rewards: apiRewards, + expiresAt: new Date(formData.expiresAt).toISOString(), + instructions: formData.instructions.trim() || undefined, + tags: formData.tags.length > 0 ? formData.tags : undefined, + imageUrl: formData.imageUrl || undefined, + userId: formData.userId.trim() || undefined, + } + + await onSave(taskData) + } catch (error) { + console.error('Error saving task:', error) + // Ошибка обрабатывается в родительском компоненте + } finally { + setIsSubmitting(false) + } + } + + return ( +
+ {/* Название */} +
+ + handleFieldChange('title', e.target.value)} + placeholder="Введите название задачи" + required + className={errors.title ? 'border-red-500' : ''} + /> + {errors.title && ( +

{errors.title}

+ )} +
+ + {/* Описание */} +
+ +