From 046c584c58ab69be0914108c1bb10ad9b99689cd Mon Sep 17 00:00:00 2001 From: Wayne Date: Thu, 22 Feb 2024 14:45:32 +0900 Subject: [PATCH] Add task breakdown --- src/app/(main)/projects/create/page.tsx | 6 +- src/app/api/projects/actions.ts | 8 +- .../CreateProject/ResourceMilestone.tsx | 9 +- .../CreateProject/ResourceSection.tsx | 170 +++++++++++++++++- src/i18n/en/common.json | 3 + src/i18n/en/projects.json | 1 + src/i18n/en/translation.json | 3 - src/i18n/zh/common.json | 1 + src/i18n/zh/projects.json | 1 + src/i18n/zh/translation.json | 3 - 10 files changed, 181 insertions(+), 24 deletions(-) create mode 100644 src/i18n/en/common.json create mode 100644 src/i18n/en/projects.json delete mode 100644 src/i18n/en/translation.json create mode 100644 src/i18n/zh/common.json create mode 100644 src/i18n/zh/projects.json delete mode 100644 src/i18n/zh/translation.json diff --git a/src/app/(main)/projects/create/page.tsx b/src/app/(main)/projects/create/page.tsx index ebe69cf..60ab586 100644 --- a/src/app/(main)/projects/create/page.tsx +++ b/src/app/(main)/projects/create/page.tsx @@ -1,5 +1,5 @@ import CreateProject from "@/components/CreateProject"; -import { getServerI18n } from "@/i18n"; +import { I18nProvider, getServerI18n } from "@/i18n"; import Typography from "@mui/material/Typography"; import { Metadata } from "next"; @@ -13,7 +13,9 @@ const Projects: React.FC = async () => { return ( <> {t("Create Project")} - + + + ); }; diff --git a/src/app/api/projects/actions.ts b/src/app/api/projects/actions.ts index edbaa24..f05ef40 100644 --- a/src/app/api/projects/actions.ts +++ b/src/app/api/projects/actions.ts @@ -22,9 +22,7 @@ export interface CreateProjectInputs { // Tasks tasks: { [taskId: Task["id"]]: { - manhourAllocation: { - [gradeId: number]: number; - }; + manhourAllocation: ManhourAllocation; }; }; @@ -41,6 +39,10 @@ export interface CreateProjectInputs { }; } +export interface ManhourAllocation { + [gradeId: number]: number; +} + export interface PaymentInputs { id: number; description: string; diff --git a/src/components/CreateProject/ResourceMilestone.tsx b/src/components/CreateProject/ResourceMilestone.tsx index aa12789..0706851 100644 --- a/src/components/CreateProject/ResourceMilestone.tsx +++ b/src/components/CreateProject/ResourceMilestone.tsx @@ -30,7 +30,10 @@ export interface Props { defaultManhourBreakdownByGrade?: { [gradeId: number]: number }; } -const ResourceMilestone: React.FC = ({ allTasks }) => { +const ResourceMilestone: React.FC = ({ + allTasks, + defaultManhourBreakdownByGrade, +}) => { const { t } = useTranslation(); const { getValues } = useFormContext(); const tasks = useMemo(() => { @@ -79,9 +82,7 @@ const ResourceMilestone: React.FC = ({ allTasks }) => { {}} - onAllocateManhours={() => {}} + manhourBreakdownByGrade={defaultManhourBreakdownByGrade} /> diff --git a/src/components/CreateProject/ResourceSection.tsx b/src/components/CreateProject/ResourceSection.tsx index bea7295..9b60fc1 100644 --- a/src/components/CreateProject/ResourceSection.tsx +++ b/src/components/CreateProject/ResourceSection.tsx @@ -9,22 +9,40 @@ import { ListItemText, TextField, } from "@mui/material"; -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Props as ResourceMilestoneProps } from "./ResourceMilestone"; +import StyledDataGrid from "../StyledDataGrid"; +import { useForm, useFormContext } from "react-hook-form"; +import { GridColDef, GridRowModel, useGridApiRef } from "@mui/x-data-grid"; +import { + CreateProjectInputs, + ManhourAllocation, +} from "@/app/api/projects/actions"; +import isEmpty from "lodash/isEmpty"; +import _reduce from "lodash/reduce"; + +const mockGrades = [1, 2, 3, 4, 5]; interface Props { tasks: Task[]; - defaultManhourBreakdownByGrade: ResourceMilestoneProps["defaultManhourBreakdownByGrade"]; - onSetManhours: (hours: number, taskId: Task["id"]) => void; - onAllocateManhours: () => void; + manhourBreakdownByGrade: ResourceMilestoneProps["defaultManhourBreakdownByGrade"]; } +type Row = ManhourAllocation & { id: "manhourAllocation" }; + +const parseValidManhours = (value: number | string): number => { + const inputNumber = Number(value); + return isNaN(inputNumber) || inputNumber < 0 ? 0 : inputNumber; +}; + const ResourceSection: React.FC = ({ tasks, - onAllocateManhours, - onSetManhours, - defaultManhourBreakdownByGrade, + manhourBreakdownByGrade = mockGrades.reduce< + NonNullable + >((acc, grade) => { + return { ...acc, [grade]: 1 }; + }, {}), }) => { const { t } = useTranslation(); const [selectedTaskId, setSelectedTaskId] = useState(tasks[0].id); @@ -40,10 +58,116 @@ const ResourceSection: React.FC = ({ setSelectedTaskId(tasks[0].id); }, [tasks]); + const { getValues, setValue } = useFormContext(); + + const updateTaskAllocations = useCallback( + (newAllocations: ManhourAllocation) => { + setValue("tasks", { + ...getValues("tasks"), + [selectedTaskId]: { + manhourAllocation: newAllocations, + }, + }); + }, + [getValues, selectedTaskId, setValue], + ); + + const gridApiRef = useGridApiRef(); + const columns = useMemo(() => { + return mockGrades.map((grade) => ({ + field: grade.toString(), + editable: true, + sortable: false, + width: 120, + headerName: t("Grade {{grade}}", { grade }), + type: "number", + valueParser: parseValidManhours, + valueFormatter(params) { + return Number(params.value).toFixed(2); + }, + })); + }, [t]); + + const rows = useMemo(() => { + const initialAllocation = + getValues("tasks")[selectedTaskId].manhourAllocation; + if (!isEmpty(initialAllocation)) { + return [{ ...initialAllocation, id: "manhourAllocation" }]; + } + return [ + mockGrades.reduce( + (acc, grade) => { + return { ...acc, [grade]: 0 }; + }, + { id: "manhourAllocation" }, + ), + ]; + }, [getValues, selectedTaskId]); + + const initialManhours = useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, ...allocations } = rows[0]; + return Object.values(allocations).reduce((acc, hours) => acc + hours, 0); + }, [rows]); + + const { + register, + reset, + getValues: getManhourFormValues, + setValue: setManhourFormValue, + } = useForm<{ manhour: number }>({ + defaultValues: { + manhour: initialManhours, + }, + }); + + // Reset man hour input when task changes + useEffect(() => { + reset({ manhour: initialManhours }); + }, [initialManhours, reset, selectedTaskId]); + + const updateAllocation = useCallback(() => { + const inputHour = getManhourFormValues("manhour"); + const ratioSum = Object.values(manhourBreakdownByGrade).reduce( + (acc, ratio) => acc + ratio, + 0, + ); + const newAllocations = _reduce( + manhourBreakdownByGrade, + (acc, value, key) => { + return { ...acc, [key]: (inputHour / ratioSum) * value }; + }, + {}, + ); + gridApiRef.current.updateRows([ + { id: "manhourAllocation", ...newAllocations }, + ]); + updateTaskAllocations(newAllocations); + }, [ + getManhourFormValues, + gridApiRef, + manhourBreakdownByGrade, + updateTaskAllocations, + ]); + + const processRowUpdate = useCallback( + (newRow: GridRowModel) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id: rowId, ...newAllocations } = newRow; + const totalHours = Object.values( + newAllocations as ManhourAllocation, + ).reduce((acc, hour) => acc + hour, 0); + setManhourFormValue("manhour", totalHours); + updateTaskAllocations(newAllocations); + return newRow; + }, + [setManhourFormValue, updateTaskAllocations], + ); + return ( - {t("Resource")} + {t("Task Breakdown")} @@ -64,7 +188,35 @@ const ResourceSection: React.FC = ({ - + + + + diff --git a/src/i18n/en/common.json b/src/i18n/en/common.json new file mode 100644 index 0000000..2b2f3a3 --- /dev/null +++ b/src/i18n/en/common.json @@ -0,0 +1,3 @@ +{ + "Grade {{grade}}": "Grade {{grade}}" +} \ No newline at end of file diff --git a/src/i18n/en/projects.json b/src/i18n/en/projects.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/src/i18n/en/projects.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/en/translation.json b/src/i18n/en/translation.json deleted file mode 100644 index 080318d..0000000 --- a/src/i18n/en/translation.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "test": "abc" -} \ No newline at end of file diff --git a/src/i18n/zh/common.json b/src/i18n/zh/common.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/src/i18n/zh/common.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/zh/projects.json b/src/i18n/zh/projects.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/src/i18n/zh/projects.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/src/i18n/zh/translation.json b/src/i18n/zh/translation.json deleted file mode 100644 index cd1d02f..0000000 --- a/src/i18n/zh/translation.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "test": "def" -} \ No newline at end of file