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