diff --git a/src/components/LeaveTable/LeaveEditModal.tsx b/src/components/LeaveTable/LeaveEditModal.tsx index c176930..70eacaa 100644 --- a/src/components/LeaveTable/LeaveEditModal.tsx +++ b/src/components/LeaveTable/LeaveEditModal.tsx @@ -57,8 +57,9 @@ const LeaveEditModal: React.FC = ({ const valid = await trigger(); if (valid) { onSave(getValues()); + reset(); } - }, [getValues, onSave, trigger]); + }, [getValues, onSave, reset, trigger]); const closeHandler = useCallback>( (...args) => { diff --git a/src/components/LeaveTable/MobileLeaveEntry.tsx b/src/components/LeaveTable/MobileLeaveEntry.tsx index 90fd19a..5fca597 100644 --- a/src/components/LeaveTable/MobileLeaveEntry.tsx +++ b/src/components/LeaveTable/MobileLeaveEntry.tsx @@ -86,93 +86,100 @@ const MobileLeaveEntry: React.FC = ({ date, leaveTypes }) => { ); return ( - + <> {shortDateFormatter(language).format(dayJsObj.toDate())} - {currentEntries.length ? ( - currentEntries.map((entry, index) => { - return ( - - + {currentEntries.length ? ( + currentEntries.map((entry, index) => { + return ( + - - - - {leaveTypeMap[entry.leaveTypeId].name} - - - {manhourFormatter.format(entry.inputHours)} - - - - - - - {entry.remark && ( - - + + {leaveTypeMap[entry.leaveTypeId].name} + + + {manhourFormatter.format(entry.inputHours)} + + + - {t("Remark")} - - {entry.remark} + + - )} - - - ); - }) - ) : ( - - {t("Add some leave entries!")} - - )} - - + {entry.remark && ( + + + {t("Remark")} + + {entry.remark} + + )} + + + ); + }) + ) : ( + + {t("Add some leave entries!")} + + )} + + + + - - + ); }; diff --git a/src/components/TimesheetTable/EntryInputTable.tsx b/src/components/TimesheetTable/EntryInputTable.tsx index 79c890d..d1f5acb 100644 --- a/src/components/TimesheetTable/EntryInputTable.tsx +++ b/src/components/TimesheetTable/EntryInputTable.tsx @@ -277,7 +277,6 @@ const EntryInputTable: React.FC = ({ projectId={params.row.projectId} taskGroupId={params.row.taskGroupId} allProjects={allProjects} - editCellProps={params} onTaskSelect={(taskId) => { params.api.setEditCellValue({ id: params.id, diff --git a/src/components/TimesheetTable/MobileTimesheetEntry.tsx b/src/components/TimesheetTable/MobileTimesheetEntry.tsx index efaf465..9f709d8 100644 --- a/src/components/TimesheetTable/MobileTimesheetEntry.tsx +++ b/src/components/TimesheetTable/MobileTimesheetEntry.tsx @@ -10,10 +10,13 @@ import { Typography, } from "@mui/material"; import dayjs from "dayjs"; -import React from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { useFormContext } from "react-hook-form"; import { useTranslation } from "react-i18next"; import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; +import TimesheetEditModal, { + Props as TimesheetEditModalProps, +} from "./TimesheetEditModal"; interface Props { date: string; @@ -30,11 +33,200 @@ const MobileTimesheetEntry: React.FC = ({ t, i18n: { language }, } = 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 { watch, setValue } = useFormContext(); const currentEntries = watch(date); - return null; + // Edit modal + const [editModalProps, setEditModalProps] = useState< + Partial + >({}); + 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 ( + <> + + {shortDateFormatter(language).format(dayJsObj.toDate())} + + + {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 ( + + + + + + {project + ? `${project.code} - ${project.name}` + : t("Non-billable Task")} + + {task && ( + + {task.name} + + )} + + + + + + + + + {t("Hours")} + + + {manhourFormatter.format(entry.inputHours || 0)} + + + + + {t("Other Hours")} + + + {manhourFormatter.format(entry.otHours || 0)} + + + + {entry.remark && ( + + + {t("Remark")} + + {entry.remark} + + )} + + + ); + }) + ) : ( + + {t("Add some time entries!")} + + )} + + + + + + + ); }; export default MobileTimesheetEntry; diff --git a/src/components/TimesheetTable/ProjectSelect.tsx b/src/components/TimesheetTable/ProjectSelect.tsx index 8ae1ab2..762512c 100644 --- a/src/components/TimesheetTable/ProjectSelect.tsx +++ b/src/components/TimesheetTable/ProjectSelect.tsx @@ -1,9 +1,11 @@ import React, { useCallback, useMemo } from "react"; import { + Autocomplete, ListSubheader, MenuItem, Select, SelectChangeEvent, + TextField, } from "@mui/material"; import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; import { useTranslation } from "react-i18next"; @@ -16,6 +18,49 @@ interface Props { onProjectSelect: (projectId: number | string) => void; } +// const AutocompleteProjectSelect: React.FC = ({ +// 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 ( +// option.group} +// getOptionLabel={(option) => option.label} +// options={options} +// renderInput={(params) => } +// /> +// ); +// }; + const ProjectSelect: React.FC = ({ allProjects, assignedProjects, @@ -68,6 +113,7 @@ const ProjectSelect: React.FC = ({ {`${project.code} - ${project.name}`} )), ]} @@ -79,6 +125,7 @@ const ProjectSelect: React.FC = ({ {`${project.code} - ${project.name}`} )), ]} diff --git a/src/components/TimesheetTable/TaskGroupSelect.tsx b/src/components/TimesheetTable/TaskGroupSelect.tsx index 0173e81..8278156 100644 --- a/src/components/TimesheetTable/TaskGroupSelect.tsx +++ b/src/components/TimesheetTable/TaskGroupSelect.tsx @@ -13,6 +13,7 @@ interface Props { projectId: number | undefined; value: number | undefined; onTaskGroupSelect: (taskGroupId: number | string) => void; + error?: boolean; } const TaskGroupSelect: React.FC = ({ @@ -20,6 +21,7 @@ const TaskGroupSelect: React.FC = ({ projectId, onTaskGroupSelect, taskGroupsByProject, + error, }) => { const { t } = useTranslation("home"); @@ -35,6 +37,7 @@ const TaskGroupSelect: React.FC = ({ return ( = ({ > {tasks.length === 0 && {t("None")}} {tasks.map((task) => ( - + {task.name} ))} diff --git a/src/components/TimesheetTable/TimesheetEditModal.tsx b/src/components/TimesheetTable/TimesheetEditModal.tsx new file mode 100644 index 0000000..2b49d71 --- /dev/null +++ b/src/components/TimesheetTable/TimesheetEditModal.tsx @@ -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 { + onSave: (leaveEntry: TimeEntry) => void; + onDelete?: () => void; + defaultValues?: Partial; + 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 = ({ + 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(); + + 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>( + (...args) => { + onClose?.(...args); + reset(); + }, + [onClose, reset], + ); + + const projectId = watch("projectId"); + const taskGroupId = watch("taskGroupId"); + const otHours = watch("otHours"); + + return ( + + + + {t("Project Code and Name")} + ( + { + field.onChange(newId ?? null); + const firstTaskGroup = ( + typeof newId === "number" ? taskGroupsByProject[newId] : [] + )[0]; + + setValue("taskGroupId", firstTaskGroup?.value); + setValue("taskId", undefined); + }} + /> + )} + rules={{ deps: ["taskGroupId", "taskId"] }} + /> + + + {t("Stage")} + ( + { + field.onChange(newId ?? null); + }} + /> + )} + rules={{ + validate: (id) => { + if (!projectId) { + return !id; + } + const taskGroups = taskGroupsByProject[projectId]; + return taskGroups.some((tg) => tg.value === id); + }, + deps: ["taskId"], + }} + /> + + + {t("Task")} + ( + { + 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)); + }, + }} + /> + + Boolean(value || otHours), + })} + error={Boolean(formState.errors.inputHours)} + /> + + Boolean(projectId || value), + })} + /> + + {onDelete && ( + + )} + + + + + ); +}; + +export default TimesheetEditModal;