From e96ab5f4816104e9fb3c2bd015cced28dab76c8c Mon Sep 17 00:00:00 2001 From: Wayne Date: Sat, 6 Apr 2024 17:42:44 +0900 Subject: [PATCH] Update new resource allocation --- src/app/api/grades/index.ts | 5 + src/app/api/projects/actions.ts | 10 + src/app/utils/formatUtil.ts | 5 + .../CreateProject/CreateProject.tsx | 18 +- .../CreateProject/CreateProjectWrapper.tsx | 8 + src/components/CreateProject/Milestone.tsx | 24 +- .../CreateProject/ResourceAllocation.tsx | 454 ++++++++++-------- .../CreateProject/StaffAllocation.tsx | 4 + src/components/CreateProject/TaskSetup.tsx | 52 +- .../TableCellEdit/TableCellEdit.tsx | 86 ++++ src/components/TableCellEdit/index.ts | 1 + 11 files changed, 447 insertions(+), 220 deletions(-) create mode 100644 src/app/api/grades/index.ts create mode 100644 src/components/TableCellEdit/TableCellEdit.tsx create mode 100644 src/components/TableCellEdit/index.ts diff --git a/src/app/api/grades/index.ts b/src/app/api/grades/index.ts new file mode 100644 index 0000000..88398d5 --- /dev/null +++ b/src/app/api/grades/index.ts @@ -0,0 +1,5 @@ +export interface Grade { + name: string; + id: number; + code: string; +} diff --git a/src/app/api/projects/actions.ts b/src/app/api/projects/actions.ts index 90894fd..cadc0c7 100644 --- a/src/app/api/projects/actions.ts +++ b/src/app/api/projects/actions.ts @@ -27,6 +27,16 @@ export interface CreateProjectInputs { }; }; + totalManhour: number; + manhourPercentageByGrade: ManhourAllocation; + + taskGroups: { + [taskGroup: TaskGroup["id"]]: { + taskIds: Task["id"][]; + percentAllocation: number; + }; + }; + // Staff allocatedStaffIds: number[]; diff --git a/src/app/utils/formatUtil.ts b/src/app/utils/formatUtil.ts index c2e38ea..f097e43 100644 --- a/src/app/utils/formatUtil.ts +++ b/src/app/utils/formatUtil.ts @@ -7,3 +7,8 @@ export const moneyFormatter = new Intl.NumberFormat("en-HK", { style: "currency", currency: "HKD", }); + +export const percentFormatter = new Intl.NumberFormat("en-HK", { + style: "percent", + maximumFractionDigits: 2, +}); diff --git a/src/components/CreateProject/CreateProject.tsx b/src/components/CreateProject/CreateProject.tsx index d9fb246..2ec65cf 100644 --- a/src/components/CreateProject/CreateProject.tsx +++ b/src/components/CreateProject/CreateProject.tsx @@ -26,12 +26,16 @@ import { Error } from "@mui/icons-material"; import { ProjectCategory } from "@/app/api/projects"; import { StaffResult } from "@/app/api/staff"; import { Typography } from "@mui/material"; +import { Grade } from "@/app/api/grades"; export interface Props { allTasks: Task[]; projectCategories: ProjectCategory[]; taskTemplates: TaskTemplate[]; teamLeads: StaffResult[]; + + // Mocked + grades: Grade[]; } const hasErrorsInTab = ( @@ -51,6 +55,7 @@ const CreateProject: React.FC = ({ projectCategories, taskTemplates, teamLeads, + grades, }) => { const [serverError, setServerError] = useState(""); const [tabIndex, setTabIndex] = useState(0); @@ -95,8 +100,13 @@ const CreateProject: React.FC = ({ const formProps = useForm({ defaultValues: { tasks: {}, + taskGroups: {}, allocatedStaffIds: [], milestones: {}, + totalManhour: 0, + manhourPercentageByGrade: grades.reduce((acc, grade) => { + return { ...acc, [grade.id]: 1 / grades.length }; + }, {}), // TODO: Remove this clientSubsidiary: "Test subsidiary", }, @@ -139,7 +149,13 @@ const CreateProject: React.FC = ({ isActive={tabIndex === 1} /> } - {} + { + + } {} {serverError && ( diff --git a/src/components/CreateProject/CreateProjectWrapper.tsx b/src/components/CreateProject/CreateProjectWrapper.tsx index 07896d5..87e49f5 100644 --- a/src/components/CreateProject/CreateProjectWrapper.tsx +++ b/src/components/CreateProject/CreateProjectWrapper.tsx @@ -18,6 +18,14 @@ const CreateProjectWrapper: React.FC = async () => { projectCategories={projectCategories} taskTemplates={taskTemplates} teamLeads={teamLeads} + // Mocks + grades={[ + { name: "Grade 1", id: 1, code: "1" }, + { name: "Grade 2", id: 2, code: "2" }, + { name: "Grade 3", id: 3, code: "3" }, + { name: "Grade 4", id: 4, code: "4" }, + { name: "Grade 5", id: 5, code: "5" }, + ]} /> ); }; diff --git a/src/components/CreateProject/Milestone.tsx b/src/components/CreateProject/Milestone.tsx index 9d61e32..d59990d 100644 --- a/src/components/CreateProject/Milestone.tsx +++ b/src/components/CreateProject/Milestone.tsx @@ -30,15 +30,21 @@ export interface Props { const Milestone: React.FC = ({ allTasks, isActive }) => { const { t } = useTranslation(); const { watch } = useFormContext(); + const currentTaskGroups = watch("taskGroups"); + const taskGroups = useMemo( + () => + uniqBy( + allTasks.reduce((acc, task) => { + if (currentTaskGroups[task.taskGroup.id]) { + return [...acc, task.taskGroup]; + } + return acc; + }, []), + "id", + ), + [allTasks, currentTaskGroups], + ); - const tasks = allTasks.filter((task) => watch("tasks")[task.id]); - - const taskGroups = useMemo(() => { - return uniqBy( - tasks.map((task) => task.taskGroup), - "id", - ); - }, [tasks]); const [currentTaskGroupId, setCurrentTaskGroupId] = useState( taskGroups[0].id, ); @@ -103,7 +109,7 @@ const NoTaskState: React.FC> = ({ isActive }) => { const MilestoneWrapper: React.FC = (props) => { const { getValues } = useFormContext(); - if (Object.keys(getValues("tasks")).length === 0) { + if (Object.keys(getValues("taskGroups")).length === 0) { return ; } diff --git a/src/components/CreateProject/ResourceAllocation.tsx b/src/components/CreateProject/ResourceAllocation.tsx index 4877a1d..c593580 100644 --- a/src/components/CreateProject/ResourceAllocation.tsx +++ b/src/components/CreateProject/ResourceAllocation.tsx @@ -1,232 +1,289 @@ -import { Task } from "@/app/api/tasks"; +import { Task, TaskGroup } from "@/app/api/tasks"; import { Box, Typography, - Grid, - Paper, - List, - ListItemButton, - ListItemText, TextField, Alert, + TableContainer, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Stack, + SxProps, } from "@mui/material"; -import { useState, useCallback, useEffect, useMemo } from "react"; +import React, { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Props as StaffAllocationProps } from "./StaffAllocation"; -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]; +import { useFormContext } from "react-hook-form"; +import { CreateProjectInputs } from "@/app/api/projects/actions"; +import uniqBy from "lodash/uniqBy"; +import { Grade } from "@/app/api/grades"; +import { manhourFormatter, percentFormatter } from "@/app/utils/formatUtil"; +import TableCellEdit from "../TableCellEdit"; interface Props { allTasks: Task[]; manhourBreakdownByGrade: StaffAllocationProps["defaultManhourBreakdownByGrade"]; + grades: Grade[]; } -type Row = ManhourAllocation & { id: "manhourAllocation" }; +const leftBorderCellSx: SxProps = { + borderLeft: "1px solid", + borderColor: "divider", +}; -const parseValidManhours = (value: number | string): number => { - const inputNumber = Number(value); - return isNaN(inputNumber) || inputNumber < 0 ? 0 : inputNumber; +const rightBorderCellSx: SxProps = { + borderRight: "1px solid", + borderColor: "divider", }; -const ResourceAllocation: React.FC = ({ - allTasks, - manhourBreakdownByGrade = mockGrades.reduce< - NonNullable - >((acc, grade) => { - return { ...acc, [grade]: 1 }; - }, {}), -}) => { +const leftRightBorderCellSx: SxProps = { + borderLeft: "1px solid", + borderRight: "1px solid", + borderColor: "divider", +}; + +const ResourceAllocationByGrade: React.FC = ({ grades }) => { const { t } = useTranslation(); - const { watch } = useFormContext(); - const currentTasks = watch("tasks"); - const tasks = useMemo( - () => allTasks.filter((task) => currentTasks[task.id]), - [allTasks, currentTasks], - ); + const { watch, register, setValue } = useFormContext(); - const [selectedTaskId, setSelectedTaskId] = useState(tasks[0].id); - const makeOnTaskSelect = useCallback( - (taskId: Task["id"]): React.MouseEventHandler => - () => { - return setSelectedTaskId(taskId); - }, - [], + const manhourPercentageByGrade = watch("manhourPercentageByGrade"); + const totalManhour = watch("totalManhour"); + const totalPercentage = Object.values(manhourPercentageByGrade).reduce( + (acc, percent) => acc + percent, + 0, ); - useEffect(() => { - setSelectedTaskId(tasks[0].id); - }, [tasks]); - - const { getValues, setValue } = useFormContext(); - - const updateTaskAllocations = useCallback( - (newAllocations: ManhourAllocation) => { - setValue("tasks", { - ...getValues("tasks"), - [selectedTaskId]: { - manhourAllocation: newAllocations, - }, - }); + const makeUpdatePercentage = useCallback( + (gradeId: Grade["id"]) => (percentage?: number) => { + if (percentage !== undefined) { + setValue("manhourPercentageByGrade", { + ...manhourPercentageByGrade, + [gradeId]: percentage, + }); + } }, - [getValues, selectedTaskId, setValue], + [manhourPercentageByGrade, 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]); + return ( + + + {t("Manhour Allocation By Grade")} + + + ({ + marginBlockStart: 2, + marginInline: -3, + borderBottom: `1px solid ${theme.palette.divider}`, + })} + > + + + + + + {t("Allocation Type")} + + {grades.map((column, idx) => ( + + {column.name} + + ))} + {t("Total")} + + + + + {t("Percentage")} + {grades.map((column, idx) => ( + percentFormatter.format(val)} + onChange={makeUpdatePercentage(column.id)} + convertValue={(inputValue) => Number(inputValue)} + cellSx={{ backgroundColor: "primary.lightest" }} + inputSx={{ width: "3rem" }} + /> + ))} + + {percentFormatter.format(totalPercentage)} + + + + {t("Manhour")} + {grades.map((column, idx) => ( + + {manhourFormatter.format( + manhourPercentageByGrade[column.id] * totalManhour, + )} + + ))} + + {manhourFormatter.format(totalManhour)} + + + +
+
+
+
+ ); +}; - const { - register, - reset, - getValues: getManhourFormValues, - setValue: setManhourFormValue, - } = useForm<{ manhour: number }>({ - defaultValues: { - manhour: initialManhours, - }, - }); +const ResourceAllocationByStage: React.FC = ({ grades, allTasks }) => { + const { t } = useTranslation(); + const { watch, setValue } = useFormContext(); - // Reset man hour input when task changes - useEffect(() => { - reset({ manhour: initialManhours }); - }, [initialManhours, reset, selectedTaskId]); + const currentTaskGroups = watch("taskGroups"); + const taskGroups = useMemo( + () => + uniqBy( + allTasks.reduce((acc, task) => { + if (currentTaskGroups[task.taskGroup.id]) { + return [...acc, task.taskGroup]; + } + return acc; + }, []), + "id", + ), + [allTasks, currentTaskGroups], + ); - 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 manhourPercentageByGrade = watch("manhourPercentageByGrade"); + const totalManhour = watch("totalManhour"); - 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; + const makeUpdatePercentage = useCallback( + (taskGroupId: TaskGroup["id"]) => (percentage?: number) => { + if (percentage !== undefined) { + setValue("taskGroups", { + ...currentTaskGroups, + [taskGroupId]: { + ...currentTaskGroups[taskGroupId], + percentAllocation: percentage, + }, + }); + } }, - [setManhourFormValue, updateTaskAllocations], + [currentTaskGroups, setValue], ); return ( - {t("Task Breakdown")} + {t("Manhour Allocation By Stage By Grade")} - - - - - {tasks.map((task, index) => { - return ( - - - - ); - })} - - - - - - - - - - + ({ + marginBlockStart: 2, + marginInline: -3, + borderBottom: `1px solid ${theme.palette.divider}`, + })} + > + + + + + {t("Stage")} + {t("Task Count")} + + {t("Total Manhour")} + + {grades.map((column, idx) => ( + + {column.name} + + ))} + + + + {taskGroups.map((tg, idx) => ( + + {tg.name} + + {currentTaskGroups[tg.id].taskIds.length} + + percentFormatter.format(val)} + onChange={makeUpdatePercentage(tg.id)} + convertValue={(inputValue) => Number(inputValue)} + cellSx={{ backgroundColor: "primary.lightest" }} + inputSx={{ width: "3rem" }} + /> + + {manhourFormatter.format( + currentTaskGroups[tg.id].percentAllocation * totalManhour, + )} + + {grades.map((column, idx) => { + const stageHours = + currentTaskGroups[tg.id].percentAllocation * totalManhour; + return ( + + {manhourFormatter.format( + manhourPercentageByGrade[column.id] * stageHours, + )} + + ); + })} + + ))} + + {t("Total")} + + {Object.values(currentTaskGroups).reduce( + (acc, tg) => acc + tg.taskIds.length, + 0, + )} + + + {percentFormatter.format( + Object.values(currentTaskGroups).reduce( + (acc, tg) => acc + tg.percentAllocation, + 0, + ), + )} + + + {manhourFormatter.format( + Object.values(currentTaskGroups).reduce( + (acc, tg) => acc + tg.percentAllocation * totalManhour, + 0, + ), + )} + + {grades.map((column, idx) => { + const hours = Object.values(currentTaskGroups).reduce( + (acc, tg) => + acc + + tg.percentAllocation * + totalManhour * + manhourPercentageByGrade[column.id], + 0, + ); + return ( + + {manhourFormatter.format(hours)} + + ); + })} + + +
+
+
); }; @@ -248,11 +305,16 @@ const NoTaskState: React.FC = () => { const ResourceAllocationWrapper: React.FC = (props) => { const { getValues } = useFormContext(); - if (Object.keys(getValues("tasks")).length === 0) { + if (Object.keys(getValues("taskGroups")).length === 0) { return ; } - return ; + return ( + + + + + ); }; export default ResourceAllocationWrapper; diff --git a/src/components/CreateProject/StaffAllocation.tsx b/src/components/CreateProject/StaffAllocation.tsx index da3fbda..bfeba33 100644 --- a/src/components/CreateProject/StaffAllocation.tsx +++ b/src/components/CreateProject/StaffAllocation.tsx @@ -33,6 +33,7 @@ import { useFormContext } from "react-hook-form"; import { CreateProjectInputs } from "@/app/api/projects/actions"; import ResourceAllocation from "./ResourceAllocation"; import { Task } from "@/app/api/tasks"; +import { Grade } from "@/app/api/grades"; interface StaffResult { id: number; @@ -106,6 +107,7 @@ export interface Props { isActive: boolean; defaultManhourBreakdownByGrade?: { [gradeId: number]: number }; allTasks: Task[]; + grades: Grade[]; } const StaffAllocation: React.FC = ({ @@ -113,6 +115,7 @@ const StaffAllocation: React.FC = ({ allTasks, isActive, defaultManhourBreakdownByGrade, + grades, }) => { const { t } = useTranslation(); const { setValue, getValues } = useFormContext(); @@ -348,6 +351,7 @@ const StaffAllocation: React.FC = ({ diff --git a/src/components/CreateProject/TaskSetup.tsx b/src/components/CreateProject/TaskSetup.tsx index f054773..443014e 100644 --- a/src/components/CreateProject/TaskSetup.tsx +++ b/src/components/CreateProject/TaskSetup.tsx @@ -18,6 +18,7 @@ import { Task, TaskTemplate } from "@/app/api/tasks"; import { useFormContext } from "react-hook-form"; import { CreateProjectInputs } from "@/app/api/projects/actions"; import isNumber from "lodash/isNumber"; +import intersectionWith from "lodash/intersectionWith"; interface Props { allTasks: Task[]; @@ -32,10 +33,16 @@ const TaskSetup: React.FC = ({ }) => { const { t } = useTranslation(); const { setValue, watch } = useFormContext(); - const currentTasks = watch("tasks"); + const currentTaskGroups = watch("taskGroups"); + const currentTaskIds = Object.values(currentTaskGroups).reduce( + (acc, group) => { + return [...acc, ...group.taskIds]; + }, + [], + ); const onReset = useCallback(() => { - setValue("tasks", {}); + setValue("taskGroups", {}); }, [setValue]); const [selectedTaskTemplateId, setSelectedTaskTemplateId] = useState< @@ -67,10 +74,12 @@ const TaskSetup: React.FC = ({ }, [tasks, selectedTaskTemplateId, taskTemplates]); const selectedItems = useMemo(() => { - return tasks - .filter((t) => currentTasks[t.id]) - .map((t) => ({ id: t.id, label: t.name, group: t.taskGroup })); - }, [currentTasks, tasks]); + return intersectionWith( + tasks, + currentTaskIds, + (task, taskId) => task.id === taskId, + ).map((t) => ({ id: t.id, label: t.name, group: t.taskGroup })); + }, [currentTaskIds, tasks]); return ( @@ -106,18 +115,33 @@ const TaskSetup: React.FC = ({ allItems={items} selectedItems={selectedItems} onChange={(selectedTasks) => { - const newTasks = selectedTasks.reduce( - (acc, item) => { - // Reuse the task from currentTasks if present + const newTaskGroups = selectedTasks.reduce< + CreateProjectInputs["taskGroups"] + >((acc, item) => { + if (!item.group) { + // TODO: this should not happen (all tasks are part of a group) + return acc; + } + if (!acc[item.group.id]) { return { ...acc, - [item.id]: currentTasks[item.id] ?? { manhourAllocation: {} }, + [item.group.id]: { + taskIds: [item.id], + percentAllocation: + currentTaskGroups[item.group.id]?.percentAllocation || 0, + }, }; - }, - {}, - ); + } + return { + ...acc, + [item.group.id]: { + ...acc[item.group.id], + taskIds: [...acc[item.group.id].taskIds, item.id], + }, + }; + }, {}); - setValue("tasks", newTasks); + setValue("taskGroups", newTaskGroups); }} allItemsLabel={t("Task Pool")} selectedItemsLabel={t("Project Task List")} diff --git a/src/components/TableCellEdit/TableCellEdit.tsx b/src/components/TableCellEdit/TableCellEdit.tsx new file mode 100644 index 0000000..a96f0b7 --- /dev/null +++ b/src/components/TableCellEdit/TableCellEdit.tsx @@ -0,0 +1,86 @@ +import React, { + ChangeEventHandler, + useCallback, + useEffect, + useRef, + useState, +} from "react"; +import { Box, Input, SxProps, TableCell } from "@mui/material"; + +interface Props { + value: T; + onChange: (newValue?: T) => void; + renderValue?: (value: T) => string; + convertValue: (inputValue?: string) => T; + cellSx?: SxProps; + inputSx?: SxProps; +} + +const TableCellEdit = ({ + value, + renderValue = (val) => `${val}`, + convertValue, + onChange, + cellSx, + inputSx, +}: Props) => { + const [editMode, setEditMode] = useState(false); + const [input, setInput] = useState(); + const inputRef = useRef(null); + + const onClick = useCallback(() => { + setEditMode(true); + setInput(`${value}`); + }, [value]); + + const onInputChange = useCallback>( + (e) => setInput(e.target.value), + [], + ); + + const onBlur = useCallback(() => { + setEditMode(false); + onChange(convertValue(input)); + setInput(undefined); + }, [convertValue, input, onChange]); + + useEffect(() => { + if (editMode && inputRef.current) { + inputRef.current?.focus(); + } + }, [editMode]); + + return ( + + + + {renderValue(value)} + + + ); +}; + +export default TableCellEdit; diff --git a/src/components/TableCellEdit/index.ts b/src/components/TableCellEdit/index.ts new file mode 100644 index 0000000..3880f5d --- /dev/null +++ b/src/components/TableCellEdit/index.ts @@ -0,0 +1 @@ +export { default } from "./TableCellEdit";