mirror of
https://github.com/HamzaSha1/zod-backend.git
synced 2025-07-10 15:17:44 +00:00
feat: add find task by id endpoint
This commit is contained in:
@ -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>
|
||||
|
@ -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`),
|
||||
};
|
||||
|
245
client/src/components/tasks/AddTask.tsx
Normal file
245
client/src/components/tasks/AddTask.tsx
Normal 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>
|
||||
);
|
||||
};
|
87
client/src/components/tasks/TaskDetails.tsx
Normal file
87
client/src/components/tasks/TaskDetails.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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) {
|
||||
|
@ -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)
|
||||
|
@ -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);
|
||||
|
Reference in New Issue
Block a user