| @@ -57,8 +57,9 @@ const LeaveEditModal: React.FC<Props> = ({ | |||||
| const valid = await trigger(); | const valid = await trigger(); | ||||
| if (valid) { | if (valid) { | ||||
| onSave(getValues()); | onSave(getValues()); | ||||
| reset(); | |||||
| } | } | ||||
| }, [getValues, onSave, trigger]); | |||||
| }, [getValues, onSave, reset, trigger]); | |||||
| const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | ||||
| (...args) => { | (...args) => { | ||||
| @@ -86,93 +86,100 @@ const MobileLeaveEntry: React.FC<Props> = ({ date, leaveTypes }) => { | |||||
| ); | ); | ||||
| return ( | return ( | ||||
| <Box | |||||
| marginInline={2} | |||||
| flex={1} | |||||
| display="flex" | |||||
| flexDirection="column" | |||||
| gap={2} | |||||
| > | |||||
| <> | |||||
| <Typography | <Typography | ||||
| paddingInline={2} | |||||
| variant="overline" | variant="overline" | ||||
| color={dayJsObj.day() === 0 ? "error.main" : undefined} | color={dayJsObj.day() === 0 ? "error.main" : undefined} | ||||
| > | > | ||||
| {shortDateFormatter(language).format(dayJsObj.toDate())} | {shortDateFormatter(language).format(dayJsObj.toDate())} | ||||
| </Typography> | </Typography> | ||||
| {currentEntries.length ? ( | |||||
| currentEntries.map((entry, index) => { | |||||
| return ( | |||||
| <Card key={`${entry.id}-${index}`} sx={{ marginInline: 1 }}> | |||||
| <CardContent | |||||
| sx={{ | |||||
| padding: 2, | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| gap: 2, | |||||
| "&:last-child": { | |||||
| paddingBottom: 2, | |||||
| }, | |||||
| }} | |||||
| <Box | |||||
| paddingInline={2} | |||||
| flex={1} | |||||
| display="flex" | |||||
| flexDirection="column" | |||||
| gap={2} | |||||
| overflow="scroll" | |||||
| > | |||||
| {currentEntries.length ? ( | |||||
| currentEntries.map((entry, index) => { | |||||
| return ( | |||||
| <Card | |||||
| key={`${entry.id}-${index}`} | |||||
| sx={{ marginInline: 1, overflow: "visible" }} | |||||
| > | > | ||||
| <Box | |||||
| display="flex" | |||||
| justifyContent="space-between" | |||||
| alignItems="flex-start" | |||||
| <CardContent | |||||
| sx={{ | |||||
| padding: 2, | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| gap: 2, | |||||
| "&:last-child": { | |||||
| paddingBottom: 2, | |||||
| }, | |||||
| }} | |||||
| > | > | ||||
| <Box> | |||||
| <Typography | |||||
| variant="body2" | |||||
| component="div" | |||||
| fontWeight="bold" | |||||
| > | |||||
| {leaveTypeMap[entry.leaveTypeId].name} | |||||
| </Typography> | |||||
| <Typography component="p"> | |||||
| {manhourFormatter.format(entry.inputHours)} | |||||
| </Typography> | |||||
| </Box> | |||||
| <IconButton | |||||
| size="small" | |||||
| color="primary" | |||||
| onClick={openEditModal(entry)} | |||||
| <Box | |||||
| display="flex" | |||||
| justifyContent="space-between" | |||||
| alignItems="flex-start" | |||||
| > | > | ||||
| <Edit /> | |||||
| </IconButton> | |||||
| </Box> | |||||
| {entry.remark && ( | |||||
| <Box> | |||||
| <Typography | |||||
| variant="body2" | |||||
| component="div" | |||||
| fontWeight="bold" | |||||
| <Box> | |||||
| <Typography | |||||
| variant="body2" | |||||
| component="div" | |||||
| fontWeight="bold" | |||||
| > | |||||
| {leaveTypeMap[entry.leaveTypeId].name} | |||||
| </Typography> | |||||
| <Typography component="p"> | |||||
| {manhourFormatter.format(entry.inputHours)} | |||||
| </Typography> | |||||
| </Box> | |||||
| <IconButton | |||||
| size="small" | |||||
| color="primary" | |||||
| onClick={openEditModal(entry)} | |||||
| > | > | ||||
| {t("Remark")} | |||||
| </Typography> | |||||
| <Typography component="p">{entry.remark}</Typography> | |||||
| <Edit /> | |||||
| </IconButton> | |||||
| </Box> | </Box> | ||||
| )} | |||||
| </CardContent> | |||||
| </Card> | |||||
| ); | |||||
| }) | |||||
| ) : ( | |||||
| <Typography variant="body2" display="block"> | |||||
| {t("Add some leave entries!")} | |||||
| </Typography> | |||||
| )} | |||||
| <Box> | |||||
| <Button startIcon={<Add />} onClick={openEditModal()}> | |||||
| {t("Record leave")} | |||||
| </Button> | |||||
| {entry.remark && ( | |||||
| <Box> | |||||
| <Typography | |||||
| variant="body2" | |||||
| component="div" | |||||
| fontWeight="bold" | |||||
| > | |||||
| {t("Remark")} | |||||
| </Typography> | |||||
| <Typography component="p">{entry.remark}</Typography> | |||||
| </Box> | |||||
| )} | |||||
| </CardContent> | |||||
| </Card> | |||||
| ); | |||||
| }) | |||||
| ) : ( | |||||
| <Typography variant="body2" display="block"> | |||||
| {t("Add some leave entries!")} | |||||
| </Typography> | |||||
| )} | |||||
| <Box> | |||||
| <Button startIcon={<Add />} onClick={openEditModal()}> | |||||
| {t("Record leave")} | |||||
| </Button> | |||||
| </Box> | |||||
| <LeaveEditModal | |||||
| leaveTypes={leaveTypes} | |||||
| open={editModalOpen} | |||||
| onClose={closeEditModal} | |||||
| onSave={onSaveEntry} | |||||
| {...editModalProps} | |||||
| /> | |||||
| </Box> | </Box> | ||||
| <LeaveEditModal | |||||
| leaveTypes={leaveTypes} | |||||
| open={editModalOpen} | |||||
| onClose={closeEditModal} | |||||
| onSave={onSaveEntry} | |||||
| {...editModalProps} | |||||
| /> | |||||
| </Box> | |||||
| </> | |||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -277,7 +277,6 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| projectId={params.row.projectId} | projectId={params.row.projectId} | ||||
| taskGroupId={params.row.taskGroupId} | taskGroupId={params.row.taskGroupId} | ||||
| allProjects={allProjects} | allProjects={allProjects} | ||||
| editCellProps={params} | |||||
| onTaskSelect={(taskId) => { | onTaskSelect={(taskId) => { | ||||
| params.api.setEditCellValue({ | params.api.setEditCellValue({ | ||||
| id: params.id, | id: params.id, | ||||
| @@ -10,10 +10,13 @@ import { | |||||
| Typography, | Typography, | ||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import React from "react"; | |||||
| import React, { useCallback, useMemo, useState } from "react"; | |||||
| import { useFormContext } from "react-hook-form"; | import { useFormContext } from "react-hook-form"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | ||||
| import TimesheetEditModal, { | |||||
| Props as TimesheetEditModalProps, | |||||
| } from "./TimesheetEditModal"; | |||||
| interface Props { | interface Props { | ||||
| date: string; | date: string; | ||||
| @@ -30,11 +33,200 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
| t, | t, | ||||
| i18n: { language }, | i18n: { language }, | ||||
| } = useTranslation("home"); | } = useTranslation("home"); | ||||
| const projectMap = useMemo(() => { | |||||
| return allProjects.reduce<{ | |||||
| [id: ProjectWithTasks["id"]]: ProjectWithTasks; | |||||
| }>((acc, project) => { | |||||
| return { ...acc, [project.id]: project }; | |||||
| }, {}); | |||||
| }, [allProjects]); | |||||
| const dayJsObj = dayjs(date); | const dayJsObj = dayjs(date); | ||||
| const { watch, setValue } = useFormContext<RecordTimesheetInput>(); | const { watch, setValue } = useFormContext<RecordTimesheetInput>(); | ||||
| const currentEntries = watch(date); | const currentEntries = watch(date); | ||||
| return null; | |||||
| // Edit modal | |||||
| const [editModalProps, setEditModalProps] = useState< | |||||
| Partial<TimesheetEditModalProps> | |||||
| >({}); | |||||
| const [editModalOpen, setEditModalOpen] = useState(false); | |||||
| const openEditModal = useCallback( | |||||
| (defaultValues?: TimeEntry) => () => { | |||||
| setEditModalProps({ | |||||
| defaultValues, | |||||
| onDelete: defaultValues | |||||
| ? () => { | |||||
| setValue( | |||||
| date, | |||||
| currentEntries.filter((entry) => entry.id !== defaultValues.id), | |||||
| ); | |||||
| setEditModalOpen(false); | |||||
| } | |||||
| : undefined, | |||||
| }); | |||||
| setEditModalOpen(true); | |||||
| }, | |||||
| [currentEntries, date, setValue], | |||||
| ); | |||||
| const closeEditModal = useCallback(() => { | |||||
| setEditModalOpen(false); | |||||
| }, []); | |||||
| const onSaveEntry = useCallback( | |||||
| (entry: TimeEntry) => { | |||||
| const existingEntry = currentEntries.find((e) => e.id === entry.id); | |||||
| if (existingEntry) { | |||||
| setValue( | |||||
| date, | |||||
| currentEntries.map((e) => ({ | |||||
| ...(e.id === existingEntry.id ? entry : e), | |||||
| })), | |||||
| ); | |||||
| } else { | |||||
| setValue(date, [...currentEntries, entry]); | |||||
| } | |||||
| setEditModalOpen(false); | |||||
| }, | |||||
| [currentEntries, date, setValue], | |||||
| ); | |||||
| return ( | |||||
| <> | |||||
| <Typography | |||||
| paddingInline={2} | |||||
| variant="overline" | |||||
| color={dayJsObj.day() === 0 ? "error.main" : undefined} | |||||
| > | |||||
| {shortDateFormatter(language).format(dayJsObj.toDate())} | |||||
| </Typography> | |||||
| <Box | |||||
| paddingInline={2} | |||||
| flex={1} | |||||
| display="flex" | |||||
| flexDirection="column" | |||||
| gap={2} | |||||
| overflow="scroll" | |||||
| > | |||||
| {currentEntries.length ? ( | |||||
| currentEntries.map((entry, index) => { | |||||
| const project = entry.projectId | |||||
| ? projectMap[entry.projectId] | |||||
| : undefined; | |||||
| const task = project?.tasks.find((t) => t.id === entry.taskId); | |||||
| return ( | |||||
| <Card | |||||
| key={`${entry.id}-${index}`} | |||||
| sx={{ marginInline: 1, overflow: "visible" }} | |||||
| > | |||||
| <CardContent | |||||
| sx={{ | |||||
| padding: 2, | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| gap: 2, | |||||
| "&:last-child": { | |||||
| paddingBottom: 2, | |||||
| }, | |||||
| }} | |||||
| > | |||||
| <Box | |||||
| display="flex" | |||||
| justifyContent="space-between" | |||||
| alignItems="flex-start" | |||||
| gap={2} | |||||
| > | |||||
| <Box> | |||||
| <Typography | |||||
| variant="body2" | |||||
| component="div" | |||||
| fontWeight="bold" | |||||
| > | |||||
| {project | |||||
| ? `${project.code} - ${project.name}` | |||||
| : t("Non-billable Task")} | |||||
| </Typography> | |||||
| {task && ( | |||||
| <Typography variant="body2" component="div"> | |||||
| {task.name} | |||||
| </Typography> | |||||
| )} | |||||
| </Box> | |||||
| <IconButton | |||||
| size="small" | |||||
| color="primary" | |||||
| onClick={openEditModal(entry)} | |||||
| > | |||||
| <Edit /> | |||||
| </IconButton> | |||||
| </Box> | |||||
| <Box display="flex" gap={2}> | |||||
| <Box> | |||||
| <Typography | |||||
| variant="body2" | |||||
| component="div" | |||||
| fontWeight="bold" | |||||
| > | |||||
| {t("Hours")} | |||||
| </Typography> | |||||
| <Typography component="p"> | |||||
| {manhourFormatter.format(entry.inputHours || 0)} | |||||
| </Typography> | |||||
| </Box> | |||||
| <Box> | |||||
| <Typography | |||||
| variant="body2" | |||||
| component="div" | |||||
| fontWeight="bold" | |||||
| > | |||||
| {t("Other Hours")} | |||||
| </Typography> | |||||
| <Typography component="p"> | |||||
| {manhourFormatter.format(entry.otHours || 0)} | |||||
| </Typography> | |||||
| </Box> | |||||
| </Box> | |||||
| {entry.remark && ( | |||||
| <Box> | |||||
| <Typography | |||||
| variant="body2" | |||||
| component="div" | |||||
| fontWeight="bold" | |||||
| > | |||||
| {t("Remark")} | |||||
| </Typography> | |||||
| <Typography component="p">{entry.remark}</Typography> | |||||
| </Box> | |||||
| )} | |||||
| </CardContent> | |||||
| </Card> | |||||
| ); | |||||
| }) | |||||
| ) : ( | |||||
| <Typography variant="body2" display="block"> | |||||
| {t("Add some time entries!")} | |||||
| </Typography> | |||||
| )} | |||||
| <Box> | |||||
| <Button startIcon={<Add />} onClick={openEditModal()}> | |||||
| {t("Record leave")} | |||||
| </Button> | |||||
| </Box> | |||||
| <TimesheetEditModal | |||||
| allProjects={allProjects} | |||||
| assignedProjects={assignedProjects} | |||||
| open={editModalOpen} | |||||
| onClose={closeEditModal} | |||||
| onSave={onSaveEntry} | |||||
| {...editModalProps} | |||||
| /> | |||||
| </Box> | |||||
| </> | |||||
| ); | |||||
| }; | }; | ||||
| export default MobileTimesheetEntry; | export default MobileTimesheetEntry; | ||||
| @@ -1,9 +1,11 @@ | |||||
| import React, { useCallback, useMemo } from "react"; | import React, { useCallback, useMemo } from "react"; | ||||
| import { | import { | ||||
| Autocomplete, | |||||
| ListSubheader, | ListSubheader, | ||||
| MenuItem, | MenuItem, | ||||
| Select, | Select, | ||||
| SelectChangeEvent, | SelectChangeEvent, | ||||
| TextField, | |||||
| } from "@mui/material"; | } from "@mui/material"; | ||||
| import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| @@ -16,6 +18,49 @@ interface Props { | |||||
| onProjectSelect: (projectId: number | string) => void; | onProjectSelect: (projectId: number | string) => void; | ||||
| } | } | ||||
| // const AutocompleteProjectSelect: React.FC<Props> = ({ | |||||
| // allProjects, | |||||
| // assignedProjects, | |||||
| // value, | |||||
| // onProjectSelect, | |||||
| // }) => { | |||||
| // const { t } = useTranslation("home"); | |||||
| // const nonAssignedProjects = useMemo(() => { | |||||
| // return differenceBy(allProjects, assignedProjects, "id"); | |||||
| // }, [allProjects, assignedProjects]); | |||||
| // const options = useMemo(() => { | |||||
| // return [ | |||||
| // { | |||||
| // value: "", | |||||
| // label: t("None"), | |||||
| // group: "non-billable", | |||||
| // }, | |||||
| // ...assignedProjects.map((p) => ({ | |||||
| // value: p.id, | |||||
| // label: `${p.code} - ${p.name}`, | |||||
| // group: "assigned", | |||||
| // })), | |||||
| // ...nonAssignedProjects.map((p) => ({ | |||||
| // value: p.id, | |||||
| // label: `${p.code} - ${p.name}`, | |||||
| // group: "non-assigned", | |||||
| // })), | |||||
| // ]; | |||||
| // }, [assignedProjects, nonAssignedProjects, t]); | |||||
| // return ( | |||||
| // <Autocomplete | |||||
| // disableClearable | |||||
| // fullWidth | |||||
| // groupBy={(option) => option.group} | |||||
| // getOptionLabel={(option) => option.label} | |||||
| // options={options} | |||||
| // renderInput={(params) => <TextField {...params} />} | |||||
| // /> | |||||
| // ); | |||||
| // }; | |||||
| const ProjectSelect: React.FC<Props> = ({ | const ProjectSelect: React.FC<Props> = ({ | ||||
| allProjects, | allProjects, | ||||
| assignedProjects, | assignedProjects, | ||||
| @@ -68,6 +113,7 @@ const ProjectSelect: React.FC<Props> = ({ | |||||
| <MenuItem | <MenuItem | ||||
| key={project.id} | key={project.id} | ||||
| value={project.id} | value={project.id} | ||||
| sx={{ whiteSpace: "wrap" }} | |||||
| >{`${project.code} - ${project.name}`}</MenuItem> | >{`${project.code} - ${project.name}`}</MenuItem> | ||||
| )), | )), | ||||
| ]} | ]} | ||||
| @@ -79,6 +125,7 @@ const ProjectSelect: React.FC<Props> = ({ | |||||
| <MenuItem | <MenuItem | ||||
| key={project.id} | key={project.id} | ||||
| value={project.id} | value={project.id} | ||||
| sx={{ whiteSpace: "wrap" }} | |||||
| >{`${project.code} - ${project.name}`}</MenuItem> | >{`${project.code} - ${project.name}`}</MenuItem> | ||||
| )), | )), | ||||
| ]} | ]} | ||||
| @@ -13,6 +13,7 @@ interface Props { | |||||
| projectId: number | undefined; | projectId: number | undefined; | ||||
| value: number | undefined; | value: number | undefined; | ||||
| onTaskGroupSelect: (taskGroupId: number | string) => void; | onTaskGroupSelect: (taskGroupId: number | string) => void; | ||||
| error?: boolean; | |||||
| } | } | ||||
| const TaskGroupSelect: React.FC<Props> = ({ | const TaskGroupSelect: React.FC<Props> = ({ | ||||
| @@ -20,6 +21,7 @@ const TaskGroupSelect: React.FC<Props> = ({ | |||||
| projectId, | projectId, | ||||
| onTaskGroupSelect, | onTaskGroupSelect, | ||||
| taskGroupsByProject, | taskGroupsByProject, | ||||
| error, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
| @@ -35,6 +37,7 @@ const TaskGroupSelect: React.FC<Props> = ({ | |||||
| return ( | return ( | ||||
| <Select | <Select | ||||
| error={error} | |||||
| displayEmpty | displayEmpty | ||||
| disabled={taskGroups.length === 0} | disabled={taskGroups.length === 0} | ||||
| value={value || ""} | value={value || ""} | ||||
| @@ -58,7 +61,11 @@ const TaskGroupSelect: React.FC<Props> = ({ | |||||
| > | > | ||||
| {taskGroups.length === 0 && <MenuItem value={""}>{t("None")}</MenuItem>} | {taskGroups.length === 0 && <MenuItem value={""}>{t("None")}</MenuItem>} | ||||
| {taskGroups.map((taskGroup) => ( | {taskGroups.map((taskGroup) => ( | ||||
| <MenuItem key={taskGroup.value} value={taskGroup.value}> | |||||
| <MenuItem | |||||
| key={taskGroup.value} | |||||
| value={taskGroup.value} | |||||
| sx={{ whiteSpace: "wrap" }} | |||||
| > | |||||
| {taskGroup.label} | {taskGroup.label} | ||||
| </MenuItem> | </MenuItem> | ||||
| ))} | ))} | ||||
| @@ -1,7 +1,5 @@ | |||||
| import React, { useCallback } from "react"; | import React, { useCallback } from "react"; | ||||
| import { MenuItem, Select, SelectChangeEvent } from "@mui/material"; | import { MenuItem, Select, SelectChangeEvent } from "@mui/material"; | ||||
| import { GridRenderEditCellParams } from "@mui/x-data-grid"; | |||||
| import { TimeEntryRow } from "./EntryInputTable"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import { ProjectWithTasks } from "@/app/api/projects"; | import { ProjectWithTasks } from "@/app/api/projects"; | ||||
| @@ -10,8 +8,8 @@ interface Props { | |||||
| value: number | undefined; | value: number | undefined; | ||||
| projectId: number | undefined; | projectId: number | undefined; | ||||
| taskGroupId: number | undefined; | taskGroupId: number | undefined; | ||||
| editCellProps: GridRenderEditCellParams<TimeEntryRow, number>; | |||||
| onTaskSelect: (taskId: number | string) => void; | onTaskSelect: (taskId: number | string) => void; | ||||
| error?: boolean; | |||||
| } | } | ||||
| const TaskSelect: React.FC<Props> = ({ | const TaskSelect: React.FC<Props> = ({ | ||||
| @@ -20,6 +18,7 @@ const TaskSelect: React.FC<Props> = ({ | |||||
| projectId, | projectId, | ||||
| taskGroupId, | taskGroupId, | ||||
| onTaskSelect, | onTaskSelect, | ||||
| error | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
| @@ -38,6 +37,7 @@ const TaskSelect: React.FC<Props> = ({ | |||||
| return ( | return ( | ||||
| <Select | <Select | ||||
| error={error} | |||||
| displayEmpty | displayEmpty | ||||
| disabled={tasks.length === 0} | disabled={tasks.length === 0} | ||||
| value={value || ""} | value={value || ""} | ||||
| @@ -61,7 +61,7 @@ const TaskSelect: React.FC<Props> = ({ | |||||
| > | > | ||||
| {tasks.length === 0 && <MenuItem value={""}>{t("None")}</MenuItem>} | {tasks.length === 0 && <MenuItem value={""}>{t("None")}</MenuItem>} | ||||
| {tasks.map((task) => ( | {tasks.map((task) => ( | ||||
| <MenuItem key={task.id} value={task.id}> | |||||
| <MenuItem key={task.id} value={task.id} sx={{ whiteSpace: "wrap" }}> | |||||
| {task.name} | {task.name} | ||||
| </MenuItem> | </MenuItem> | ||||
| ))} | ))} | ||||
| @@ -0,0 +1,247 @@ | |||||
| import { TimeEntry } from "@/app/api/timesheets/actions"; | |||||
| import { Check, Delete } from "@mui/icons-material"; | |||||
| import { | |||||
| Box, | |||||
| Button, | |||||
| FormControl, | |||||
| InputLabel, | |||||
| Modal, | |||||
| ModalProps, | |||||
| Paper, | |||||
| SxProps, | |||||
| TextField, | |||||
| } 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 from "./TaskGroupSelect"; | |||||
| import TaskSelect from "./TaskSelect"; | |||||
| import { TaskGroup } from "@/app/api/tasks"; | |||||
| import uniqBy from "lodash/uniqBy"; | |||||
| export interface Props extends Omit<ModalProps, "children"> { | |||||
| onSave: (leaveEntry: TimeEntry) => void; | |||||
| onDelete?: () => void; | |||||
| defaultValues?: Partial<TimeEntry>; | |||||
| allProjects: ProjectWithTasks[]; | |||||
| assignedProjects: AssignedProject[]; | |||||
| } | |||||
| 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, | |||||
| }) => { | |||||
| const { t } = 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 { | |||||
| register, | |||||
| control, | |||||
| reset, | |||||
| getValues, | |||||
| setValue, | |||||
| trigger, | |||||
| formState, | |||||
| watch, | |||||
| } = useForm<TimeEntry>(); | |||||
| useEffect(() => { | |||||
| reset(defaultValues ?? { id: Date.now() }); | |||||
| }, [defaultValues, reset]); | |||||
| const saveHandler = useCallback(async () => { | |||||
| const valid = await trigger(); | |||||
| if (valid) { | |||||
| onSave(getValues()); | |||||
| reset(); | |||||
| } | |||||
| }, [getValues, onSave, reset, trigger]); | |||||
| const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | |||||
| (...args) => { | |||||
| onClose?.(...args); | |||||
| reset(); | |||||
| }, | |||||
| [onClose, reset], | |||||
| ); | |||||
| const projectId = watch("projectId"); | |||||
| const taskGroupId = watch("taskGroupId"); | |||||
| const otHours = watch("otHours"); | |||||
| return ( | |||||
| <Modal open={open} onClose={closeHandler}> | |||||
| <Paper sx={modalSx}> | |||||
| <FormControl fullWidth> | |||||
| <InputLabel shrink>{t("Project Code and Name")}</InputLabel> | |||||
| <Controller | |||||
| control={control} | |||||
| name="projectId" | |||||
| render={({ field }) => ( | |||||
| <ProjectSelect | |||||
| 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); | |||||
| }} | |||||
| /> | |||||
| )} | |||||
| rules={{ deps: ["taskGroupId", "taskId"] }} | |||||
| /> | |||||
| </FormControl> | |||||
| <FormControl fullWidth> | |||||
| <InputLabel shrink>{t("Stage")}</InputLabel> | |||||
| <Controller | |||||
| control={control} | |||||
| name="taskGroupId" | |||||
| render={({ field }) => ( | |||||
| <TaskGroupSelect | |||||
| error={Boolean(formState.errors.taskGroupId)} | |||||
| projectId={projectId} | |||||
| taskGroupsByProject={taskGroupsByProject} | |||||
| value={field.value} | |||||
| onTaskGroupSelect={(newId) => { | |||||
| field.onChange(newId ?? null); | |||||
| }} | |||||
| /> | |||||
| )} | |||||
| rules={{ | |||||
| validate: (id) => { | |||||
| if (!projectId) { | |||||
| return !id; | |||||
| } | |||||
| 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); | |||||
| }} | |||||
| /> | |||||
| )} | |||||
| rules={{ | |||||
| validate: (id) => { | |||||
| if (!projectId) { | |||||
| return !id; | |||||
| } | |||||
| const projectTasks = allProjects.find((p) => p.id === projectId) | |||||
| ?.tasks; | |||||
| return Boolean(projectTasks?.some((task) => task.id === id)); | |||||
| }, | |||||
| }} | |||||
| /> | |||||
| </FormControl> | |||||
| <TextField | |||||
| type="number" | |||||
| label={t("Hours")} | |||||
| fullWidth | |||||
| {...register("inputHours", { | |||||
| valueAsNumber: true, | |||||
| validate: (value) => Boolean(value || otHours), | |||||
| })} | |||||
| error={Boolean(formState.errors.inputHours)} | |||||
| /> | |||||
| <TextField | |||||
| type="number" | |||||
| label={t("Other Hours")} | |||||
| fullWidth | |||||
| {...register("otHours", { | |||||
| valueAsNumber: true, | |||||
| })} | |||||
| error={Boolean(formState.errors.otHours)} | |||||
| /> | |||||
| <TextField | |||||
| label={t("Remark")} | |||||
| fullWidth | |||||
| multiline | |||||
| rows={2} | |||||
| error={Boolean(formState.errors.remark)} | |||||
| {...register("remark", { | |||||
| validate: (value) => Boolean(projectId || value), | |||||
| })} | |||||
| /> | |||||
| <Box display="flex" justifyContent="flex-end" gap={1}> | |||||
| {onDelete && ( | |||||
| <Button | |||||
| variant="outlined" | |||||
| startIcon={<Delete />} | |||||
| color="error" | |||||
| onClick={onDelete} | |||||
| > | |||||
| {t("Delete")} | |||||
| </Button> | |||||
| )} | |||||
| <Button | |||||
| variant="contained" | |||||
| startIcon={<Check />} | |||||
| onClick={saveHandler} | |||||
| > | |||||
| {t("Save")} | |||||
| </Button> | |||||
| </Box> | |||||
| </Paper> | |||||
| </Modal> | |||||
| ); | |||||
| }; | |||||
| export default TimesheetEditModal; | |||||