From 221f3bae4f47ba5b4621327a9a0c759173f12be0 Mon Sep 17 00:00:00 2001 From: Abdalhamid Alhamad Date: Mon, 13 Jan 2025 14:15:29 +0300 Subject: [PATCH] feat: add find task by id endpoint --- client/src/App.tsx | 18 +- client/src/api/client.ts | 68 +++--- client/src/components/tasks/AddTask.tsx | 245 ++++++++++++++++++++ client/src/components/tasks/TaskDetails.tsx | 87 +++++++ client/src/components/tasks/TasksList.tsx | 36 +-- src/task/controllers/task.controller.ts | 7 + src/task/services/task.service.ts | 11 + 7 files changed, 406 insertions(+), 66 deletions(-) create mode 100644 client/src/components/tasks/AddTask.tsx create mode 100644 client/src/components/tasks/TaskDetails.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index 515bc81..5894b95 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,14 +1,15 @@ -import React from 'react'; -import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { CssBaseline, ThemeProvider, createTheme } from '@mui/material'; -import { AuthProvider } from './contexts/AuthContext'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; import { LoginForm } from './components/auth/LoginForm'; import { RegisterForm } from './components/auth/RegisterForm'; -import { AuthLayout } from './components/layout/AuthLayout'; import { Dashboard } from './components/dashboard/Dashboard'; -import { JuniorsList } from './components/juniors/JuniorsList'; import { AddJuniorForm } from './components/juniors/AddJuniorForm'; +import { JuniorsList } from './components/juniors/JuniorsList'; +import { AuthLayout } from './components/layout/AuthLayout'; +import { AddTaskForm } from './components/tasks/AddTask'; +import { TaskDetails } from './components/tasks/TaskDetails'; import { TasksList } from './components/tasks/TasksList'; +import { AuthProvider } from './contexts/AuthContext'; // Create theme const theme = createTheme({ @@ -110,13 +111,12 @@ function App() { } /> } /> } /> + } /> + } /> {/* Redirect root to dashboard or login */} - } - /> + } /> diff --git a/client/src/api/client.ts b/client/src/api/client.ts index e9f7409..3e52f27 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -15,34 +15,32 @@ export const apiClient = axios.create({ baseURL: API_BASE_URL, headers: { 'Content-Type': 'application/json', - 'x-client-id': 'web-client' - } + 'x-client-id': 'web-client', + }, }); // Add request interceptor to include current auth header -apiClient.interceptors.request.use(config => { +apiClient.interceptors.request.use((config) => { config.headers.Authorization = getAuthHeader(); return config; }); // Add response interceptor to handle errors apiClient.interceptors.response.use( - response => response, - error => { - const errorMessage = error.response?.data?.message || - error.response?.data?.error || - error.message || - 'An unexpected error occurred'; + (response) => response, + (error) => { + const errorMessage = + error.response?.data?.message || error.response?.data?.error || error.message || 'An unexpected error occurred'; console.error('API Error:', { status: error.response?.status, message: errorMessage, - data: error.response?.data + data: error.response?.data, }); // Throw error with meaningful message throw new Error(errorMessage); - } + }, ); // Auth API @@ -52,13 +50,13 @@ export const authApi = { const cleanPhoneNumber = phoneNumber.replace(/\D/g, ''); return apiClient.post('/api/auth/register/otp', { countryCode: countryCode.startsWith('+') ? countryCode : `+${countryCode}`, - phoneNumber: cleanPhoneNumber + phoneNumber: cleanPhoneNumber, }); }, verifyOtp: (countryCode: string, phoneNumber: string, otp: string) => apiClient.post('/api/auth/register/verify', { countryCode, phoneNumber, otp }), - + setEmail: (email: string) => { // Use the stored token from localStorage const storedToken = localStorage.getItem('accessToken'); @@ -67,7 +65,7 @@ export const authApi = { } return apiClient.post('/api/auth/register/set-email', { email }); }, - + setPasscode: (passcode: string) => { // Use the stored token from localStorage const storedToken = localStorage.getItem('accessToken'); @@ -83,29 +81,23 @@ export const authApi = { email, password, fcmToken: 'web-client-token', // Required by API - signature: 'web-login' // Required by API - }) + signature: 'web-login', // Required by API + }), }; // Juniors API export const juniorsApi = { - createJunior: (data: CreateJuniorRequest) => - apiClient.post('/api/juniors', data), + createJunior: (data: CreateJuniorRequest) => apiClient.post('/api/juniors', data), - getJuniors: (page = 1, size = 10) => - apiClient.get(`/api/juniors?page=${page}&size=${size}`), + getJuniors: (page = 1, size = 10) => apiClient.get(`/api/juniors?page=${page}&size=${size}`), - getJunior: (juniorId: string) => - apiClient.get(`/api/juniors/${juniorId}`), + getJunior: (juniorId: string) => apiClient.get(`/api/juniors/${juniorId}`), - setTheme: (data: JuniorTheme) => - apiClient.post('/api/juniors/set-theme', data), + setTheme: (data: JuniorTheme) => apiClient.post('/api/juniors/set-theme', data), - getQrCode: (juniorId: string) => - apiClient.get(`/api/juniors/${juniorId}/qr-code`), + getQrCode: (juniorId: string) => apiClient.get(`/api/juniors/${juniorId}/qr-code`), - validateQrCode: (token: string) => - apiClient.get(`/api/juniors/qr-code/${token}/validate`) + validateQrCode: (token: string) => apiClient.get(`/api/juniors/qr-code/${token}/validate`), }; // Document API @@ -116,16 +108,15 @@ export const documentApi = { formData.append('documentType', documentType); return apiClient.post('/api/document', formData, { headers: { - 'Content-Type': 'multipart/form-data' - } + 'Content-Type': 'multipart/form-data', + }, }); - } + }, }; // Tasks API export const tasksApi = { - createTask: (data: CreateTaskRequest) => - apiClient.post('/api/tasks', data), + createTask: (data: CreateTaskRequest) => apiClient.post('/api/tasks', data), getTasks: (status: TaskStatus, page = 1, size = 10, juniorId?: string) => { const url = new URL('/api/tasks', API_BASE_URL); @@ -136,12 +127,11 @@ export const tasksApi = { return apiClient.get(url.pathname + url.search); }, - submitTask: (taskId: string, data: TaskSubmission) => - apiClient.patch(`/api/tasks/${taskId}/submit`, data), + getTaskById: (taskId: string) => apiClient.get(`/api/tasks/${taskId}`), - approveTask: (taskId: string) => - apiClient.patch(`/api/tasks/${taskId}/approve`), + submitTask: (taskId: string, data: TaskSubmission) => apiClient.patch(`/api/tasks/${taskId}/submit`, data), - rejectTask: (taskId: string) => - apiClient.patch(`/api/tasks/${taskId}/reject`) + approveTask: (taskId: string) => apiClient.patch(`/api/tasks/${taskId}/approve`), + + rejectTask: (taskId: string) => apiClient.patch(`/api/tasks/${taskId}/reject`), }; diff --git a/client/src/components/tasks/AddTask.tsx b/client/src/components/tasks/AddTask.tsx new file mode 100644 index 0000000..057ff38 --- /dev/null +++ b/client/src/components/tasks/AddTask.tsx @@ -0,0 +1,245 @@ +import { + Alert, + Box, + Button, + Checkbox, + FormControl, + FormControlLabel, + Grid, + InputLabel, + MenuItem, + Paper, + Select, + SelectChangeEvent, + TextField, + Typography, +} from '@mui/material'; +import { AxiosError } from 'axios'; +import React, { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { juniorsApi, tasksApi } from '../../api/client'; +import { ApiError } from '../../types/api'; +import { DocumentType } from '../../types/document'; +import { Junior, PaginatedResponse } from '../../types/junior'; +import { CreateTaskRequest } from '../../types/task'; +import { DocumentUpload } from '../document/DocumentUpload'; + +export const AddTaskForm = () => { + const navigate = useNavigate(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [formData, setFormData] = useState({ + title: '', + description: '', + dueDate: '', + rewardAmount: 0, + isProofRequired: false, + juniorId: '', + imageId: '', + }); + + const [juniors, setJuniors] = useState([]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + console.log('Form data:', formData); + + setError(''); + setLoading(true); + + try { + if (!formData.imageId) { + console.log('Proof is required but no image uploaded'); + } + + console.log('Submitting data:', formData); + const dataToSubmit = { + ...formData, + rewardAmount: Number(formData.rewardAmount), + imageId: formData.imageId, + }; + await tasksApi.createTask(dataToSubmit); + navigate('/tasks'); + } catch (err) { + console.error('Create junior error:', err); + if (err instanceof AxiosError && err.response?.data) { + const apiError = err.response.data as ApiError; + const messages = Array.isArray(apiError.message) + ? apiError.message.map((m) => `${m.field}: ${m.message}`).join('\n') + : apiError.message; + setError(messages); + } else { + setError(err instanceof Error ? err.message : 'Failed to create Task'); + } + } finally { + setLoading(false); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value } = e.target; + console.log(name, value); + setFormData((prev) => ({ + ...prev, + [name]: value, + })); + }; + + const fetchJuniors = async () => { + try { + const response = await juniorsApi.getJuniors(1, 50); + const data = response.data as PaginatedResponse; + setJuniors(data.data); + } catch (err) { + console.error('Failed to load juniors:', err); + } + }; + + const handleSelectChange = (e: SelectChangeEvent) => { + const { name, value } = e.target; + setFormData((prev) => ({ + ...prev, + [name as string]: value, + })); + }; + + useEffect(() => { + console.log('Form data updated:', formData); + }, [formData]); + + useEffect(() => { + fetchJuniors(); + }, []); + + const handleTaskImageUpload = (documentId: string) => { + console.log('task image ID uploaded:', documentId); + setFormData((prev) => ({ + ...prev, + imageId: documentId, + })); + }; + + const handleCheckedInputChange = (e: React.ChangeEvent) => { + setFormData((prev) => ({ + ...prev, + isProofRequired: e.target.checked, + })); + }; + + return ( + + + Add New Task + + + + {error && ( + + {error} + + )} + + + + + + + + + + + + + + + + + + + + + Junior + + + + + + + {formData.imageId && ( + + Task Image uploaded (ID: {formData.imageId}) + + )} + + + + + + } + label="Proof Required" + /> + + + + + + + + + + + + ); +}; diff --git a/client/src/components/tasks/TaskDetails.tsx b/client/src/components/tasks/TaskDetails.tsx new file mode 100644 index 0000000..a6ae7ba --- /dev/null +++ b/client/src/components/tasks/TaskDetails.tsx @@ -0,0 +1,87 @@ +import { Box, Card, CardContent, Chip, CircularProgress, Typography } from '@mui/material'; +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +import { tasksApi } from '../../api/client'; +import { Task } from '../../types/task'; + +export const TaskDetails = () => { + useNavigate(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const statusColors = { + PENDING: 'warning', + IN_PROGRESS: 'info', + COMPLETED: 'success', + } as const; + + const { taskId } = useParams(); + if (!taskId) { + throw new Error('Task ID is required'); + } + const [task, setTask] = useState(); + const fetchTask = async () => { + try { + setLoading(true); + const response = await tasksApi.getTaskById(taskId); + setTask(response.data.data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load task'); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchTask(); + }, []); + + if (loading) { + return ( + + + + ); + } + + if (error) { + return ( + + {error} + + ); + } + + if (!task) { + return ( + + Task not found + + ); + } + console.log(task); + + return ( + + + + + {task.title} + + + + + Due: {new Date(task.dueDate).toLocaleDateString()} + + + {task.description} + + + Reward: ${task.rewardAmount} + + + Assigned to: {task.junior.fullName} + + + + ); +}; diff --git a/client/src/components/tasks/TasksList.tsx b/client/src/components/tasks/TasksList.tsx index 985e3a8..7703498 100644 --- a/client/src/components/tasks/TasksList.tsx +++ b/client/src/components/tasks/TasksList.tsx @@ -1,24 +1,24 @@ -import React, { useEffect, useState } from 'react'; -import { - Box, - Typography, - Grid, - Card, - CardContent, +import { + Box, Button, - CircularProgress, - Pagination, - FormControl, - InputLabel, - Select, - MenuItem, + Card, + CardContent, Chip, - SelectChangeEvent + CircularProgress, + FormControl, + Grid, + InputLabel, + MenuItem, + Pagination, + Select, + SelectChangeEvent, + Typography } from '@mui/material'; -import { tasksApi, juniorsApi } from '../../api/client'; -import { Task, TaskStatus } from '../../types/task'; -import { Junior, PaginatedResponse } from '../../types/junior'; +import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { juniorsApi, tasksApi } from '../../api/client'; +import { Junior, PaginatedResponse } from '../../types/junior'; +import { Task, TaskStatus } from '../../types/task'; const statusColors = { PENDING: 'warning', @@ -39,7 +39,7 @@ export const TasksList = () => { const fetchJuniors = async () => { try { - const response = await juniorsApi.getJuniors(1, 100); + const response = await juniorsApi.getJuniors(1, 50); const data = response.data as PaginatedResponse; setJuniors(data.data); } catch (err) { diff --git a/src/task/controllers/task.controller.ts b/src/task/controllers/task.controller.ts index 715bf3c..81265cb 100644 --- a/src/task/controllers/task.controller.ts +++ b/src/task/controllers/task.controller.ts @@ -41,6 +41,13 @@ export class TaskController { ); } + @Get(':taskId') + @UseGuards(AccessTokenGuard) + async findTask(@Param('taskId', CustomParseUUIDPipe) taskId: string, @AuthenticatedUser() user: IJwtPayload) { + const task = await this.taskService.findTaskForUser(taskId, user); + return ResponseFactory.data(new TaskResponseDto(task)); + } + @Patch(':taskId/submit') @UseGuards(RolesGuard) @AllowedRoles(Roles.JUNIOR) diff --git a/src/task/services/task.service.ts b/src/task/services/task.service.ts index 1e57bab..264c47d 100644 --- a/src/task/services/task.service.ts +++ b/src/task/services/task.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import moment from 'moment'; import { FindOptionsWhere } from 'typeorm'; +import { Roles } from '~/auth/enums'; import { IJwtPayload } from '~/auth/interfaces'; import { DocumentService, OciService } from '~/document/services'; import { CreateTaskRequestDto, TasksFilterOptions, TaskSubmissionRequestDto } from '../dtos/request'; @@ -35,6 +36,16 @@ export class TaskService { return this.findTask({ id: task.id }); } + findTaskForUser(taskId: string, user: IJwtPayload) { + this.logger.log(`Finding task ${taskId} for user ${user.sub}`); + + return this.findTask({ + id: taskId, + assignedById: user.roles.includes(Roles.GUARDIAN) ? user.sub : undefined, + assignedToId: user.roles.includes(Roles.JUNIOR) ? user.sub : undefined, + }); + } + async findTask(where: FindOptionsWhere) { this.logger.log(`Finding task with where ${JSON.stringify(where)}`); const task = await this.taskRepository.findTask(where);