@@ -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> | |||