From e61d7c7a956d466faed9faae7ca48eaeae03812b Mon Sep 17 00:00:00 2001 From: raffifr098 Date: Thu, 30 Oct 2025 11:02:28 +0700 Subject: [PATCH 01/85] Fix some issues --- Backend/controllers/hr.controller.js | 14 ++++++++++++-- Backend/scripts/employee-import-template.xlsx | Bin 8026 -> 7993 bytes 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/Backend/controllers/hr.controller.js b/Backend/controllers/hr.controller.js index 27cd7e7..8291826 100644 --- a/Backend/controllers/hr.controller.js +++ b/Backend/controllers/hr.controller.js @@ -248,6 +248,11 @@ const importEmployees = async (req, res) => { const managerEmailRaw = row.managerEmail || row.ManagerEmail || null; const managerEmail = managerEmailRaw ? String(managerEmailRaw).toLowerCase() : null; const role = row.role || row.Role || 'staff'; + // Parse skills - dapat berupa string (comma-separated) atau array + let skills = row.skills || row.Skills || []; + if (typeof skills === 'string') { + // Jika skills dalam format string, split by comma dan bersihkan whitespace + skills = skills.split(',').map(s => s.trim()).filter(s => s); const rowResult = { row: rowIndex, errors: [], warnings: [], resolved: {} }; @@ -332,6 +337,7 @@ const importEmployees = async (req, res) => { position, managerId, role, + skills, // tambahkan skills ke object creation password: hashedPassword, }); @@ -372,10 +378,14 @@ const getImportTemplate = async (req, res) => { const csvPath = path.join(__dirname, '..', 'scripts', 'employee-import-template.csv'); const fs = require('fs'); if (format === 'xlsx' && fs.existsSync(xlsxPath)) { - return res.download(xlsxPath, 'employee-import-template.xlsx'); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', 'attachment; filename=employee-import-template.xlsx'); + return res.sendFile(xlsxPath); } if (format === 'csv' && fs.existsSync(csvPath)) { - return res.download(csvPath, 'employee-import-template.csv'); + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', 'attachment; filename=employee-import-template.csv'); + return res.sendFile(csvPath); } // fallback: error if file not found return res.status(404).json({ success: false, error: 'Template file not found' }); diff --git a/Backend/scripts/employee-import-template.xlsx b/Backend/scripts/employee-import-template.xlsx index bd8e0ddd5441928d2a97e0283cd64e9f3395c57a..10ddeb5f8922cea741945a41d21763b7fd7a00b3 100644 GIT binary patch delta 2978 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 Date: Thu, 30 Oct 2025 11:05:41 +0700 Subject: [PATCH 02/85] Work in progress before pulling --- Backend/controllers/hr.controller.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/Backend/controllers/hr.controller.js b/Backend/controllers/hr.controller.js index 8291826..94feeae 100644 --- a/Backend/controllers/hr.controller.js +++ b/Backend/controllers/hr.controller.js @@ -248,11 +248,6 @@ const importEmployees = async (req, res) => { const managerEmailRaw = row.managerEmail || row.ManagerEmail || null; const managerEmail = managerEmailRaw ? String(managerEmailRaw).toLowerCase() : null; const role = row.role || row.Role || 'staff'; - // Parse skills - dapat berupa string (comma-separated) atau array - let skills = row.skills || row.Skills || []; - if (typeof skills === 'string') { - // Jika skills dalam format string, split by comma dan bersihkan whitespace - skills = skills.split(',').map(s => s.trim()).filter(s => s); const rowResult = { row: rowIndex, errors: [], warnings: [], resolved: {} }; @@ -337,7 +332,6 @@ const importEmployees = async (req, res) => { position, managerId, role, - skills, // tambahkan skills ke object creation password: hashedPassword, }); From 12deec5c3c11ac8d612ba0a2b54106aaa8f83962 Mon Sep 17 00:00:00 2001 From: raffifr098 Date: Fri, 31 Oct 2025 13:13:33 +0700 Subject: [PATCH 03/85] - Add Endpoint GET Staff Project - Add Endpoint GET Staff Project Detail - Add Endpoint GET All Task For Spesific Project - Add Endpoint PUT Update Task Status --- .../controllers/project-task.controller.js | 360 ++++++++++++++++++ Backend/routes/project-task.routes.js | 117 ++++++ 2 files changed, 477 insertions(+) create mode 100644 Backend/controllers/project-task.controller.js create mode 100644 Backend/routes/project-task.routes.js diff --git a/Backend/controllers/project-task.controller.js b/Backend/controllers/project-task.controller.js new file mode 100644 index 0000000..b8e5658 --- /dev/null +++ b/Backend/controllers/project-task.controller.js @@ -0,0 +1,360 @@ +const { User, Project, ProjectAssignment, Task, TaskAssignment } = require('../models'); +const mongoose = require('mongoose'); + +const getStaffProjects = async (req, res) => { + try { + const userId = req.user.id; // Get from auth middleware + + // Get all project assignments for this user + const assignments = await ProjectAssignment.find({ userId }) + .populate({ + path: 'projectId', + select: 'name description status startDate deadline teamMemberCount', + populate: { + path: 'createdBy', + select: 'name email', + }, + }); + + // Map assignments to project details with role info + const projects = assignments.map(assignment => ({ + id: assignment.projectId._id, + name: assignment.projectId.name, + description: assignment.projectId.description, + status: assignment.projectId.status, + startDate: assignment.projectId.startDate, + deadline: assignment.projectId.deadline, + teamMemberCount: assignment.projectId.teamMemberCount, + manager: { + id: assignment.projectId.createdBy._id, + name: assignment.projectId.createdBy.name, + email: assignment.projectId.createdBy.email, + }, + role: assignment.isTechLead ? 'tech_lead' : 'member', + })); + + return res.json({ + success: true, + data: projects, + }); + } catch (err) { + return res.status(500).json({ + success: false, + error: 'Internal Server Error', + message: err.message, + }); + } +}; + +const getStaffProjectDetail = async (req, res) => { + try { + const { projectId } = req.params; + const userId = req.user.id; + + if (!mongoose.Types.ObjectId.isValid(projectId)) { + return res.status(400).json({ + success: false, + error: 'Invalid project ID format' + }); + } + + // Check if user is assigned to this project + const assignment = await ProjectAssignment.findOne({ + projectId, + userId + }); + + if (!assignment) { + return res.status(403).json({ + success: false, + error: 'Forbidden', + message: 'You are not assigned to this project', + }); + } + + // Get project details with manager info + const project = await Project.findById(projectId) + .populate('createdBy', 'name email'); + + if (!project) { + return res.status(404).json({ + success: false, + error: 'Not Found', + message: 'Project not found', + }); + } + + // Get all team members + const teamAssignments = await ProjectAssignment.find({ projectId }) + .populate('userId', 'name email position'); + + // Get all tasks for the project + const tasks = await Task.find({ projectId }) + .populate('requiredSkills', 'name') + .populate('createdBy', 'name email'); + + // Get task assignments + const taskAssignments = await TaskAssignment.find({ + taskId: { $in: tasks.map(t => t._id) } + }).populate('userId', 'name email'); + + // Map tasks with their assignments + const mappedTasks = tasks.map(task => ({ + id: task._id, + title: task.title, + description: task.description, + status: task.status, + startDate: task.startDate, + endDate: task.endDate, + requiredSkills: task.requiredSkills, + createdBy: { + id: task.createdBy._id, + name: task.createdBy.name, + email: task.createdBy.email, + }, + assignees: taskAssignments + .filter(ta => ta.taskId.equals(task._id)) + .map(ta => ({ + id: ta.userId._id, + name: ta.userId.name, + email: ta.userId.email, + })), + })); + + // Prepare response + const response = { + id: project._id, + name: project.name, + description: project.description, + status: project.status, + startDate: project.startDate, + deadline: project.deadline, + teamMemberCount: project.teamMemberCount, + manager: { + id: project.createdBy._id, + name: project.createdBy.name, + email: project.createdBy.email, + }, + userRole: assignment.isTechLead ? 'tech_lead' : 'member', + team: teamAssignments.map(ta => ({ + id: ta.userId._id, + name: ta.userId.name, + email: ta.userId.email, + position: ta.userId.position, + role: ta.isTechLead ? 'tech_lead' : 'member', + })), + tasks: mappedTasks, + }; + + return res.json({ + success: true, + data: response, + }); + } catch (err) { + return res.status(500).json({ + success: false, + error: 'Internal Server Error', + message: err.message, + }); + } +}; + +const getProjectTasks = async (req, res) => { + try { + const { projectId } = req.params; + const userId = req.user.id; + + if (!mongoose.Types.ObjectId.isValid(projectId)) { + return res.status(400).json({ + success: false, + error: 'Invalid project ID format' + }); + } + + // Check if user is assigned to this project + const assignment = await ProjectAssignment.findOne({ projectId, userId }); + if (!assignment) { + return res.status(403).json({ + success: false, + error: 'Forbidden', + message: 'You are not assigned to this project', + }); + } + + // Get all tasks for the project + const tasks = await Task.find({ projectId }) + .populate('requiredSkills', 'name') + .populate('createdBy', 'name email') + .sort({ createdAt: -1 }); + + // Get task assignments in one query + const taskAssignments = await TaskAssignment.find({ + taskId: { $in: tasks.map(t => t._id) } + }).populate('userId', 'name email'); + + // Map tasks with assignments + const mappedTasks = tasks.map(task => ({ + id: task._id, + title: task.title, + description: task.description, + status: task.status, + startDate: task.startDate, + endDate: task.endDate, + requiredSkills: task.requiredSkills.map(s => ({ + id: s._id, + name: s.name, + })), + createdBy: { + id: task.createdBy._id, + name: task.createdBy.name, + email: task.createdBy.email, + }, + assignees: taskAssignments + .filter(ta => ta.taskId.equals(task._id)) + .map(ta => ({ + id: ta.userId._id, + name: ta.userId.name, + email: ta.userId.email, + })), + createdAt: task.createdAt, + updatedAt: task.updatedAt, + })); + + return res.json({ + success: true, + data: mappedTasks, + }); + } catch (err) { + return res.status(500).json({ + success: false, + error: 'Internal Server Error', + message: err.message, + }); + } +}; + +const updateTaskStatus = async (req, res) => { + try { + const { taskId } = req.params; + const { status } = req.body; + const userId = req.user.id; + + if (!mongoose.Types.ObjectId.isValid(taskId)) { + return res.status(400).json({ + success: false, + error: 'Invalid task ID format' + }); + } + + // Get task with project info + const task = await Task.findById(taskId).select('projectId status'); + if (!task) { + return res.status(404).json({ + success: false, + error: 'Not Found', + message: 'Task not found', + }); + } + + // Check if user is assigned to this project + const projectAssignment = await ProjectAssignment.findOne({ + projectId: task.projectId, + userId + }); + if (!projectAssignment) { + return res.status(403).json({ + success: false, + error: 'Forbidden', + message: 'You are not assigned to this project', + }); + } + + // Check if user is assigned to this task + const taskAssignment = await TaskAssignment.findOne({ taskId, userId }); + if (!taskAssignment && !projectAssignment.isTechLead) { + return res.status(403).json({ + success: false, + error: 'Forbidden', + message: 'You are not assigned to this task', + }); + } + + // Validate status transition + const validTransitions = { + 'backlog': ['in_progress'], + 'in_progress': ['review', 'backlog'], + 'review': ['done', 'in_progress'], + 'done': ['in_progress'], + }; + + if (!validTransitions[task.status]?.includes(status)) { + return res.status(400).json({ + success: false, + error: 'Invalid Status Transition', + message: `Cannot transition from ${task.status} to ${status}`, + allowedTransitions: validTransitions[task.status], + }); + } + + // Update task status + const updatedTask = await Task.findByIdAndUpdate( + taskId, + { + status, + ...(status === 'in_progress' ? { startDate: new Date() } : {}), + ...(status === 'done' ? { endDate: new Date() } : {}), + }, + { new: true } + ) + .populate('requiredSkills', 'name') + .populate('createdBy', 'name email'); + + // Get task assignees + const assignees = await TaskAssignment.find({ taskId }) + .populate('userId', 'name email'); + + // Format response + const response = { + id: updatedTask._id, + title: updatedTask.title, + description: updatedTask.description, + status: updatedTask.status, + startDate: updatedTask.startDate, + endDate: updatedTask.endDate, + requiredSkills: updatedTask.requiredSkills.map(s => ({ + id: s._id, + name: s.name, + })), + createdBy: { + id: updatedTask.createdBy._id, + name: updatedTask.createdBy.name, + email: updatedTask.createdBy.email, + }, + assignees: assignees.map(a => ({ + id: a.userId._id, + name: a.userId.name, + email: a.userId.email, + })), + createdAt: updatedTask.createdAt, + updatedAt: updatedTask.updatedAt, + }; + + return res.json({ + success: true, + data: response, + }); + } catch (err) { + return res.status(500).json({ + success: false, + error: 'Internal Server Error', + message: err.message, + }); + } +}; + +module.exports = { + getStaffProjects, // DEV-61 + getStaffProjectDetail, // DEV-62 + getProjectTasks, // DEV-79 + updateTaskStatus, // DEV-80 +}; \ No newline at end of file diff --git a/Backend/routes/project-task.routes.js b/Backend/routes/project-task.routes.js new file mode 100644 index 0000000..703afd0 --- /dev/null +++ b/Backend/routes/project-task.routes.js @@ -0,0 +1,117 @@ +const express = require('express'); +const router = express.Router(); +const { + getStaffProjects, + getStaffProjectDetail, + getProjectTasks, + updateTaskStatus, +} = require('../controllers/project-task.controller'); +const verifyToken = require('../middlewares/token'); + +/** + * @swagger + * /project-tasks/staff/projects: + * get: + * summary: Get all projects where the authenticated user is a member + * tags: [Project Tasks] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: List of projects + * 401: + * description: Unauthorized + */ +router.get('/staff/projects', verifyToken, getStaffProjects); + +/** + * @swagger + * /project-tasks/staff/projects/{projectId}: + * get: + * summary: Get detailed project information including tasks and team members + * tags: [Project Tasks] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: projectId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: Project details with tasks + * 401: + * description: Unauthorized + * 403: + * description: Forbidden - Not a project member + * 404: + * description: Project not found + */ +router.get('/staff/projects/:projectId', verifyToken, getStaffProjectDetail); + +/** + * @swagger + * /project-tasks/projects/{projectId}/tasks: + * get: + * summary: Get all tasks for a specific project + * tags: [Project Tasks] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: projectId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: List of tasks + * 401: + * description: Unauthorized + * 403: + * description: Forbidden - Not a project member + * 404: + * description: Project not found + */ +router.get('/projects/:projectId/tasks', verifyToken, getProjectTasks); + +/** + * @swagger + * /project-tasks/tasks/{taskId}/status: + * put: + * summary: Update task status (e.g., To Do -> In Progress) + * tags: [Project Tasks] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: taskId + * required: true + * schema: + * type: string + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: string + * enum: [backlog, in_progress, review, done] + * responses: + * 200: + * description: Updated task details + * 400: + * description: Invalid status transition + * 401: + * description: Unauthorized + * 403: + * description: Forbidden - Not assigned to task/project + * 404: + * description: Task not found + */ +router.put('/tasks/:taskId/status', verifyToken, updateTaskStatus); + +module.exports = router; \ No newline at end of file From 630c07311ee4239a27789633171c398edea12d83 Mon Sep 17 00:00:00 2001 From: irsyadibdrrhmn Date: Fri, 31 Oct 2025 14:41:45 +0700 Subject: [PATCH 04/85] feat: integrate project-pm --- Backend/routes/position.routes.js | 20 +-- Frontend/src/App.jsx | 5 +- Frontend/src/pages/PM/CreateProject.jsx | 189 +++++++++++++--------- Frontend/src/pages/PM/ListProject.jsx | 195 +++++++++-------------- Frontend/src/services/project.service.js | 135 ++++++++++------ 5 files changed, 287 insertions(+), 257 deletions(-) diff --git a/Backend/routes/position.routes.js b/Backend/routes/position.routes.js index f1cfe84..3e00563 100644 --- a/Backend/routes/position.routes.js +++ b/Backend/routes/position.routes.js @@ -1,4 +1,4 @@ -const express = require('express'); +const express = require("express"); const { getPositions, createPosition, @@ -6,9 +6,9 @@ const { updatePosition, deletePosition, deleteMultiplePositions, -} = require('../controllers/position.controller'); -const auth = require('../middlewares/authorization'); -const verifyToken = require('../middlewares/token'); +} = require("../controllers/position.controller"); +const auth = require("../middlewares/authorization"); +const verifyToken = require("../middlewares/token"); const router = express.Router(); /** @@ -64,7 +64,7 @@ const router = express.Router(); * name: * type: string */ -router.get('/', verifyToken, auth('hr'), getPositions); +router.get("/", verifyToken, auth("hr", "manager"), getPositions); /** * @swagger @@ -90,7 +90,7 @@ router.get('/', verifyToken, auth('hr'), getPositions); * 400: * description: Bad request */ -router.post('/', verifyToken, auth('hr'), createPosition); +router.post("/", verifyToken, auth("hr"), createPosition); /** * @swagger @@ -122,7 +122,7 @@ router.post('/', verifyToken, auth('hr'), createPosition); * 404: * description: Position not found */ -router.put('/:positionId', verifyToken, auth('hr'), updatePosition); +router.put("/:positionId", verifyToken, auth("hr"), updatePosition); /** * @swagger @@ -144,7 +144,7 @@ router.put('/:positionId', verifyToken, auth('hr'), updatePosition); * 404: * description: Position not found */ -router.delete('/:positionId', verifyToken, auth('hr'), deletePosition); +router.delete("/:positionId", verifyToken, auth("hr"), deletePosition); /** * @swagger @@ -172,7 +172,7 @@ router.delete('/:positionId', verifyToken, auth('hr'), deletePosition); * 400: * description: Bad request */ -router.post('/batch', verifyToken, auth('hr'), createMultiplePositions); +router.post("/batch", verifyToken, auth("hr"), createMultiplePositions); /** * @swagger @@ -200,6 +200,6 @@ router.post('/batch', verifyToken, auth('hr'), createMultiplePositions); * 400: * description: Bad request */ -router.delete('/batch', verifyToken, auth('hr'), deleteMultiplePositions); +router.delete("/batch", verifyToken, auth("hr"), deleteMultiplePositions); module.exports = router; diff --git a/Frontend/src/App.jsx b/Frontend/src/App.jsx index 30ccbcd..c56c519 100644 --- a/Frontend/src/App.jsx +++ b/Frontend/src/App.jsx @@ -10,11 +10,14 @@ import Kanban from "@/pages/Kanban"; import Login from "@/pages/auth/Login"; // pastikan path-nya sesuai import ForgotPassword from "./pages/auth/ForgotPassword"; import ResetPassword from "@/pages/auth/ResetPassword"; -import ManageEmployee from "@/pages/HR/ManageEmployee"; +import ManageEmployee from "@/pages/HR/Employee/ManageEmployee"; +import EmployeeDetail from "./pages/HR/Employee/EmployeeDetail"; +import AddEmployee from "./pages/HR/Employee/AddEmployee"; import HRDashboard from "@/pages/HR/Dashboard"; import PMDashboard from "./pages/PM/Dashboard"; import StaffDashboard from "./pages/Staff/Dashboard"; import CreateProject from "./pages/PM/CreateProject"; +import ListProjects from "./pages/PM/ListProject"; // Layout import AppLayout from "@/components/layouts/AppLayout"; diff --git a/Frontend/src/pages/PM/CreateProject.jsx b/Frontend/src/pages/PM/CreateProject.jsx index 2514d54..c3990c4 100644 --- a/Frontend/src/pages/PM/CreateProject.jsx +++ b/Frontend/src/pages/PM/CreateProject.jsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; +import projectService from "../../services/project.service"; export default function CreateProject() { const navigate = useNavigate(); @@ -27,25 +28,27 @@ export default function CreateProject() { const [employees, setEmployees] = useState([]); const [selectedEmployees, setSelectedEmployees] = useState([]); const [isGenerating, setIsGenerating] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); - // TODO: Fetch skills from database + // Fetch skills from database useEffect(() => { fetchSkills(); }, []); - // TODO: Fetch positions from database + // Fetch positions from database useEffect(() => { fetchPositions(); }, []); - // TODO: API - Fetch all skills from database + // API - Fetch all skills from database const fetchSkills = async () => { try { - // const response = await fetch('/api/skills'); - // const data = await response.json(); - // setSkills(data); - - // TEMPORARY: Mock data + const response = await projectService.getAllSkills(); + // Sesuaikan dengan struktur response dari controller: response.skills + setSkills(response.skills || []); // Mengambil array skills dari objek response + } catch (error) { + console.error("Error fetching skills:", error); + // Fallback to mock data if error setSkills([ { _id: "1", name: "React.js" }, { _id: "2", name: "Node.js" }, @@ -58,19 +61,18 @@ export default function CreateProject() { { _id: "9", name: "Cypress" }, { _id: "10", name: "CI/CD" }, ]); - } catch (error) { - console.error("Error fetching skills:", error); } }; - // TODO: API - Fetch all positions from database + // API - Fetch all positions from database const fetchPositions = async () => { try { - // const response = await fetch('/api/positions'); - // const data = await response.json(); - // setPositions(data); - - // TEMPORARY: Mock data + const response = await projectService.getAllPositions(); + // Sesuaikan dengan struktur response dari controller: response.positions + setPositions(response.positions || []); // Mengambil array positions dari objek response + } catch (error) { + console.error("Error fetching positions:", error); + // Fallback to mock data if error setPositions([ { _id: "1", name: "Frontend Developer" }, { _id: "2", name: "Backend Developer" }, @@ -80,19 +82,31 @@ export default function CreateProject() { { _id: "6", name: "DevOps Engineer" }, { _id: "7", name: "Project Manager" }, ]); - } catch (error) { - console.error("Error fetching positions:", error); } }; - // TODO: API - Fetch all employees (will be replaced with AI recommendations) + // API - Fetch all employees (will be replaced with AI recommendations) const fetchAllEmployees = async () => { try { - // const response = await fetch('/api/employees'); - // const data = await response.json(); - // setEmployees(data); - - // TEMPORARY: Mock data - showing all employees + // response sekarang adalah langsung array karyawan + const employeesList = await projectService.getAllEmployees(); + + // Transform employees to match UI format + const transformedEmployees = employeesList.map((emp) => ({ + _id: emp.id, + name: emp.name, + position: emp.position || { name: "Not Assigned" }, + skills: emp.skills || [], + // TODO: Calculate workload from active projects + currentWorkload: 0, + availability: "Available", + matchingPercentage: 100, + })); + + setEmployees(transformedEmployees); + } catch (error) { + console.error("Error fetching employees:", error); + // Fallback to mock data if error setEmployees([ { _id: "1", @@ -130,8 +144,6 @@ export default function CreateProject() { matchingPercentage: 80, }, ]); - } catch (error) { - console.error("Error fetching employees:", error); } }; @@ -178,29 +190,26 @@ export default function CreateProject() { const handleGenerateRecommendations = async () => { setIsGenerating(true); - // TODO: API - Call AI recommendation endpoint + // TODO: API - Call AI recommendation endpoint (not ready yet) try { - // const response = await fetch('/api/projects/recommend-team', { - // method: 'POST', - // headers: { 'Content-Type': 'application/json' }, - // body: JSON.stringify({ - // skills: selectedSkills.map(s => s._id), - // positions: teamPositions, - // projectId: formData.projectId // if editing - // }) - // }); - // const data = await response.json(); - // setEmployees(data.recommendations); - - // TEMPORARY: Just fetch all employees + // For now, just fetch all employees + // Later replace with: await projectService.getTeamRecommendations({...}) await fetchAllEmployees(); } catch (error) { console.error("Error generating recommendations:", error); + alert(error.message || "Failed to generate recommendations"); } finally { setIsGenerating(false); } }; + const handleCheckboxClick = (e, employeeId) => { + // e.stopPropagation() mencegah event click naik ke
parent + e.stopPropagation(); + // Panggil fungsi toggle yang sudah ada + handleToggleEmployee(employeeId); + }; + const handleToggleEmployee = (employeeId) => { if (selectedEmployees.includes(employeeId)) { setSelectedEmployees(selectedEmployees.filter((id) => id !== employeeId)); @@ -210,51 +219,69 @@ export default function CreateProject() { }; const handleAutoAssignBestTeam = () => { - // TODO: API - Auto assign best team based on AI - // For now, select top 2 available employees + // TODO: API - Auto assign best team based on AI (not ready yet) + // For now, select top 3 available employees const availableEmployees = employees .filter((e) => e.availability === "Available") - .slice(0, 2) + .slice(0, 3) .map((e) => e._id); setSelectedEmployees(availableEmployees); }; const handleSubmit = async () => { - // TODO: API - Create project + // Validation + if (!formData.projectName.trim()) { + alert("Project name is required"); + return; + } + + if (!formData.projectDescription.trim()) { + alert("Project description is required"); + return; + } + + if (selectedEmployees.length === 0) { + alert("At least one staff member must be assigned to the project"); + return; + } + + setIsSubmitting(true); + try { - // const response = await fetch('/api/projects', { - // method: 'POST', - // headers: { - // 'Content-Type': 'application/json', - // 'Authorization': `Bearer ${localStorage.getItem('token')}` - // }, - // body: JSON.stringify({ - // name: formData.projectName, - // description: formData.projectDescription, - // startDate: formData.startDate, - // deadline: formData.deadline, - // requiredSkills: selectedSkills.map(s => s._id), - // teamPositions: teamPositions, - // teamMembers: selectedEmployees, - // }) - // }); - // const data = await response.json(); - - console.log("Project Data to Submit:", { + // Prepare project data according to backend API + const projectData = { name: formData.projectName, description: formData.projectDescription, - startDate: formData.startDate, - deadline: formData.deadline, - requiredSkills: selectedSkills.map((s) => s._id), - teamPositions: teamPositions, - teamMembers: selectedEmployees, - }); - - alert("Project created successfully! (Mock)"); - // navigate('/dashboard-pm'); + staffIds: selectedEmployees, // Array of employee IDs + }; + + // Add optional fields only if they have values + if (formData.startDate) { + projectData.startDate = formData.startDate; + } + + if (formData.deadline) { + projectData.deadline = formData.deadline; + } + + console.log("Submitting project data:", projectData); + + // Call API to create project with assignments + const response = await projectService.createProjectWithAssignments( + projectData + ); + + if (response.success) { + alert( + `Project "${response.data.project.name}" created successfully with ${selectedEmployees.length} staff members assigned!` + ); + navigate("/projects"); // Navigate to projects list + } } catch (error) { console.error("Error creating project:", error); - alert("Failed to create project"); + alert(error.message || "Failed to create project. Please try again."); + } finally { + setIsSubmitting(false); } }; @@ -388,7 +415,7 @@ export default function CreateProject() { @@ -446,7 +473,7 @@ export default function CreateProject() { /> @@ -511,9 +538,10 @@ export default function CreateProject() {
@@ -534,14 +562,17 @@ export default function CreateProject() { ? "border-blue-500 bg-blue-50" : getMatchingColor(employee.matchingPercentage) }`} - onClick={() => handleToggleEmployee(employee._id)} + onClick={() => handleToggleEmployee(employee._id)} // <-- Biarkan handler ini tetap ada untuk klik di area kartu >
{}} + // Gunakan fungsi baru untuk menangani klik pada checkbox + onChange={(e) => + handleCheckboxClick(e, employee._id) + } // <--- PERUBAHAN DI SINI className="absolute top-0 left-0 w-5 h-5" />
diff --git a/Frontend/src/pages/PM/ListProject.jsx b/Frontend/src/pages/PM/ListProject.jsx index 54a4247..52dc494 100644 --- a/Frontend/src/pages/PM/ListProject.jsx +++ b/Frontend/src/pages/PM/ListProject.jsx @@ -1,109 +1,74 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import projectService from "../../services/project.service"; export default function ListProjects() { + const navigate = useNavigate(); const [projects, setProjects] = useState([]); const [filteredProjects, setFilteredProjects] = useState([]); const [activeFilter, setActiveFilter] = useState("All"); const [filters, setFilters] = useState({ - status: "", deadline: "", teamSize: "", }); const [isLoading, setIsLoading] = useState(false); - // TODO: Fetch projects from API on component mount + // Fetch projects from API on component mount useEffect(() => { fetchProjects(); }, []); - // TODO: API - Fetch all projects + // API - Fetch all projects const fetchProjects = async () => { setIsLoading(true); try { - // const response = await projectService.getAllProjects(); - // setProjects(response.data); - // setFilteredProjects(response.data); + const response = await projectService.getAllProjects(); + // Response: { success: true, data: { page, perPage, total, projects: [...] } } + const projectsList = response.data.projects || []; - // TEMPORARY: Mock data - const mockProjects = [ - { - _id: "1", - name: "HRIS System Redesign", - description: - "Developing a new human resources information system to improve user experience and functionality.", - status: "In Progress", - deadline: "2024-12-15", - teamMembers: [ - { _id: "1", name: "John Doe" }, - { _id: "2", name: "Jane Smith" }, - { _id: "3", name: "Mike Johnson" }, - ], - }, - { - _id: "2", - name: "Onboarding Process", - description: - "Automating the new employee onboarding process to reduce manual work and improve efficiency.", - status: "Completed", - deadline: "2024-10-31", - teamMembers: [ - { _id: "4", name: "Sarah Williams" }, - { _id: "5", name: "David Brown" }, - { _id: "6", name: "Emily Davis" }, - ], - }, - { - _id: "3", - name: "Mobile App Development", - description: - "Creating a new mobile application for employees to access company resources on-the-go.", - status: "On Hold", - deadline: "2025-03-01", - teamMembers: [ - { _id: "7", name: "Chris Wilson" }, - { _id: "8", name: "Lisa Anderson" }, - ], - }, - { - _id: "4", - name: "Q4 Performance Review Cycle", - description: - "Planning and executing the Q4 quarterly performance review process for all departments.", - status: "Overdue", - deadline: "2023-11-30", - teamMembers: [ - { _id: "9", name: "Robert Taylor" }, - { _id: "10", name: "Jennifer Martinez" }, - ], - }, - ]; - setProjects(mockProjects); - setFilteredProjects(mockProjects); + // Transform projects to add computed fields + const transformedProjects = projectsList.map((project) => ({ + ...project, + // Map status to display status + displayStatus: getDisplayStatus(project.status, project.deadline), + // Ensure teamMembers exists (may need separate API call for full details) + teamMembers: project.teamMembers || [], + })); + + setProjects(transformedProjects); + setFilteredProjects(transformedProjects); } catch (error) { console.error("Error fetching projects:", error); + alert(error.message || "Failed to fetch projects"); } finally { setIsLoading(false); } }; - // Filter projects based on active filter - useEffect(() => { - filterProjects(); - }, [activeFilter, filters, projects]); + // Convert backend status to display status + const getDisplayStatus = (status, deadline) => { + // Backend only has: 'active', 'completed' + if (status === "completed") return "Completed"; + if (status === "active") { + // Check if overdue + if (deadline && new Date(deadline) < new Date()) { + return "Overdue"; + } + return "In Progress"; + } + return "In Progress"; + }; - const filterProjects = () => { + // Filter projects based on active filter + const filterProjects = useCallback(() => { let filtered = [...projects]; // Filter by status tab if (activeFilter !== "All") { - filtered = filtered.filter((p) => p.status === activeFilter); - } - - // Filter by dropdown filters - if (filters.status) { - filtered = filtered.filter((p) => p.status === filters.status); + filtered = filtered.filter((p) => p.displayStatus === activeFilter); } + // Filter by deadline sort if (filters.deadline) { filtered = filtered.sort((a, b) => { if (filters.deadline === "Earliest") { @@ -114,18 +79,25 @@ export default function ListProjects() { }); } + // Filter by team size sort if (filters.teamSize) { filtered = filtered.sort((a, b) => { + const aSize = a.teamMemberCount || 0; + const bSize = b.teamMemberCount || 0; if (filters.teamSize === "Smallest") { - return a.teamMembers.length - b.teamMembers.length; + return aSize - bSize; } else { - return b.teamMembers.length - a.teamMembers.length; + return bSize - aSize; } }); } setFilteredProjects(filtered); - }; + }, [activeFilter, filters, projects]); + + useEffect(() => { + filterProjects(); + }, [filterProjects]); const handleFilterChange = (filterName, value) => { setFilters((prev) => ({ @@ -138,13 +110,13 @@ export default function ListProjects() { const colors = { "In Progress": "bg-blue-100 text-blue-700", Completed: "bg-green-100 text-green-700", - "On Hold": "bg-yellow-100 text-yellow-700", Overdue: "bg-red-100 text-red-700", }; return colors[status] || "bg-gray-100 text-gray-700"; }; const formatDate = (dateString) => { + if (!dateString) return "No deadline"; const date = new Date(dateString); return date.toLocaleDateString("en-US", { year: "numeric", @@ -153,22 +125,19 @@ export default function ListProjects() { }); }; - // TODO: Navigate to project details + // Navigate to project details const handleViewDetails = (projectId) => { - console.log("View details for project:", projectId); - // navigate(`/projects/${projectId}/details`); + navigate(`/projects/${projectId}/details`); }; - // TODO: Navigate to project kanban board + // Navigate to project kanban board const handleViewKanban = (projectId) => { - console.log("View kanban for project:", projectId); - // navigate(`/projects/${projectId}/kanban`); + navigate(`/projects/${projectId}/kanban`); }; - // TODO: Navigate to create project page + // Navigate to create project page const handleCreateProject = () => { - console.log("Navigate to create project"); - // navigate('/create-project'); + navigate("/create-project"); }; return ( @@ -187,39 +156,25 @@ export default function ListProjects() { {/* Filters */}
- {/* Status Tabs */} + {/* Status Tabs - removed "On Hold" since backend doesn't have it */}
- {["All", "In Progress", "Completed", "On Hold", "Overdue"].map( - (status) => ( - - ) - )} + {["All", "In Progress", "Completed", "Overdue"].map((status) => ( + + ))}
{/* Dropdown Filters */}
- {/* */} - + {/* setNewTask(e.target.value)} placeholder="New task..." - /> - + /> */} + + + + + + {/*
*/} + + Add New Task + +
+
+ + +
+
+ +