| @@ -78,6 +78,14 @@ const hasErrorsInTab = ( | |||
| return ( | |||
| errors.projectName || errors.projectCode || errors.projectDescription | |||
| ); | |||
| case 2: | |||
| return ( | |||
| errors.totalManhour || errors.manhourPercentageByGrade || errors.taskGroups | |||
| ); | |||
| case 3: | |||
| return ( | |||
| errors.milestones | |||
| ) | |||
| default: | |||
| false; | |||
| } | |||
| @@ -132,7 +140,31 @@ const CreateProject: React.FC<Props> = ({ | |||
| const onSubmit = useCallback<SubmitHandler<CreateProjectInputs>>( | |||
| async (data, event) => { | |||
| try { | |||
| console.log("first"); | |||
| console.log(data); | |||
| // detect errors | |||
| let hasErrors = false | |||
| if (data.totalManhour === null || data.totalManhour <= 0) { | |||
| formProps.setError("totalManhour", { message: "totalManhour value is not valid", type: "required" }) | |||
| 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) { | |||
| formProps.setError("manhourPercentageByGrade", { message: "manhourPercentageByGrade value is not valid", type: "invalid" }) | |||
| 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"}) | |||
| hasErrors = true | |||
| } | |||
| if (hasErrors) return false | |||
| // save project | |||
| setServerError(""); | |||
| let title = t("Do you want to submit?"); | |||
| @@ -185,6 +217,7 @@ const CreateProject: React.FC<Props> = ({ | |||
| const onSubmitError = useCallback<SubmitErrorHandler<CreateProjectInputs>>( | |||
| (errors) => { | |||
| console.log(errors) | |||
| // Set the tab so that the focus will go there | |||
| if ( | |||
| errors.projectName || | |||
| @@ -192,6 +225,10 @@ const CreateProject: React.FC<Props> = ({ | |||
| errors.projectCode | |||
| ) { | |||
| setTabIndex(0); | |||
| } else if (errors.totalManhour || errors.manhourPercentageByGrade || errors.taskGroups) { | |||
| setTabIndex(2) | |||
| } else if (errors.milestones) { | |||
| setTabIndex(3) | |||
| } | |||
| }, | |||
| [], | |||
| @@ -208,8 +245,8 @@ 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]: 1 / grades.length }; | |||
| }, {}) | |||
| : defaultInputs?.manhourPercentageByGrade, | |||
| }, | |||
| }); | |||
| @@ -253,15 +290,15 @@ const CreateProject: React.FC<Props> = ({ | |||
| formProps.getValues("projectActualStart") && | |||
| formProps.getValues("projectActualEnd") | |||
| ) && ( | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<Delete />} | |||
| color="error" | |||
| onClick={handleDelete} | |||
| > | |||
| {t("Delete Project")} | |||
| </Button> | |||
| )} | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<Delete />} | |||
| color="error" | |||
| onClick={handleDelete} | |||
| > | |||
| {t("Delete Project")} | |||
| </Button> | |||
| )} | |||
| </Stack> | |||
| )} | |||
| <Tabs | |||
| @@ -271,6 +308,7 @@ const CreateProject: React.FC<Props> = ({ | |||
| > | |||
| <Tab | |||
| label={t("Project and Client Details")} | |||
| sx={{ marginInlineEnd: !hasErrorsInTab(1, errors) && (hasErrorsInTab(2, errors) || hasErrorsInTab(3, errors)) ? 1 : undefined }} | |||
| icon={ | |||
| hasErrorsInTab(0, errors) ? ( | |||
| <Error sx={{ marginInlineEnd: 1 }} color="error" /> | |||
| @@ -278,12 +316,26 @@ const CreateProject: React.FC<Props> = ({ | |||
| } | |||
| iconPosition="end" | |||
| /> | |||
| <Tab label={t("Project Task Setup")} iconPosition="end" /> | |||
| <Tab | |||
| label={t("Project Task Setup")} | |||
| sx={{ marginInlineEnd: hasErrorsInTab(2, errors) || hasErrorsInTab(3, errors) ? 1 : undefined }} | |||
| iconPosition="end" /> | |||
| <Tab | |||
| label={t("Staff Allocation and Resource")} | |||
| sx={{ marginInlineEnd: !hasErrorsInTab(2, errors) && hasErrorsInTab(3, errors) ? 1 : undefined }} | |||
| icon={ | |||
| hasErrorsInTab(2, errors) ? ( | |||
| <Error sx={{ marginInlineEnd: 1 }} color="error" /> | |||
| ) : undefined | |||
| } | |||
| iconPosition="end" | |||
| /> | |||
| <Tab label={t("Milestone")} iconPosition="end" /> | |||
| <Tab label={t("Milestone")} | |||
| icon={ | |||
| hasErrorsInTab(3, errors) ? ( | |||
| <Error sx={{ marginInlineEnd: 1 }} color="error" />) | |||
| : undefined} | |||
| iconPosition="end" /> | |||
| </Tabs> | |||
| { | |||
| <ProjectClientDetails | |||
| @@ -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 from "react"; | |||
| import React, { useCallback, useEffect, useMemo } from "react"; | |||
| import { useFormContext } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| @@ -12,17 +12,27 @@ interface Props { | |||
| const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | |||
| const { t } = useTranslation(); | |||
| const { watch } = useFormContext<CreateProjectInputs>(); | |||
| const { watch, setError, clearErrors } = 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) => { | |||
| const payments = milestones[group.id]?.payments || []; | |||
| const paymentTotal = payments.reduce((acc, p) => acc + p.amount, 0); | |||
| projectTotal += paymentTotal; | |||
| return ( | |||
| @@ -41,9 +51,9 @@ const ProjectTotalFee: React.FC<Props> = ({ taskGroups }) => { | |||
| <Typography variant="h6">{t("Project Total Fee")}</Typography> | |||
| <Typography>{moneyFormatter.format(projectTotal)}</Typography> | |||
| </Stack> | |||
| {projectTotal > expectedTotalFee && ( | |||
| {projectTotal !== expectedTotalFee && ( | |||
| <Typography variant="caption" color="warning.main" alignSelf="flex-end"> | |||
| {t("Project total fee is larger than the expected total fee!")} | |||
| {t("Project total fee should be same as the expected total fee!")} | |||
| </Typography> | |||
| )} | |||
| </Stack> | |||
| @@ -45,9 +45,20 @@ const leftRightBorderCellSx: SxProps = { | |||
| borderColor: "divider", | |||
| }; | |||
| const errorCellSx: SxProps = { | |||
| outline: "1px solid", | |||
| outlineColor: "error.main", | |||
| // borderLeft: "1px solid", | |||
| // borderRight: "1px solid", | |||
| // borderTop: "1px solid", | |||
| // borderBottom: "1px solid", | |||
| // borderColor: 'error.main' | |||
| } | |||
| const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||
| const { t } = useTranslation(); | |||
| const { watch, register, setValue } = useFormContext<CreateProjectInputs>(); | |||
| const { watch, register, setValue, formState: { errors }, setError, clearErrors } = useFormContext<CreateProjectInputs>(); | |||
| const manhourPercentageByGrade = watch("manhourPercentageByGrade"); | |||
| const totalManhour = watch("totalManhour"); | |||
| @@ -59,10 +70,20 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||
| const makeUpdatePercentage = useCallback( | |||
| (gradeId: Grade["id"]) => (percentage?: number) => { | |||
| if (percentage !== undefined) { | |||
| setValue("manhourPercentageByGrade", { | |||
| const updatedManhourPercentageByGrade = { | |||
| ...manhourPercentageByGrade, | |||
| [gradeId]: percentage, | |||
| }); | |||
| } | |||
| setValue("manhourPercentageByGrade", updatedManhourPercentageByGrade); | |||
| 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) { | |||
| setError("manhourPercentageByGrade", {message: "manhourPercentageByGrade value is not valid", type: "invalid"}) | |||
| } else { | |||
| clearErrors("manhourPercentageByGrade") | |||
| } | |||
| } | |||
| }, | |||
| [manhourPercentageByGrade, setValue], | |||
| @@ -79,7 +100,10 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||
| type="number" | |||
| {...register("totalManhour", { | |||
| valueAsNumber: true, | |||
| required: "totalManhour code required!", | |||
| min: 1, | |||
| })} | |||
| error={Boolean(errors.totalManhour)} | |||
| /> | |||
| <Box | |||
| sx={(theme) => ({ | |||
| @@ -115,9 +139,10 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||
| convertValue={(inputValue) => Number(inputValue)} | |||
| cellSx={{ backgroundColor: "primary.lightest" }} | |||
| inputSx={{ width: "3rem" }} | |||
| error={manhourPercentageByGrade[column.id] < 0} | |||
| /> | |||
| ))} | |||
| <TableCell sx={leftBorderCellSx}> | |||
| <TableCell sx={{ ...(totalPercentage === 1 && leftBorderCellSx), ...(totalPercentage !== 1 && {...errorCellSx, borderRight: "1px solid", borderColor: "error.main"})}}> | |||
| {percentFormatter.format(totalPercentage)} | |||
| </TableCell> | |||
| </TableRow> | |||
| @@ -144,7 +169,7 @@ const ResourceAllocationByGrade: React.FC<Props> = ({ grades }) => { | |||
| const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| const { t } = useTranslation(); | |||
| const { watch, setValue } = useFormContext<CreateProjectInputs>(); | |||
| const { watch, setValue, clearErrors, setError } = useFormContext<CreateProjectInputs>(); | |||
| const currentTaskGroups = watch("taskGroups"); | |||
| const taskGroups = useMemo( | |||
| @@ -167,13 +192,22 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| const makeUpdatePercentage = useCallback( | |||
| (taskGroupId: TaskGroup["id"]) => (percentage?: number) => { | |||
| if (percentage !== undefined) { | |||
| setValue("taskGroups", { | |||
| const updatedTaskGroups = { | |||
| ...currentTaskGroups, | |||
| [taskGroupId]: { | |||
| ...currentTaskGroups[taskGroupId], | |||
| percentAllocation: percentage, | |||
| }, | |||
| }); | |||
| } | |||
| setValue("taskGroups", updatedTaskGroups); | |||
| 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) { | |||
| setError("taskGroups", {message: "Task Groups value is not invalid", type: "invalid"}) | |||
| } else { | |||
| clearErrors("taskGroups") | |||
| } | |||
| } | |||
| }, | |||
| [currentTaskGroups, setValue], | |||
| @@ -219,8 +253,11 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| renderValue={(val) => percentFormatter.format(val)} | |||
| onChange={makeUpdatePercentage(tg.id)} | |||
| convertValue={(inputValue) => Number(inputValue)} | |||
| cellSx={{ backgroundColor: "primary.lightest" }} | |||
| cellSx={{ | |||
| backgroundColor: "primary.lightest", | |||
| }} | |||
| inputSx={{ width: "3rem" }} | |||
| error={currentTaskGroups[tg.id].percentAllocation < 0} | |||
| /> | |||
| <TableCell sx={rightBorderCellSx}> | |||
| {manhourFormatter.format( | |||
| @@ -248,7 +285,11 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| 0, | |||
| )} | |||
| </TableCell> | |||
| <TableCell sx={leftBorderCellSx}> | |||
| <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) | |||
| }} | |||
| > | |||
| {percentFormatter.format( | |||
| Object.values(currentTaskGroups).reduce( | |||
| (acc, tg) => acc + tg.percentAllocation, | |||
| @@ -269,8 +310,8 @@ const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => { | |||
| (acc, tg) => | |||
| acc + | |||
| tg.percentAllocation * | |||
| totalManhour * | |||
| manhourPercentageByGrade[column.id], | |||
| totalManhour * | |||
| manhourPercentageByGrade[column.id], | |||
| 0, | |||
| ); | |||
| return ( | |||
| @@ -127,7 +127,7 @@ const NavigationContent: React.FC<Props> = ({ abilities }) => { | |||
| {icon: <Analytics />, label:"Project Claims Report", path: "/analytics/ProjectClaimsReport"}, | |||
| {icon: <Analytics />, label:"Project P&L Report", path: "/analytics/ProjectPLReport"}, | |||
| {icon: <Analytics />, label:"Financial Status Report", path: "/analytics/FinancialStatusReport"}, | |||
| {icon: <Analytics />, label:"EX02 - Project Cash Flow Report", path: "/analytics/EX02ProjectCashFlowReport"}, | |||
| {icon: <Analytics />, label:"Project Cash Flow Report", path: "/analytics/ProjectCashFlowReport"}, | |||
| ], | |||
| }, | |||
| { | |||
| @@ -6,6 +6,7 @@ import React, { | |||
| useState, | |||
| } from "react"; | |||
| import { Box, Input, SxProps, TableCell } from "@mui/material"; | |||
| import palette from "@/theme/devias-material-kit/palette"; | |||
| interface Props<T> { | |||
| value: T; | |||
| @@ -14,6 +15,7 @@ interface Props<T> { | |||
| convertValue: (inputValue: string) => T; | |||
| cellSx?: SxProps; | |||
| inputSx?: SxProps; | |||
| error?: Boolean; | |||
| } | |||
| const TableCellEdit = <T,>({ | |||
| @@ -23,8 +25,10 @@ const TableCellEdit = <T,>({ | |||
| onChange, | |||
| cellSx, | |||
| inputSx, | |||
| error, | |||
| }: Props<T>) => { | |||
| const [editMode, setEditMode] = useState(false); | |||
| // const [afterEdit, setAfterEdit] = useState(false); | |||
| const [input, setInput] = useState<string>(""); | |||
| const inputRef = useRef<HTMLInputElement>(null); | |||
| @@ -40,6 +44,7 @@ const TableCellEdit = <T,>({ | |||
| const onBlur = useCallback(() => { | |||
| setEditMode(false); | |||
| // setAfterEdit(true) | |||
| onChange(convertValue(input)); | |||
| setInput(""); | |||
| }, [convertValue, input, onChange]); | |||
| @@ -53,8 +58,8 @@ const TableCellEdit = <T,>({ | |||
| return ( | |||
| <TableCell | |||
| sx={{ | |||
| outline: editMode ? "1px solid" : undefined, | |||
| outlineColor: editMode ? "primary.main" : undefined, | |||
| outline: editMode && !error ? "1px solid" : error ? "1px solid" : undefined, | |||
| outlineColor: editMode && !error ? "primary.main" : error ? "error.main" : undefined, | |||
| ...cellSx, | |||
| }} | |||
| > | |||
| @@ -76,7 +81,7 @@ const TableCellEdit = <T,>({ | |||
| onBlur={onBlur} | |||
| type={typeof value === "number" ? "number" : "text"} | |||
| /> | |||
| <Box sx={{ display: editMode ? "none" : "block" }} onClick={onClick}> | |||
| <Box sx={{ display: editMode ? "none" : "block" }} onClick={onClick}> | |||
| {renderValue(value)} | |||
| </Box> | |||
| </TableCell> | |||