| @@ -144,22 +144,48 @@ const CreateProject: React.FC<Props> = ({ | |||
| // detect errors | |||
| let hasErrors = false | |||
| // Tab - Staff Allocation and Resource | |||
| if (data.totalManhour === null || data.totalManhour <= 0) { | |||
| formProps.setError("totalManhour", { message: "totalManhour value is not valid", type: "required" }) | |||
| setTabIndex(2) | |||
| hasErrors = true | |||
| } | |||
| const manhourPercentageByGradeKeys = Object.keys(data.manhourPercentageByGrade) | |||
| if (manhourPercentageByGradeKeys.filter(k => data.manhourPercentageByGrade[k as any] < 0).length > 0 || | |||
| manhourPercentageByGradeKeys.reduce((acc, value) => acc + data.manhourPercentageByGrade[value as any], 0) !== 1) { | |||
| manhourPercentageByGradeKeys.reduce((acc, value) => acc + data.manhourPercentageByGrade[value as any], 0) !== 100) { | |||
| formProps.setError("manhourPercentageByGrade", { message: "manhourPercentageByGrade value is not valid", type: "invalid" }) | |||
| setTabIndex(2) | |||
| hasErrors = true | |||
| } | |||
| const taskGroupKeys = Object.keys(data.taskGroups) | |||
| if (taskGroupKeys.filter(k => data.taskGroups[k as any].percentAllocation < 0).length > 0 || | |||
| taskGroupKeys.reduce((acc, value) => acc + data.taskGroups[value as any].percentAllocation, 0) !== 1) { | |||
| formProps.setError("taskGroups", {message: "Task Groups value is not invalid", type: "invalid"}) | |||
| taskGroupKeys.reduce((acc, value) => acc + data.taskGroups[value as any].percentAllocation, 0) !== 100) { | |||
| formProps.setError("taskGroups", { message: "Task Groups value is not invalid", type: "invalid" }) | |||
| setTabIndex(2) | |||
| hasErrors = true | |||
| } | |||
| // Tab - Milestone | |||
| let projectTotal = 0 | |||
| const milestonesKeys = Object.keys(data.milestones) | |||
| milestonesKeys.forEach(key => { | |||
| const { startDate, endDate, payments } = data.milestones[parseFloat(key)] | |||
| if (!Boolean(startDate) || startDate === "Invalid Date" || !Boolean(endDate) || endDate === "Invalid Date" || new Date(startDate) > new Date(endDate)) { | |||
| formProps.setError("milestones", {message: "milestones is not valid", type: "invalid"}) | |||
| setTabIndex(3) | |||
| hasErrors = true | |||
| } | |||
| projectTotal += payments.reduce((acc, payment) => acc + payment.amount, 0) | |||
| }) | |||
| if (projectTotal !== data.expectedProjectFee) { | |||
| formProps.setError("milestones", {message: "milestones is not valid", type: "invalid"}) | |||
| setTabIndex(3) | |||
| hasErrors = true | |||
| } | |||
| @@ -245,7 +271,7 @@ const CreateProject: React.FC<Props> = ({ | |||
| // manhourPercentageByGrade should have a sensible default | |||
| manhourPercentageByGrade: isEmpty(defaultInputs?.manhourPercentageByGrade) | |||
| ? grades.reduce((acc, grade) => { | |||
| return { ...acc, [grade.id]: 1 / grades.length }; | |||
| return { ...acc, [grade.id]: 100 / grades.length }; | |||
| }, {}) | |||
| : defaultInputs?.manhourPercentageByGrade, | |||
| }, | |||
| @@ -4,7 +4,7 @@ import Card from "@mui/material/Card"; | |||
| import CardContent from "@mui/material/CardContent"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import Button from "@mui/material/Button"; | |||
| import React, { useCallback, useMemo, useState } from "react"; | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import CardActions from "@mui/material/CardActions"; | |||
| import RestartAlt from "@mui/icons-material/RestartAlt"; | |||
| import { | |||
| @@ -29,7 +29,7 @@ export interface Props { | |||
| const Milestone: React.FC<Props> = ({ allTasks, isActive }) => { | |||
| const { t } = useTranslation(); | |||
| const { watch } = useFormContext<CreateProjectInputs>(); | |||
| const { watch, setError, clearErrors } = useFormContext<CreateProjectInputs>(); | |||
| const currentTaskGroups = watch("taskGroups"); | |||
| const taskGroups = useMemo( | |||
| () => | |||
| @@ -57,6 +57,35 @@ const Milestone: React.FC<Props> = ({ allTasks, isActive }) => { | |||
| [], | |||
| ); | |||
| // handle error checking | |||
| const milestones = watch("milestones") | |||
| const expectedTotalFee = watch("expectedProjectFee"); | |||
| useEffect(() => { | |||
| const milestonesKeys = Object.keys(milestones) | |||
| let hasError = false | |||
| let projectTotal = 0 | |||
| milestonesKeys.forEach(key => { | |||
| const { startDate, endDate, payments } = milestones[parseFloat(key)] | |||
| if (new Date(startDate) > new Date(endDate) || !Boolean(startDate) || !Boolean(endDate)) { | |||
| hasError = true | |||
| } | |||
| projectTotal += payments.reduce((acc, payment) => acc + payment.amount, 0) | |||
| }) | |||
| if (projectTotal !== expectedTotalFee) { | |||
| hasError = true | |||
| } | |||
| // console.log(Object.keys(milestones).reduce((acc, key) => acc + milestones[parseFloat(key)].payments.reduce((acc2, value) => acc2 + value.amount, 0), 0)) | |||
| if (hasError) { | |||
| setError("milestones", {message: "milestones is not valid", type: "invalid"}) | |||
| } else { | |||
| clearErrors("milestones") | |||
| } | |||
| }, [milestones]) | |||
| return ( | |||
| <> | |||
| <Card sx={{ display: isActive ? "block" : "none" }}> | |||
| @@ -26,7 +26,7 @@ import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
| import dayjs from "dayjs"; | |||
| import "dayjs/locale/zh-hk"; | |||
| import React, { useCallback, useEffect, useMemo, useState } from "react"; | |||
| import { useFormContext } from "react-hook-form"; | |||
| import { Controller, useFormContext } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import StyledDataGrid from "../StyledDataGrid"; | |||
| import { INPUT_DATE_FORMAT, moneyFormatter } from "@/app/utils/formatUtil"; | |||
| @@ -57,7 +57,9 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
| const apiRef = useGridApiRef(); | |||
| const addRow = useCallback(() => { | |||
| const id = Date.now(); | |||
| // const id = Date.now(); | |||
| const minId = Math.min(...payments.map((payment) => payment.id!!)); | |||
| const id = minId >= 0 ? -1 : minId - 1 | |||
| setPayments((p) => [...p, { id, _isNew: true }]); | |||
| setRowModesModel((model) => ({ | |||
| ...model, | |||
| @@ -239,21 +241,26 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
| <Grid container spacing={2} columns={{ xs: 6, sm: 12 }}> | |||
| <Grid item xs> | |||
| <FormControl fullWidth> | |||
| <DatePicker | |||
| label={t("Stage Start Date")} | |||
| value={startDate ? dayjs(startDate) : null} | |||
| onChange={(date) => { | |||
| if (!date) return; | |||
| const milestones = getValues("milestones"); | |||
| setValue("milestones", { | |||
| ...milestones, | |||
| [taskGroupId]: { | |||
| ...milestones[taskGroupId], | |||
| startDate: date.format(INPUT_DATE_FORMAT), | |||
| }, | |||
| }); | |||
| }} | |||
| /> | |||
| <DatePicker | |||
| label={t("Stage Start Date")} | |||
| value={startDate ? dayjs(startDate) : null} | |||
| onChange={(date) => { | |||
| if (!date) return; | |||
| const milestones = getValues("milestones"); | |||
| setValue("milestones", { | |||
| ...milestones, | |||
| [taskGroupId]: { | |||
| ...milestones[taskGroupId], | |||
| startDate: date.format(INPUT_DATE_FORMAT), | |||
| }, | |||
| }); | |||
| }} | |||
| slotProps={{ | |||
| textField: { | |||
| error: startDate === "Invalid Date" || new Date(startDate) > new Date(endDate) || (Boolean(formState.errors.milestones) && !Boolean(startDate)), | |||
| }, | |||
| }} | |||
| /> | |||
| </FormControl> | |||
| </Grid> | |||
| <Grid item xs> | |||
| @@ -272,6 +279,11 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
| }, | |||
| }); | |||
| }} | |||
| slotProps={{ | |||
| textField: { | |||
| error: endDate === "Invalid Date" || new Date(startDate) > new Date(endDate) || (Boolean(formState.errors.milestones) && !Boolean(endDate)), | |||
| }, | |||
| }} | |||
| /> | |||
| </FormControl> | |||
| </Grid> | |||
| @@ -2,7 +2,7 @@ import { CreateProjectInputs } from "@/app/api/projects/actions"; | |||
| import { TaskGroup } from "@/app/api/tasks"; | |||
| import { moneyFormatter } from "@/app/utils/formatUtil"; | |||
| import { Divider, Stack, Typography } from "@mui/material"; | |||
| import React, { useCallback, useEffect, useMemo } from "react"; | |||
| import React from "react"; | |||
| import { useFormContext } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| @@ -12,21 +12,12 @@ interface Props { | |||
| const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | |||
| const { t } = useTranslation(); | |||
| const { watch, setError, clearErrors } = useFormContext<CreateProjectInputs>(); | |||
| const { watch } = useFormContext<CreateProjectInputs>(); | |||
| const milestones = watch("milestones"); | |||
| const expectedTotalFee = watch("expectedProjectFee"); | |||
| let projectTotal = 0; | |||
| useEffect(() => { | |||
| console.log(Object.keys(milestones).reduce((acc, key) => acc + milestones[parseInt(key)].payments.reduce((acc2, value) => acc2 + value.amount, 0), 0)) | |||
| if (Object.keys(milestones).reduce((acc, key) => acc + milestones[parseInt(key)].payments.reduce((acc2, value) => acc2 + value.amount, 0), 0) !== expectedTotalFee) { | |||
| setError("milestones", {message: "project total is not valid", type: "invalid"}) | |||
| } else { | |||
| clearErrors("milestones") | |||
| } | |||
| }, [milestones]) | |||
| return ( | |||
| <Stack spacing={1}> | |||
| {taskGroups.map((group, index) => { | |||
| @@ -62,10 +62,10 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||
| const manhourPercentageByGrade = watch("manhourPercentageByGrade"); | |||
| const totalManhour = watch("totalManhour"); | |||
| const totalPercentage = Object.values(manhourPercentageByGrade).reduce( | |||
| const totalPercentage = Math.round(Object.values(manhourPercentageByGrade).reduce( | |||
| (acc, percent) => acc + percent, | |||
| 0, | |||
| ); | |||
| ) * 100) / 100; | |||
| const makeUpdatePercentage = useCallback( | |||
| (gradeId: Grade["id"]) => (percentage?: number) => { | |||
| @@ -78,7 +78,7 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||
| const keys = Object.keys(updatedManhourPercentageByGrade) | |||
| if (keys.filter(k => updatedManhourPercentageByGrade[k as any] < 0).length > 0 || | |||
| keys.reduce((acc, value) => acc + updatedManhourPercentageByGrade[value as any], 0) !== 1) { | |||
| keys.reduce((acc, value) => acc + updatedManhourPercentageByGrade[value as any], 0) !== 100) { | |||
| setError("manhourPercentageByGrade", {message: "manhourPercentageByGrade value is not valid", type: "invalid"}) | |||
| } else { | |||
| clearErrors("manhourPercentageByGrade") | |||
| @@ -134,7 +134,8 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||
| <TableCellEdit | |||
| key={`${column.id}${idx}`} | |||
| value={manhourPercentageByGrade[column.id]} | |||
| renderValue={(val) => percentFormatter.format(val)} | |||
| renderValue={(val) => val + "%"} | |||
| // renderValue={(val) => percentFormatter.format(val)} | |||
| onChange={makeUpdatePercentage(column.id)} | |||
| convertValue={(inputValue) => Number(inputValue)} | |||
| cellSx={{ backgroundColor: "primary.lightest" }} | |||
| @@ -142,8 +143,9 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||
| error={manhourPercentageByGrade[column.id] < 0} | |||
| /> | |||
| ))} | |||
| <TableCell sx={{ ...(totalPercentage === 1 && leftBorderCellSx), ...(totalPercentage !== 1 && {...errorCellSx, borderRight: "1px solid", borderColor: "error.main"})}}> | |||
| {percentFormatter.format(totalPercentage)} | |||
| <TableCell sx={{ ...(totalPercentage === 100 && leftBorderCellSx), ...(totalPercentage !== 100 && {...errorCellSx, borderRight: "1px solid", borderColor: "error.main"})}}> | |||
| {totalPercentage + "%"} | |||
| {/* {percentFormatter.format(totalPercentage)} */} | |||
| </TableCell> | |||
| </TableRow> | |||
| <TableRow> | |||
| @@ -151,7 +153,7 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||
| {grades.map((column, idx) => ( | |||
| <TableCell key={`${column.id}${idx}`}> | |||
| {manhourFormatter.format( | |||
| manhourPercentageByGrade[column.id] * totalManhour, | |||
| manhourPercentageByGrade[column.id] / 100 * totalManhour, | |||
| )} | |||
| </TableCell> | |||
| ))} | |||
| @@ -203,7 +205,7 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| const keys = Object.keys(updatedTaskGroups) | |||
| if (keys.filter(k => updatedTaskGroups[k as any].percentAllocation < 0).length > 0 || | |||
| keys.reduce((acc, value) => acc + updatedTaskGroups[value as any].percentAllocation, 0) !== 1) { | |||
| keys.reduce((acc, value) => acc + updatedTaskGroups[value as any].percentAllocation, 0) !== 100) { | |||
| setError("taskGroups", {message: "Task Groups value is not invalid", type: "invalid"}) | |||
| } else { | |||
| clearErrors("taskGroups") | |||
| @@ -250,7 +252,8 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| </TableCell> | |||
| <TableCellEdit | |||
| value={currentTaskGroups[tg.id].percentAllocation} | |||
| renderValue={(val) => percentFormatter.format(val)} | |||
| // renderValue={(val) => percentFormatter.format(val)} | |||
| renderValue={(val) => val + "%"} | |||
| onChange={makeUpdatePercentage(tg.id)} | |||
| convertValue={(inputValue) => Number(inputValue)} | |||
| cellSx={{ | |||
| @@ -261,7 +264,7 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| /> | |||
| <TableCell sx={rightBorderCellSx}> | |||
| {manhourFormatter.format( | |||
| currentTaskGroups[tg.id].percentAllocation * totalManhour, | |||
| currentTaskGroups[tg.id].percentAllocation / 100 * totalManhour, | |||
| )} | |||
| </TableCell> | |||
| {grades.map((column, idx) => { | |||
| @@ -270,7 +273,7 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| return ( | |||
| <TableCell key={`${column.id}${idx}`}> | |||
| {manhourFormatter.format( | |||
| manhourPercentageByGrade[column.id] * stageHours, | |||
| manhourPercentageByGrade[column.id] / 100 * stageHours, | |||
| )} | |||
| </TableCell> | |||
| ); | |||
| @@ -286,13 +289,13 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| )} | |||
| </TableCell> | |||
| <TableCell sx={{ | |||
| ...(Object.values(currentTaskGroups).reduce((acc, tg) => acc + tg.percentAllocation, 0,) === 1 && leftBorderCellSx), | |||
| ...(Object.values(currentTaskGroups).reduce((acc, tg) => acc + tg.percentAllocation, 0,) !== 1 && errorCellSx) | |||
| ...(Object.values(currentTaskGroups).reduce((acc, tg) => acc + tg.percentAllocation, 0,) === 100 && leftBorderCellSx), | |||
| ...(Object.values(currentTaskGroups).reduce((acc, tg) => acc + tg.percentAllocation, 0,) !== 100 && errorCellSx) | |||
| }} | |||
| > | |||
| {percentFormatter.format( | |||
| Object.values(currentTaskGroups).reduce( | |||
| (acc, tg) => acc + tg.percentAllocation, | |||
| (acc, tg) => acc + tg.percentAllocation / 100, | |||
| 0, | |||
| ), | |||
| )} | |||
| @@ -300,7 +303,7 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| <TableCell sx={rightBorderCellSx}> | |||
| {manhourFormatter.format( | |||
| Object.values(currentTaskGroups).reduce( | |||
| (acc, tg) => acc + tg.percentAllocation * totalManhour, | |||
| (acc, tg) => acc + tg.percentAllocation / 100 * totalManhour, | |||
| 0, | |||
| ), | |||
| )} | |||
| @@ -309,7 +312,7 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| const hours = Object.values(currentTaskGroups).reduce( | |||
| (acc, tg) => | |||
| acc + | |||
| tg.percentAllocation * | |||
| tg.percentAllocation / 100 * | |||
| totalManhour * | |||
| manhourPercentageByGrade[column.id], | |||
| 0, | |||
| @@ -52,7 +52,7 @@ const TaskSetup: React.FC<Props> = ({ | |||
| (e: SelectChangeEvent<number | "All">) => { | |||
| if (e.target.value === "All" || isNumber(e.target.value)) { | |||
| setSelectedTaskTemplateId(e.target.value); | |||
| onReset(); | |||
| // onReset(); | |||
| } | |||
| }, | |||
| [onReset], | |||