@@ -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; |