Parcourir la source

update project & task template

tags/Baseline_30082024_FRONTEND_UAT
cyril.tsui il y a 1 an
Parent
révision
7636743bee
8 fichiers modifiés avec 404 ajouts et 77 suppressions
  1. +2
    -2
      src/app/(main)/tasks/edit/page.tsx
  2. +3
    -2
      src/app/api/tasks/index.ts
  3. +1
    -1
      src/components/CreateProject/CreateProject.tsx
  4. +1
    -1
      src/components/CreateProject/Milestone.tsx
  5. +1
    -1
      src/components/CreateProject/MilestoneSection.tsx
  6. +103
    -66
      src/components/CreateTaskTemplate/CreateTaskTemplate.tsx
  7. +6
    -4
      src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx
  8. +287
    -0
      src/components/CreateTaskTemplate/ResourceAllocation.tsx

+ 2
- 2
src/app/(main)/tasks/edit/page.tsx Voir le fichier

@@ -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<Props> = async ({ searchParams }) => {
preloadAllTasks();

try {
await fetchTaskTemplate(taskTemplateId);
await fetchTaskTemplateDetail(taskTemplateId);
} catch (e) {
if (e instanceof ServerFetchError && e.response?.status === 404) {
notFound();


+ 3
- 2
src/app/api/tasks/index.ts Voir le fichier

@@ -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<Task[]>(`${BASE_API_URL}/tasks`);
});

export const fetchTaskTemplate = cache(async (id: string) => {
const taskTemplate = await serverFetchJson<TaskTemplate>(
export const fetchTaskTemplateDetail = cache(async (id: string) => {
const taskTemplate = await serverFetchJson<NewTaskTemplateFormInputs>(
`${BASE_API_URL}/tasks/templatesDetails/${id}`,
{
method: "GET",


+ 1
- 1
src/components/CreateProject/CreateProject.tsx Voir le fichier

@@ -171,7 +171,7 @@ const CreateProject: React.FC<Props> = ({
// 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)) {


+ 1
- 1
src/components/CreateProject/Milestone.tsx Voir le fichier

@@ -65,7 +65,7 @@ const Milestone: React.FC<Props> = ({ 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)) {


+ 1
- 1
src/components/CreateProject/MilestoneSection.tsx Voir le fichier

@@ -65,7 +65,7 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => {
...model,
[id]: { mode: GridRowModes.Edit, fieldToFocus: "description" },
}));
}, []);
}, [payments]);

const validateRow = useCallback(
(id: GridRowId) => {


+ 103
- 66
src/components/CreateTaskTemplate/CreateTaskTemplate.tsx Voir le fichier

@@ -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<Props> = ({ tasks, defaultInputs }) => {


const CreateTaskTemplate: React.FC<Props> = ({ tasks, defaultInputs, grades }) => {
const { t } = useTranslation();

const searchParams = useSearchParams()
const router = useRouter();
const handleCancel = () => {
router.back();
@@ -48,57 +52,53 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks, defaultInputs }) => {

const [serverError, setServerError] = React.useState("");

const {
register,
handleSubmit,
setValue,
watch,
resetField,
formState: { errors, isSubmitting },
} = useForm<NewTaskTemplateFormInputs>({ defaultValues: defaultInputs });

const currentTaskIds = watch("taskIds");
const formProps = useForm<NewTaskTemplateFormInputs>({
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<Task["id"][]>(
(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<NewTaskTemplateFormInputs>()
// 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<NewTaskTemplateFormInputs> = 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<Props> = ({ tasks, defaultInputs }) => {

return (
<>
<Stack component="form" onSubmit={handleSubmit(onSubmit)} gap={2}>
<FormProvider {...formProps}>
<Stack component="form" onSubmit={formProps.handleSubmit(onSubmit)} gap={2}>
{/* Task List Setup */}
<Card>
{/* Task List Setup */}
<CardContent sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
<Typography variant="overline">{t("Task List Setup")}</Typography>
<Grid
@@ -136,22 +137,22 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks, defaultInputs }) => {
<TextField
label={t("Task Template Code")}
fullWidth
{...register("code", {
{...formProps.register("code", {
required: t("Task template code is required"),
})}
error={Boolean(errors.code?.message)}
helperText={errors.code?.message}
error={Boolean(formProps.formState.errors.code?.message)}
helperText={formProps.formState.errors.code?.message}
/>
</Grid>
<Grid item xs={6}>
<TextField
label={t("Task Template Name")}
fullWidth
{...register("name", {
{...formProps.register("name", {
required: t("Task template name is required"),
})}
error={Boolean(errors.name?.message)}
helperText={errors.name?.message}
error={Boolean(formProps.formState.errors.name?.message)}
helperText={formProps.formState.errors.name?.message}
/>
</Grid>
</Grid>
@@ -159,17 +160,52 @@ const CreateTaskTemplate: React.FC<Props> = ({ 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 */}
</CardContent>
</Card>

{/* Resource Allocation */}
<Card>
<CardContent>
<ResourceAllocationWrapper
allTasks={tasks}
grades={grades}
/>
</CardContent>
</Card>
{
@@ -187,12 +223,13 @@ const CreateTaskTemplate: React.FC<Props> = ({ tasks, defaultInputs }) => {
variant="contained"
startIcon={<Check />}
type="submit"
disabled={isSubmitting}
disabled={formProps.formState.isSubmitting}
>
{t("Confirm")}
</Button>
</Stack>
</Stack >
</FormProvider>
</>
);
};


+ 6
- 4
src/components/CreateTaskTemplate/CreateTaskTemplateWrapper.tsx Voir le fichier

@@ -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<Props> = 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 <CreateTaskTemplate tasks={tasks} defaultInputs={taskTemplateInfo}/>;
const taskTemplateInfo = props.taskTemplateId ? await fetchTaskTemplateDetail(props.taskTemplateId) : undefined
return <CreateTaskTemplate tasks={tasks} grades={grades} defaultInputs={taskTemplateInfo}/>;
};

export default CreateTaskTemplateWrapper;

+ 287
- 0
src/components/CreateTaskTemplate/ResourceAllocation.tsx Voir le fichier

@@ -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<Props> = ({ grades }) => {
const { t } = useTranslation();
const { watch, register, setValue, formState: { errors }, setError, clearErrors } = useFormContext<NewTaskTemplateFormInputs>();

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 (
<Box>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Manhour Allocation By Grade")}
</Typography>
<Box
sx={(theme) => ({
marginBlockStart: 2,
marginInline: -3,
borderBottom: `1px solid ${theme.palette.divider}`,
})}
>
<TableContainer sx={{ maxHeight: 440 }}>
<Table>
<TableHead>
<TableRow>
<TableCell sx={rightBorderCellSx}>
{t("Allocation Type")}
</TableCell>
{grades.map((column, idx) => (
<TableCell key={`${column.id}${idx}`}>
{column.name}
</TableCell>
))}
<TableCell sx={leftBorderCellSx}>{t("Total")}</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell sx={rightBorderCellSx}>{t("Percentage")}</TableCell>
{grades.map((column, idx) => (
<TableCellEdit
key={`${column.id}${idx}`}
value={manhourPercentageByGrade[column.id]}
renderValue={(val) => val + "%"}
onChange={makeUpdatePercentage(column.id)}
convertValue={(inputValue) => Number(inputValue)}
cellSx={{ backgroundColor: "primary.lightest" }}
inputSx={{ width: "3rem" }}
error={manhourPercentageByGrade[column.id] < 0}
/>
))}
<TableCell sx={{ ...(totalPercentage === 100 && leftBorderCellSx), ...(totalPercentage !== 100 && { ...errorCellSx, borderRight: "1px solid", borderColor: "error.main" }) }}>
{totalPercentage + "%"}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</Box>
</Box>
);
};

const ResourceAllocationByStage: React.FC<Props> = ({ grades, allTasks }) => {
const { t } = useTranslation();
const { watch, setValue, clearErrors, setError } = useFormContext<NewTaskTemplateFormInputs>();

const currentTaskGroups = watch("taskGroups");
const taskGroups = useMemo(
() =>
uniqBy(
allTasks.reduce<TaskGroup[]>((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 (
<Box>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Manhour Allocation By Stage By Grade")}
</Typography>
<Box
sx={(theme) => ({
marginBlockStart: 2,
marginInline: -3,
borderBottom: `1px solid ${theme.palette.divider}`,
})}
>
<TableContainer sx={{ maxHeight: 440 }}>
<Table>
<TableHead>
<TableRow>
<TableCell>{t("Stage")}</TableCell>
<TableCell sx={leftBorderCellSx}>{t("Task Count")}</TableCell>
<TableCell colSpan={2} sx={leftRightBorderCellSx}>
{t("Total Manhour")}
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{taskGroups.map((tg, idx) => (
<TableRow key={`${tg.id}${idx}`}>
<TableCell>{tg.name}</TableCell>
<TableCell sx={leftBorderCellSx}>
{currentTaskGroups[tg.id].taskIds.length}
</TableCell>
<TableCellEdit
value={currentTaskGroups[tg.id].percentAllocation}
// renderValue={(val) => 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}
/>
</TableRow>
))}
<TableRow>
<TableCell>{t("Total")}</TableCell>
<TableCell sx={leftBorderCellSx}>
{Object.values(currentTaskGroups).reduce(
(acc, tg) => acc + tg.taskIds.length,
0,
)}
</TableCell>
<TableCell sx={{
...(Object.values(currentTaskGroups).reduce((acc, tg) => 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,
),
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</TableContainer>
</Box>
</Box>
);
};

const NoTaskState: React.FC = () => {
const { t } = useTranslation();
return (
<>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Task Breakdown")}
</Typography>
<Alert severity="warning">
{t('Please add some tasks first!')}
</Alert>
</>
);
};

const ResourceAllocationWrapper: React.FC<Props> = (props) => {
const { getValues } = useFormContext<NewTaskTemplateFormInputs>();

if (Object.keys(getValues("taskGroups")).length === 0) {
return <NoTaskState />;
}

return (
<Stack spacing={4}>
<ResourceAllocationByGrade {...props} />
<ResourceAllocationByStage {...props} />
</Stack>
);
};

export default ResourceAllocationWrapper;

Chargement…
Annuler
Enregistrer