From 7636743bee9ff60910ebe6b43f361abab10fa4d6 Mon Sep 17 00:00:00 2001 From: "cyril.tsui" Date: Fri, 10 May 2024 16:57:19 +0800 Subject: [PATCH] update project & task template --- src/app/(main)/tasks/edit/page.tsx | 4 +- src/app/api/tasks/index.ts | 5 +- .../CreateProject/CreateProject.tsx | 2 +- src/components/CreateProject/Milestone.tsx | 2 +- .../CreateProject/MilestoneSection.tsx | 2 +- .../CreateTaskTemplate/CreateTaskTemplate.tsx | 169 +++++++---- .../CreateTaskTemplateWrapper.tsx | 10 +- .../CreateTaskTemplate/ResourceAllocation.tsx | 287 ++++++++++++++++++ 8 files changed, 404 insertions(+), 77 deletions(-) create mode 100644 src/components/CreateTaskTemplate/ResourceAllocation.tsx diff --git a/src/app/(main)/tasks/edit/page.tsx b/src/app/(main)/tasks/edit/page.tsx index 4872daf..8f20792 100644 --- a/src/app/(main)/tasks/edit/page.tsx +++ b/src/app/(main)/tasks/edit/page.tsx @@ -1,4 +1,4 @@ -import { fetchTaskTemplate, preloadAllTasks } from "@/app/api/tasks"; +import { fetchTaskTemplateDetail, preloadAllTasks } from "@/app/api/tasks"; import CreateTaskTemplate from "@/components/CreateTaskTemplate"; import { getServerI18n } from "@/i18n"; import Typography from "@mui/material/Typography"; @@ -27,7 +27,7 @@ const TaskTemplates: React.FC = async ({ searchParams }) => { preloadAllTasks(); try { - await fetchTaskTemplate(taskTemplateId); + await fetchTaskTemplateDetail(taskTemplateId); } catch (e) { if (e instanceof ServerFetchError && e.response?.status === 404) { notFound(); diff --git a/src/app/api/tasks/index.ts b/src/app/api/tasks/index.ts index ba993f0..855a900 100644 --- a/src/app/api/tasks/index.ts +++ b/src/app/api/tasks/index.ts @@ -2,6 +2,7 @@ import { serverFetchJson } from "@/app/utils/fetchUtil"; import { BASE_API_URL } from "@/config/api"; import { cache } from "react"; import "server-only"; +import { NewTaskTemplateFormInputs } from "./actions"; export interface TaskGroup { id: number; @@ -40,8 +41,8 @@ export const fetchAllTasks = cache(async () => { return serverFetchJson(`${BASE_API_URL}/tasks`); }); -export const fetchTaskTemplate = cache(async (id: string) => { - const taskTemplate = await serverFetchJson( +export const fetchTaskTemplateDetail = cache(async (id: string) => { + const taskTemplate = await serverFetchJson( `${BASE_API_URL}/tasks/templatesDetails/${id}`, { method: "GET", diff --git a/src/components/CreateProject/CreateProject.tsx b/src/components/CreateProject/CreateProject.tsx index a8a13a8..c4aa9d5 100644 --- a/src/components/CreateProject/CreateProject.tsx +++ b/src/components/CreateProject/CreateProject.tsx @@ -171,7 +171,7 @@ const CreateProject: React.FC = ({ // Tab - Milestone let projectTotal = 0 const milestonesKeys = Object.keys(data.milestones) - milestonesKeys.forEach(key => { + milestonesKeys.filter(key => Object.keys(data.taskGroups).includes(key)).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)) { diff --git a/src/components/CreateProject/Milestone.tsx b/src/components/CreateProject/Milestone.tsx index 66dcb2d..d9c021b 100644 --- a/src/components/CreateProject/Milestone.tsx +++ b/src/components/CreateProject/Milestone.tsx @@ -65,7 +65,7 @@ const Milestone: React.FC = ({ allTasks, isActive }) => { let hasError = false let projectTotal = 0 - milestonesKeys.forEach(key => { + milestonesKeys.filter(key => taskGroups.map(taskGroup => taskGroup.id).includes(parseInt(key))).forEach(key => { const { startDate, endDate, payments } = milestones[parseFloat(key)] if (new Date(startDate) > new Date(endDate) || !Boolean(startDate) || !Boolean(endDate)) { diff --git a/src/components/CreateProject/MilestoneSection.tsx b/src/components/CreateProject/MilestoneSection.tsx index 5d66480..a6d6ccc 100644 --- a/src/components/CreateProject/MilestoneSection.tsx +++ b/src/components/CreateProject/MilestoneSection.tsx @@ -65,7 +65,7 @@ const MilestoneSection: React.FC = ({ taskGroupId }) => { ...model, [id]: { mode: GridRowModes.Edit, fieldToFocus: "description" }, })); - }, []); + }, [payments]); const validateRow = useCallback( (id: GridRowId) => { diff --git a/src/components/CreateTaskTemplate/CreateTaskTemplate.tsx b/src/components/CreateTaskTemplate/CreateTaskTemplate.tsx index 856f66e..79f30af 100644 --- a/src/components/CreateTaskTemplate/CreateTaskTemplate.tsx +++ b/src/components/CreateTaskTemplate/CreateTaskTemplate.tsx @@ -10,27 +10,31 @@ import TransferList from "../TransferList"; import Button from "@mui/material/Button"; import Check from "@mui/icons-material/Check"; import Close from "@mui/icons-material/Close"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter } from "next/navigation"; import React from "react"; import Stack from "@mui/material/Stack"; import { Task, TaskTemplate } from "@/app/api/tasks"; import { NewTaskTemplateFormInputs, - fetchTaskTemplate, saveTaskTemplate, } from "@/app/api/tasks/actions"; -import { SubmitHandler, useFieldArray, useForm } from "react-hook-form"; +import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; import { errorDialog, submitDialog, successDialog } from "../Swal/CustomAlerts"; +import { Grade } from "@/app/api/grades"; +import { intersectionWith, isEmpty } from "lodash"; +import ResourceAllocationWrapper from "./ResourceAllocation"; interface Props { tasks: Task[]; - defaultInputs?: TaskTemplate; + defaultInputs?: NewTaskTemplateFormInputs; + grades: Grade[] } -const CreateTaskTemplate: React.FC = ({ tasks, defaultInputs }) => { + + +const CreateTaskTemplate: React.FC = ({ tasks, defaultInputs, grades }) => { const { t } = useTranslation(); - const searchParams = useSearchParams() const router = useRouter(); const handleCancel = () => { router.back(); @@ -48,57 +52,53 @@ const CreateTaskTemplate: React.FC = ({ tasks, defaultInputs }) => { const [serverError, setServerError] = React.useState(""); - const { - register, - handleSubmit, - setValue, - watch, - resetField, - formState: { errors, isSubmitting }, - } = useForm({ defaultValues: defaultInputs }); - - const currentTaskIds = watch("taskIds"); + const formProps = useForm({ + defaultValues: { + taskGroups: {}, + ...defaultInputs, + + manhourPercentageByGrade: isEmpty(defaultInputs?.manhourPercentageByGrade) + ? grades.reduce((acc, grade) => { + return { ...acc, [grade.id]: 100 / grades.length }; + }, {}) + : defaultInputs?.manhourPercentageByGrade, + } + }); + + const currentTaskGroups = formProps.watch("taskGroups"); + const currentTaskIds = Object.values(currentTaskGroups).reduce( + (acc, group) => { + return [...acc, ...group.taskIds]; + }, + [], + ); const selectedItems = React.useMemo(() => { - return items.filter((item) => currentTaskIds.includes(item.id)); - }, [currentTaskIds, items]); - - // const [refTaskTemplate, setRefTaskTemplate] = React.useState() - // const id = searchParams.get('id') - - // const fetchCurrentTaskTemplate = async () => { - // try { - // const taskTemplate = await fetchTaskTemplate(parseInt(id!!)) - - // const defaultValues = { - // id: parseInt(id!!), - // code: taskTemplate.code ?? null, - // name: taskTemplate.name ?? null, - // taskIds: taskTemplate.tasks.map(task => task.id) ?? [], - // } - - // setRefTaskTemplate(defaultValues) - // } catch (e) { - // console.log(e) - // } - // } - - // React.useLayoutEffect(() => { - // if (id !== null && parseInt(id) > 0) fetchCurrentTaskTemplate() - // }, [id]) - - // React.useEffect(() => { - // if (refTaskTemplate) { - // setValue("taskIds", refTaskTemplate.taskIds) - // resetField("code", { defaultValue: refTaskTemplate.code }) - // resetField("name", { defaultValue: refTaskTemplate.name }) - // setValue("id", refTaskTemplate.id) - // } - // }, [refTaskTemplate]) + return intersectionWith( + tasks, + currentTaskIds, + (task, taskId) => task.id === taskId, + ).map((t) => ({ id: t.id, label: t.name, group: t.taskGroup })); + }, [currentTaskIds, tasks]); const onSubmit: SubmitHandler = React.useCallback( async (data) => { try { + console.log(data) + setServerError(""); + + let hasErrors = false + + // check the manhour allocation by stage by grade -> total = 100? + 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) !== 100) { + formProps.setError("taskGroups", { message: "Task Groups value is not invalid", type: "invalid" }) + hasErrors = true + } + + if (hasErrors) return false + submitDialog(async () => { const response = await saveTaskTemplate(data); @@ -121,9 +121,10 @@ const CreateTaskTemplate: React.FC = ({ tasks, defaultInputs }) => { return ( <> - + + + {/* Task List Setup */} - {/* Task List Setup */} {t("Task List Setup")} = ({ tasks, defaultInputs }) => { @@ -159,17 +160,52 @@ const CreateTaskTemplate: React.FC = ({ tasks, defaultInputs }) => { allItems={items} selectedItems={selectedItems} onChange={(selectedTasks) => { - setValue( - "taskIds", - selectedTasks.map((item) => item.id), - ); + // formProps.setValue( + // "taskIds", + // selectedTasks.map((item) => item.id), + // ); + + const newTaskGroups = selectedTasks.reduce< + NewTaskTemplateFormInputs["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.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], + }, + }; + }, {}); + + formProps.setValue("taskGroups", newTaskGroups); }} allItemsLabel={t("Task Pool")} selectedItemsLabel={t("Task List Template")} /> - {/* Task List Setup */} - {/* Task List Setup */} + + + {/* Resource Allocation */} + + + { @@ -187,12 +223,13 @@ const CreateTaskTemplate: React.FC = ({ tasks, defaultInputs }) => { variant="contained" startIcon={} type="submit" - disabled={isSubmitting} + disabled={formProps.formState.isSubmitting} > {t("Confirm")} + ); }; diff --git a/src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx b/src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx index 50a131b..b236986 100644 --- a/src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx +++ b/src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx @@ -1,18 +1,20 @@ import React from "react"; import CreateTaskTemplate from "./CreateTaskTemplate"; -import { fetchAllTasks, fetchTaskTemplate } from "@/app/api/tasks"; +import { fetchAllTasks, fetchTaskTemplateDetail } from "@/app/api/tasks"; +import { fetchGrades } from "@/app/api/grades"; interface Props { taskTemplateId?: string; } const CreateTaskTemplateWrapper: React.FC = async (props) => { - const [tasks] = await Promise.all([ + const [tasks, grades] = await Promise.all([ fetchAllTasks(), + fetchGrades(), ]); - const taskTemplateInfo = props.taskTemplateId ? await fetchTaskTemplate(props.taskTemplateId) : undefined - return ; + const taskTemplateInfo = props.taskTemplateId ? await fetchTaskTemplateDetail(props.taskTemplateId) : undefined + return ; }; export default CreateTaskTemplateWrapper; diff --git a/src/components/CreateTaskTemplate/ResourceAllocation.tsx b/src/components/CreateTaskTemplate/ResourceAllocation.tsx new file mode 100644 index 0000000..5e529f0 --- /dev/null +++ b/src/components/CreateTaskTemplate/ResourceAllocation.tsx @@ -0,0 +1,287 @@ +import { Task, TaskGroup } from "@/app/api/tasks"; +import { + Box, + Typography, + TextField, + Alert, + TableContainer, + Table, + TableBody, + TableCell, + TableHead, + TableRow, + Stack, + SxProps, +} from "@mui/material"; +import React, { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import uniqBy from "lodash/uniqBy"; +import { Grade } from "@/app/api/grades"; +import { percentFormatter } from "@/app/utils/formatUtil"; +import TableCellEdit from "../TableCellEdit"; +import { useFormContext } from "react-hook-form"; +import { NewTaskTemplateFormInputs } from "@/app/api/tasks/actions"; + +interface Props { + allTasks: Task[]; + grades: Grade[]; +} + +const leftBorderCellSx: SxProps = { + borderLeft: "1px solid", + borderColor: "divider", +}; + +const rightBorderCellSx: SxProps = { + borderRight: "1px solid", + borderColor: "divider", +}; + +const leftRightBorderCellSx: SxProps = { + borderLeft: "1px solid", + borderRight: "1px solid", + borderColor: "divider", +}; + +const errorCellSx: SxProps = { + outline: "1px solid", + outlineColor: "error.main", +} + +const ResourceAllocationByGrade: React.FC = ({ grades }) => { + const { t } = useTranslation(); + const { watch, register, setValue, formState: { errors }, setError, clearErrors } = useFormContext(); + + const manhourPercentageByGrade = watch("manhourPercentageByGrade"); + const totalPercentage = Math.round(Object.values(manhourPercentageByGrade).reduce( + (acc, percent) => acc + percent, + 0, + ) * 100) / 100; + + const makeUpdatePercentage = useCallback( + (gradeId: Grade["id"]) => (percentage?: number) => { + if (percentage !== undefined) { + 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) !== 100) { + setError("manhourPercentageByGrade", { message: "manhourPercentageByGrade value is not valid", type: "invalid" }) + } else { + clearErrors("manhourPercentageByGrade") + } + + } + }, + [manhourPercentageByGrade, setValue], + ); + + 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) => ( + val + "%"} + onChange={makeUpdatePercentage(column.id)} + convertValue={(inputValue) => Number(inputValue)} + cellSx={{ backgroundColor: "primary.lightest" }} + inputSx={{ width: "3rem" }} + error={manhourPercentageByGrade[column.id] < 0} + /> + ))} + + {totalPercentage + "%"} + + + +
+
+
+
+ ); +}; + +const ResourceAllocationByStage: React.FC = ({ grades, allTasks }) => { + const { t } = useTranslation(); + const { watch, setValue, clearErrors, setError } = 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 manhourPercentageByGrade = watch("manhourPercentageByGrade"); + + const makeUpdatePercentage = useCallback( + (taskGroupId: TaskGroup["id"]) => (percentage?: number) => { + console.log(percentage) + if (percentage !== undefined) { + const updatedTaskGroups = { + ...currentTaskGroups, + [taskGroupId]: { + ...currentTaskGroups[taskGroupId], + percentAllocation: percentage, + }, + } + console.log(updatedTaskGroups) + 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) !== 100) { + setError("taskGroups", { message: "Task Groups value is not invalid", type: "invalid" }) + } else { + clearErrors("taskGroups") + } + } + }, + [currentTaskGroups, setValue], + ); + + return ( + + + {t("Manhour Allocation By Stage By Grade")} + + ({ + marginBlockStart: 2, + marginInline: -3, + borderBottom: `1px solid ${theme.palette.divider}`, + })} + > + + + + + {t("Stage")} + {t("Task Count")} + + {t("Total Manhour")} + + + + + {taskGroups.map((tg, idx) => ( + + {tg.name} + + {currentTaskGroups[tg.id].taskIds.length} + + percentFormatter.format(val)} + renderValue={(val) => val + "%"} + onChange={makeUpdatePercentage(tg.id)} + convertValue={(inputValue) => Number(inputValue)} + cellSx={{ + backgroundColor: "primary.lightest", + ...(currentTaskGroups[tg.id].percentAllocation < 0 && { ...errorCellSx, borderBottom: "0px", borderRight: "1px solid", borderColor: "error.main"}) + }} + inputSx={{ width: "3rem" }} + error={currentTaskGroups[tg.id].percentAllocation < 0} + /> + + ))} + + {t("Total")} + + {Object.values(currentTaskGroups).reduce( + (acc, tg) => acc + tg.taskIds.length, + 0, + )} + + acc + tg.percentAllocation, 0,) === 100 && leftBorderCellSx), + ...(Object.values(currentTaskGroups).reduce((acc, tg) => acc + tg.percentAllocation, 0,) !== 100 && { ...errorCellSx, borderRight: "1px solid", borderColor: "error.main"}) + }} + > + {percentFormatter.format( + Object.values(currentTaskGroups).reduce( + (acc, tg) => acc + tg.percentAllocation / 100, + 0, + ), + )} + + + +
+
+
+
+ ); +}; + +const NoTaskState: React.FC = () => { + const { t } = useTranslation(); + return ( + <> + + {t("Task Breakdown")} + + + {t('Please add some tasks first!')} + + + ); +}; + +const ResourceAllocationWrapper: React.FC = (props) => { + const { getValues } = useFormContext(); + + if (Object.keys(getValues("taskGroups")).length === 0) { + return ; + } + + return ( + + + + + ); +}; + +export default ResourceAllocationWrapper;