|
- import { TimeEntry } from "@/app/api/timesheets/actions";
- import { Check, Delete, Close } from "@mui/icons-material";
- import {
- Box,
- Button,
- FormControl,
- InputLabel,
- Modal,
- ModalProps,
- Paper,
- SxProps,
- TextField,
- Typography,
- } from "@mui/material";
- import React, { useCallback, useEffect, useMemo } from "react";
- import { Controller, useForm } from "react-hook-form";
- import { useTranslation } from "react-i18next";
- import ProjectSelect from "./ProjectSelect";
- import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
- import TaskGroupSelect, {
- TaskGroupSelectWithoutProject,
- } from "./TaskGroupSelect";
- import TaskSelect from "./TaskSelect";
- import { Task, TaskGroup } from "@/app/api/tasks";
- import uniqBy from "lodash/uniqBy";
- import { roundToNearestQuarter } from "@/app/utils/manhourUtils";
- import { shortDateFormatter } from "@/app/utils/formatUtil";
- import {
- DAILY_NORMAL_MAX_HOURS,
- TIMESHEET_DAILY_MAX_HOURS,
- } from "@/app/api/timesheets/utils";
- import dayjs from "dayjs";
-
- export interface Props extends Omit<ModalProps, "children"> {
- onSave: (timeEntry: TimeEntry, recordDate?: string) => Promise<void>;
- onDelete?: () => Promise<void>;
- defaultValues?: Partial<TimeEntry>;
- allProjects: ProjectWithTasks[];
- assignedProjects: AssignedProject[];
- modalSx?: SxProps;
- recordDate?: string;
- isHoliday?: boolean;
- miscTasks: Task[];
- }
-
- const modalSx: SxProps = {
- position: "absolute",
- top: "50%",
- left: "50%",
- transform: "translate(-50%, -50%)",
- width: "90%",
- maxHeight: "90%",
- padding: 3,
- display: "flex",
- flexDirection: "column",
- gap: 2,
- };
- const TimesheetEditModal: React.FC<Props> = ({
- onSave,
- onDelete,
- open,
- onClose,
- defaultValues,
- allProjects,
- assignedProjects,
- modalSx: mSx,
- recordDate,
- isHoliday,
- miscTasks,
- }) => {
- const {
- t,
- i18n: { language },
- } = useTranslation("home");
-
- const taskGroupsByProject = useMemo(() => {
- return allProjects.reduce<{
- [projectId: AssignedProject["id"]]: {
- value: TaskGroup["id"];
- label: string;
- }[];
- }>((acc, project) => {
- return {
- ...acc,
- [project.id]: uniqBy(
- project.tasks.map((t) => ({
- value: t.taskGroup.id,
- label: t.taskGroup.name,
- })),
- "value",
- ),
- };
- }, {});
- }, [allProjects]);
-
- const taskGroupsWithoutProject = useMemo(
- () =>
- uniqBy(
- miscTasks.map((t) => t.taskGroup),
- "id",
- ),
- [miscTasks],
- );
-
- const {
- register,
- control,
- reset,
- getValues,
- setValue,
- trigger,
- setError,
- formState,
- watch,
- } = useForm<TimeEntry>();
-
- useEffect(() => {
- reset(defaultValues ?? { id: Date.now() });
- }, [defaultValues, reset]);
-
- const saveHandler = useCallback(async () => {
- const valid = await trigger();
- if (valid) {
- try {
- await onSave(getValues(), recordDate);
- reset({ id: Date.now() });
- } catch (e) {
- setError("root", {
- message: e instanceof Error ? e.message : "Unknown error",
- });
- }
- }
- }, [getValues, onSave, recordDate, reset, setError, trigger]);
-
- const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>(
- (...args) => {
- onClose?.(...args);
- reset({ id: Date.now() });
- },
- [onClose, reset],
- );
-
- const projectId = watch("projectId");
- const taskGroupId = watch("taskGroupId");
- const otHours = watch("otHours");
-
- return (
- <Modal open={open} onClose={closeHandler}>
- <Paper sx={{ ...modalSx, ...mSx }}>
- {recordDate && (
- <Typography
- variant="h6"
- marginBlockEnd={2}
- color={isHoliday ? "error.main" : undefined}
- >
- {shortDateFormatter(language).format(new Date(recordDate))}
- </Typography>
- )}
- <FormControl fullWidth>
- <InputLabel shrink>{t("Project Code and Name")}</InputLabel>
- <Controller
- control={control}
- name="projectId"
- render={({ field }) => (
- <ProjectSelect
- referenceDay={dayjs(recordDate)}
- multiple={false}
- allProjects={allProjects}
- assignedProjects={assignedProjects}
- value={field.value}
- onProjectSelect={(newId) => {
- field.onChange(newId ?? null);
- const firstTaskGroup = (
- typeof newId === "number" ? taskGroupsByProject[newId] : []
- )[0];
-
- setValue("taskGroupId", firstTaskGroup?.value);
- setValue("taskId", undefined);
- }}
- isTimesheetAdmendment={true}
- />
- )}
- rules={{ deps: ["taskGroupId", "taskId"] }}
- />
- </FormControl>
- <FormControl fullWidth>
- <InputLabel shrink>{t("Stage")}</InputLabel>
- <Controller
- control={control}
- name="taskGroupId"
- render={({ field }) =>
- projectId ? (
- <TaskGroupSelect
- error={Boolean(formState.errors.taskGroupId)}
- projectId={projectId}
- taskGroupsByProject={taskGroupsByProject}
- value={field.value}
- onTaskGroupSelect={(newId) => {
- field.onChange(newId ?? null);
- }}
- />
- ) : (
- <TaskGroupSelectWithoutProject
- value={field.value}
- onTaskGroupSelect={(newId) => {
- field.onChange(newId ?? null);
- if (!newId) {
- setValue("taskId", undefined);
- }
- }}
- taskGroups={taskGroupsWithoutProject}
- />
- )
- }
- rules={{
- validate: (id) => {
- if (!projectId) {
- return true;
- }
- const taskGroups = taskGroupsByProject[projectId];
- return taskGroups.some((tg) => tg.value === id);
- },
- deps: ["taskId"],
- }}
- />
- </FormControl>
- <FormControl fullWidth>
- <InputLabel shrink>{t("Task")}</InputLabel>
- <Controller
- control={control}
- name="taskId"
- render={({ field }) => (
- <TaskSelect
- error={Boolean(formState.errors.taskId)}
- projectId={projectId}
- taskGroupId={taskGroupId}
- allProjects={allProjects}
- value={field.value}
- onTaskSelect={(newId) => {
- field.onChange(newId ?? null);
- }}
- miscTasks={miscTasks}
- />
- )}
- rules={{
- validate: (id) => {
- const tasks = projectId
- ? allProjects.find((p) => p.id === projectId)?.tasks
- : miscTasks;
-
- return taskGroupId
- ? Boolean(
- tasks?.some(
- (task) =>
- task.id === id && task.taskGroup.id === taskGroupId,
- ),
- )
- : !id;
- },
- }}
- />
- </FormControl>
- <TextField
- type="number"
- label={t("Hours")}
- fullWidth
- {...register("inputHours", {
- setValueAs: (value) => roundToNearestQuarter(parseFloat(value)),
- validate: (value) => {
- if (value) {
- if (isHoliday) {
- return t("Cannot input normal hours on holidays");
- }
-
- return (
- (0 < value && value <= DAILY_NORMAL_MAX_HOURS) ||
- t(
- "Input hours should be between 0 and {{DAILY_NORMAL_MAX_HOURS}}",
- { DAILY_NORMAL_MAX_HOURS },
- )
- );
- } else {
- return Boolean(value || otHours) || t("Required");
- }
- },
- })}
- error={Boolean(formState.errors.inputHours)}
- helperText={formState.errors.inputHours?.message}
- />
- <TextField
- type="number"
- label={t("Other Hours")}
- fullWidth
- {...register("otHours", {
- setValueAs: (value) => roundToNearestQuarter(parseFloat(value)),
- validate: (value) => (value ? value > 0 : true),
- })}
- error={Boolean(formState.errors.otHours)}
- />
- <TextField
- label={t("Remarks")}
- fullWidth
- multiline
- rows={2}
- error={Boolean(formState.errors.remark)}
- {...register("remark", {
- validate: (value) =>
- Boolean(projectId || taskGroupId || value) ||
- t("Required for non-billable tasks"),
- })}
- helperText={formState.errors.remark?.message}
- />
- {formState.errors.root?.message && (
- <Typography variant="caption" color="error">
- {t(formState.errors.root.message, {
- DAILY_NORMAL_MAX_HOURS,
- TIMESHEET_DAILY_MAX_HOURS,
- })}
- </Typography>
- )}
- <Box display="flex" justifyContent="flex-end" gap={1}>
- {onDelete && (
- <Button
- variant="outlined"
- startIcon={<Delete />}
- color="error"
- onClick={onDelete}
- >
- {t("Delete")}
- </Button>
- )}
- <Button
- variant="outlined"
- startIcon={<Close />}
- onClick={(event) => closeHandler(event, "backdropClick")}
- >
- {t("Close")}
- </Button>
- <Button
- variant="contained"
- startIcon={<Check />}
- onClick={saveHandler}
- >
- {t("Save")}
- </Button>
- </Box>
- </Paper>
- </Modal>
- );
- };
-
- export default TimesheetEditModal;
|