feat: add find task by id endpoint

This commit is contained in:
Abdalhamid Alhamad
2025-01-13 14:15:29 +03:00
parent 62621c1a15
commit 221f3bae4f
7 changed files with 406 additions and 66 deletions

View File

@ -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() {
<Route path="/juniors" element={<JuniorsList />} />
<Route path="/juniors/new" element={<AddJuniorForm />} />
<Route path="/tasks" element={<TasksList />} />
<Route path="/tasks/new" element={<AddTaskForm />} />
<Route path="/tasks/:taskId" element={<TaskDetails />} />
</Route>
{/* Redirect root to dashboard or login */}
<Route
path="/"
element={<Navigate to="/dashboard" replace />}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes>
</BrowserRouter>
</AuthProvider>

View File

@ -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,7 +50,7 @@ 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,
});
},
@ -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`),
};

View File

@ -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<CreateTaskRequest>({
title: '',
description: '',
dueDate: '',
rewardAmount: 0,
isProofRequired: false,
juniorId: '',
imageId: '',
});
const [juniors, setJuniors] = useState<Junior[]>([]);
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<HTMLInputElement>) => {
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<Junior>;
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<HTMLInputElement>) => {
setFormData((prev) => ({
...prev,
isProofRequired: e.target.checked,
}));
};
return (
<Box p={3}>
<Typography variant="h4" gutterBottom>
Add New Task
</Typography>
<Paper sx={{ p: 3, maxWidth: 600, mx: 'auto' }}>
{error && (
<Alert severity="error" sx={{ mb: 3, whiteSpace: 'pre-line' }}>
{error}
</Alert>
)}
<Box component="form" onSubmit={handleSubmit}>
<Grid container spacing={3}>
<Grid item xs={12} sm={12}>
<TextField
fullWidth
label="Title"
name="title"
value={formData.title}
onChange={handleInputChange}
placeholder="Task Title"
required
/>
</Grid>
<Grid item xs={12} sm={12}>
<TextField
fullWidth
label="Description"
name="description"
value={formData.description}
onChange={handleInputChange}
placeholder="Task Description"
required
/>
</Grid>
<Grid item xs={12} sm={12}>
<TextField
fullWidth
label="Due Date"
name="dueDate"
type="date"
value={formData.dueDate}
onChange={handleInputChange}
required
InputLabelProps={{
shrink: true,
}}
/>
</Grid>
<Grid item xs={12} sm={12}>
<TextField
fullWidth
label="Reward Amount"
name="rewardAmount"
type="number"
value={formData.rewardAmount}
onChange={handleInputChange}
required
/>
</Grid>
<Grid item xs={12}>
<FormControl fullWidth>
<InputLabel>Junior</InputLabel>
<Select name="juniorId" value={formData.juniorId} label="Junior" onChange={handleSelectChange}>
<MenuItem value="">Select Junior</MenuItem>
{juniors.map((junior) => (
<MenuItem key={junior.id} value={junior.id}>
{junior.fullName}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid item xs={12} sm={12}>
<DocumentUpload
documentType={DocumentType.PASSPORT}
label="Upload Task Image"
onUploadSuccess={handleTaskImageUpload}
/>
{formData.imageId && (
<Typography variant="caption" color="success.main" sx={{ mt: 1, display: 'block' }}>
Task Image uploaded (ID: {formData.imageId})
</Typography>
)}
</Grid>
<Grid item xs={12}>
<FormControl fullWidth>
<FormControlLabel
control={
<Checkbox checked={formData.isProofRequired} onChange={handleCheckedInputChange} color="primary" />
}
label="Proof Required"
/>
</FormControl>
</Grid>
</Grid>
<Box sx={{ mt: 3, display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
<Button variant="outlined" onClick={() => navigate('/juniors')}>
Cancel
</Button>
<Button type="submit" variant="contained" disabled={loading}>
{loading ? 'Adding...' : 'Add Task'}
</Button>
</Box>
</Box>
</Paper>
</Box>
);
};

View File

@ -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<Task>();
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 (
<Box display="flex" justifyContent="center" alignItems="center" minHeight="200px">
<CircularProgress />
</Box>
);
}
if (error) {
return (
<Box p={3}>
<Typography color="error">{error}</Typography>
</Box>
);
}
if (!task) {
return (
<Box p={3}>
<Typography color="error">Task not found</Typography>
</Box>
);
}
console.log(task);
return (
<Card>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="flex-start">
<Typography variant="h6" gutterBottom>
{task.title}
</Typography>
<Chip label={task.status} color={statusColors[task.status]} size="small" />
</Box>
<Typography color="textSecondary" gutterBottom>
Due: {new Date(task.dueDate).toLocaleDateString()}
</Typography>
<Typography variant="body2" gutterBottom>
{task.description}
</Typography>
<Typography color="primary" gutterBottom>
Reward: ${task.rewardAmount}
</Typography>
<Typography variant="body2" color="textSecondary">
Assigned to: {task.junior.fullName}
</Typography>
</CardContent>
</Card>
);
};

View File

@ -1,24 +1,24 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Typography,
Grid,
Button,
Card,
CardContent,
Button,
CircularProgress,
Pagination,
FormControl,
InputLabel,
Select,
MenuItem,
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<Junior>;
setJuniors(data.data);
} catch (err) {

View File

@ -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)

View File

@ -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<Task>) {
this.logger.log(`Finding task with where ${JSON.stringify(where)}`);
const task = await this.taskRepository.findTask(where);