| @@ -94,6 +94,34 @@ export const saveMemberLeave = async (data: { | |||||
| ); | ); | ||||
| }; | }; | ||||
| export const deleteMemberEntry = async (data: { | |||||
| staffId: number; | |||||
| entryId: number; | |||||
| }) => { | |||||
| return serverFetchJson<RecordTimesheetInput>( | |||||
| `${BASE_API_URL}/timesheets/deleteMemberEntry`, | |||||
| { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }, | |||||
| ); | |||||
| }; | |||||
| export const deleteMemberLeave = async (data: { | |||||
| staffId: number; | |||||
| entryId: number; | |||||
| }) => { | |||||
| return serverFetchJson<RecordLeaveInput>( | |||||
| `${BASE_API_URL}/timesheets/deleteMemberLeave`, | |||||
| { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }, | |||||
| ); | |||||
| }; | |||||
| export const revalidateCacheAfterAmendment = () => { | export const revalidateCacheAfterAmendment = () => { | ||||
| revalidatePath("/(main)/home"); | revalidatePath("/(main)/home"); | ||||
| }; | }; | ||||
| @@ -28,7 +28,7 @@ export const validateTimeEntry = ( | |||||
| if (!entry.inputHours && !entry.otHours) { | if (!entry.inputHours && !entry.otHours) { | ||||
| error[isHoliday ? "otHours" : "inputHours"] = "Required"; | error[isHoliday ? "otHours" : "inputHours"] = "Required"; | ||||
| } else if (entry.inputHours && isHoliday) { | } else if (entry.inputHours && isHoliday) { | ||||
| error.inputHours = "Cannot input normal hours for holidays"; | |||||
| error.inputHours = "Cannot input normal hours on holidays"; | |||||
| } else if (entry.inputHours && entry.inputHours <= 0) { | } else if (entry.inputHours && entry.inputHours <= 0) { | ||||
| error.inputHours = | error.inputHours = | ||||
| "Input hours should be between 0 and {{DAILY_NORMAL_MAX_HOURS}}"; | "Input hours should be between 0 and {{DAILY_NORMAL_MAX_HOURS}}"; | ||||
| @@ -55,16 +55,31 @@ export const validateTimeEntry = ( | |||||
| return Object.keys(error).length > 0 ? error : undefined; | return Object.keys(error).length > 0 ? error : undefined; | ||||
| }; | }; | ||||
| export const isValidLeaveEntry = (entry: Partial<LeaveEntry>): string => { | |||||
| export type LeaveEntryError = { | |||||
| [field in keyof LeaveEntry]?: string; | |||||
| }; | |||||
| export const validateLeaveEntry = ( | |||||
| entry: Partial<LeaveEntry>, | |||||
| isHoliday: boolean, | |||||
| ): LeaveEntryError | undefined => { | |||||
| // Test for errrors | // Test for errrors | ||||
| let error: keyof LeaveEntry | "" = ""; | |||||
| const error: LeaveEntryError = {}; | |||||
| if (!entry.leaveTypeId) { | if (!entry.leaveTypeId) { | ||||
| error = "leaveTypeId"; | |||||
| } else if (!entry.inputHours || !(entry.inputHours >= 0)) { | |||||
| error = "inputHours"; | |||||
| error.leaveTypeId = "Required"; | |||||
| } else if (entry.inputHours && isHoliday) { | |||||
| error.inputHours = "Cannot input normal hours on holidays"; | |||||
| } else if (!entry.inputHours) { | |||||
| error.inputHours = "Required"; | |||||
| } else if ( | |||||
| entry.inputHours && | |||||
| (entry.inputHours <= 0 || entry.inputHours > DAILY_NORMAL_MAX_HOURS) | |||||
| ) { | |||||
| error.inputHours = | |||||
| "Input hours should be between 0 and {{DAILY_NORMAL_MAX_HOURS}}"; | |||||
| } | } | ||||
| return error; | |||||
| return Object.keys(error).length > 0 ? error : undefined; | |||||
| }; | }; | ||||
| export const validateTimesheet = ( | export const validateTimesheet = ( | ||||
| @@ -95,27 +110,10 @@ export const validateTimesheet = ( | |||||
| } | } | ||||
| // Check total hours | // Check total hours | ||||
| const leaves = leaveRecords[date]; | |||||
| const leaveHours = | |||||
| leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; | |||||
| const totalInputHours = timeEntries.reduce((acc, entry) => { | |||||
| return acc + (entry.inputHours || 0); | |||||
| }, 0); | |||||
| const totalOtHours = timeEntries.reduce((acc, entry) => { | |||||
| return acc + (entry.otHours || 0); | |||||
| }, 0); | |||||
| if (totalInputHours + leaveHours > DAILY_NORMAL_MAX_HOURS) { | |||||
| errors[date] = | |||||
| "The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours or decrease the leave hours."; | |||||
| } else if ( | |||||
| totalInputHours + totalOtHours + leaveHours > | |||||
| TIMESHEET_DAILY_MAX_HOURS | |||||
| ) { | |||||
| errors[date] = | |||||
| "The daily total hours cannot be more than {{TIMESHEET_DAILY_MAX_HOURS}}"; | |||||
| const leaves = leaveRecords[date] || []; | |||||
| const totalHourError = checkTotalHours(timeEntries, leaves); | |||||
| if (totalHourError) { | |||||
| errors[date] = totalHourError; | |||||
| } | } | ||||
| }); | }); | ||||
| @@ -125,48 +123,64 @@ export const validateTimesheet = ( | |||||
| export const validateLeaveRecord = ( | export const validateLeaveRecord = ( | ||||
| leaveRecords: RecordLeaveInput, | leaveRecords: RecordLeaveInput, | ||||
| timesheet: RecordTimesheetInput, | timesheet: RecordTimesheetInput, | ||||
| companyHolidays: HolidaysResult[], | |||||
| ): { [date: string]: string } | undefined => { | ): { [date: string]: string } | undefined => { | ||||
| const errors: { [date: string]: string } = {}; | const errors: { [date: string]: string } = {}; | ||||
| const holidays = new Set( | |||||
| compact([ | |||||
| ...getPublicHolidaysForNYears(2).map((h) => h.date), | |||||
| ...companyHolidays.map((h) => convertDateArrayToString(h.date)), | |||||
| ]), | |||||
| ); | |||||
| Object.keys(leaveRecords).forEach((date) => { | Object.keys(leaveRecords).forEach((date) => { | ||||
| const leaves = leaveRecords[date]; | const leaves = leaveRecords[date]; | ||||
| // Check each leave entry | // Check each leave entry | ||||
| for (const entry of leaves) { | for (const entry of leaves) { | ||||
| const entryError = isValidLeaveEntry(entry); | |||||
| const entryError = validateLeaveEntry(entry, holidays.has(date)); | |||||
| if (entryError) { | if (entryError) { | ||||
| errors[date] = "There are errors in the entries"; | errors[date] = "There are errors in the entries"; | ||||
| return; | |||||
| } | } | ||||
| } | } | ||||
| // Check total hours | // Check total hours | ||||
| const timeEntries = timesheet[date] || []; | const timeEntries = timesheet[date] || []; | ||||
| const leaveHours = leaves.reduce((acc, entry) => acc + entry.inputHours, 0); | |||||
| const totalInputHours = timeEntries.reduce((acc, entry) => { | |||||
| return acc + (entry.inputHours || 0); | |||||
| }, 0); | |||||
| const totalOtHours = timeEntries.reduce((acc, entry) => { | |||||
| return acc + (entry.otHours || 0); | |||||
| }, 0); | |||||
| if (totalInputHours + leaveHours > DAILY_NORMAL_MAX_HOURS) { | |||||
| errors[date] = | |||||
| "The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours or decrease the leave hours."; | |||||
| } else if ( | |||||
| totalInputHours + totalOtHours + leaveHours > | |||||
| TIMESHEET_DAILY_MAX_HOURS | |||||
| ) { | |||||
| errors[date] = | |||||
| "The daily total hours cannot be more than {{TIMESHEET_DAILY_MAX_HOURS}}"; | |||||
| const totalHourError = checkTotalHours(timeEntries, leaves); | |||||
| if (totalHourError) { | |||||
| errors[date] = totalHourError; | |||||
| } | } | ||||
| }); | }); | ||||
| return Object.keys(errors).length > 0 ? errors : undefined; | return Object.keys(errors).length > 0 ? errors : undefined; | ||||
| }; | }; | ||||
| export const checkTotalHours = ( | |||||
| timeEntries: TimeEntry[], | |||||
| leaves: LeaveEntry[], | |||||
| ): string | undefined => { | |||||
| const leaveHours = leaves.reduce((acc, entry) => acc + entry.inputHours, 0); | |||||
| const totalInputHours = timeEntries.reduce((acc, entry) => { | |||||
| return acc + (entry.inputHours || 0); | |||||
| }, 0); | |||||
| const totalOtHours = timeEntries.reduce((acc, entry) => { | |||||
| return acc + (entry.otHours || 0); | |||||
| }, 0); | |||||
| if (totalInputHours + leaveHours > DAILY_NORMAL_MAX_HOURS) { | |||||
| return "The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours or decrease the leave hours."; | |||||
| } else if ( | |||||
| totalInputHours + totalOtHours + leaveHours > | |||||
| TIMESHEET_DAILY_MAX_HOURS | |||||
| ) { | |||||
| return "The daily total hours cannot be more than {{TIMESHEET_DAILY_MAX_HOURS}}"; | |||||
| } | |||||
| }; | |||||
| export const DAILY_NORMAL_MAX_HOURS = 8; | export const DAILY_NORMAL_MAX_HOURS = 8; | ||||
| export const LEAVE_DAILY_MAX_HOURS = 8; | |||||
| export const TIMESHEET_DAILY_MAX_HOURS = 20; | export const TIMESHEET_DAILY_MAX_HOURS = 20; | ||||
| @@ -81,7 +81,11 @@ const LeaveModal: React.FC<Props> = ({ | |||||
| const onSubmit = useCallback<SubmitHandler<RecordLeaveInput>>( | const onSubmit = useCallback<SubmitHandler<RecordLeaveInput>>( | ||||
| async (data) => { | async (data) => { | ||||
| const errors = validateLeaveRecord(data, timesheetRecords); | |||||
| const errors = validateLeaveRecord( | |||||
| data, | |||||
| timesheetRecords, | |||||
| companyHolidays, | |||||
| ); | |||||
| if (errors) { | if (errors) { | ||||
| Object.keys(errors).forEach((date) => | Object.keys(errors).forEach((date) => | ||||
| formProps.setError(date, { | formProps.setError(date, { | ||||
| @@ -106,7 +110,7 @@ const LeaveModal: React.FC<Props> = ({ | |||||
| formProps.reset(newFormValues); | formProps.reset(newFormValues); | ||||
| onClose(); | onClose(); | ||||
| }, | }, | ||||
| [formProps, onClose, timesheetRecords, username], | |||||
| [companyHolidays, formProps, onClose, timesheetRecords, username], | |||||
| ); | ); | ||||
| const onCancel = useCallback(() => { | const onCancel = useCallback(() => { | ||||
| @@ -1,6 +1,6 @@ | |||||
| import { LeaveType } from "@/app/api/timesheets"; | import { LeaveType } from "@/app/api/timesheets"; | ||||
| import { LeaveEntry } from "@/app/api/timesheets/actions"; | import { LeaveEntry } from "@/app/api/timesheets/actions"; | ||||
| import { LEAVE_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils"; | |||||
| import { DAILY_NORMAL_MAX_HOURS } from "@/app/api/timesheets/utils"; | |||||
| import { shortDateFormatter } from "@/app/utils/formatUtil"; | import { shortDateFormatter } from "@/app/utils/formatUtil"; | ||||
| import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; | import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; | ||||
| import { Check, Delete } from "@mui/icons-material"; | import { Check, Delete } from "@mui/icons-material"; | ||||
| @@ -24,7 +24,7 @@ import { useTranslation } from "react-i18next"; | |||||
| export interface Props extends Omit<ModalProps, "children"> { | export interface Props extends Omit<ModalProps, "children"> { | ||||
| onSave: (leaveEntry: LeaveEntry, recordDate?: string) => Promise<void>; | onSave: (leaveEntry: LeaveEntry, recordDate?: string) => Promise<void>; | ||||
| onDelete?: () => void; | |||||
| onDelete?: () => Promise<void>; | |||||
| leaveTypes: LeaveType[]; | leaveTypes: LeaveType[]; | ||||
| defaultValues?: Partial<LeaveEntry>; | defaultValues?: Partial<LeaveEntry>; | ||||
| modalSx?: SxProps; | modalSx?: SxProps; | ||||
| @@ -59,7 +59,7 @@ const LeaveEditModal: React.FC<Props> = ({ | |||||
| t, | t, | ||||
| i18n: { language }, | i18n: { language }, | ||||
| } = useTranslation("home"); | } = useTranslation("home"); | ||||
| const { register, control, reset, getValues, trigger, formState } = | |||||
| const { register, control, reset, getValues, trigger, formState, setError } = | |||||
| useForm<LeaveEntry>({ | useForm<LeaveEntry>({ | ||||
| defaultValues: { | defaultValues: { | ||||
| leaveTypeId: leaveTypes[0].id, | leaveTypeId: leaveTypes[0].id, | ||||
| @@ -73,10 +73,16 @@ const LeaveEditModal: React.FC<Props> = ({ | |||||
| const saveHandler = useCallback(async () => { | const saveHandler = useCallback(async () => { | ||||
| const valid = await trigger(); | const valid = await trigger(); | ||||
| if (valid) { | if (valid) { | ||||
| await onSave(getValues(), recordDate); | |||||
| reset({ id: Date.now() }); | |||||
| 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, trigger]); | |||||
| }, [getValues, onSave, recordDate, reset, setError, trigger]); | |||||
| const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | ||||
| (...args) => { | (...args) => { | ||||
| @@ -121,12 +127,19 @@ const LeaveEditModal: React.FC<Props> = ({ | |||||
| fullWidth | fullWidth | ||||
| {...register("inputHours", { | {...register("inputHours", { | ||||
| setValueAs: (value) => roundToNearestQuarter(parseFloat(value)), | setValueAs: (value) => roundToNearestQuarter(parseFloat(value)), | ||||
| validate: (value) => | |||||
| (0 < value && value <= LEAVE_DAILY_MAX_HOURS) || | |||||
| t( | |||||
| "Input hours should be between 0 and {{LEAVE_DAILY_MAX_HOURS}}", | |||||
| { LEAVE_DAILY_MAX_HOURS }, | |||||
| ), | |||||
| validate: (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 }, | |||||
| ) | |||||
| ); | |||||
| }, | |||||
| })} | })} | ||||
| error={Boolean(formState.errors.inputHours)} | error={Boolean(formState.errors.inputHours)} | ||||
| helperText={formState.errors.inputHours?.message} | helperText={formState.errors.inputHours?.message} | ||||
| @@ -138,6 +151,11 @@ const LeaveEditModal: React.FC<Props> = ({ | |||||
| rows={2} | rows={2} | ||||
| {...register("remark")} | {...register("remark")} | ||||
| /> | /> | ||||
| {formState.errors.root?.message && ( | |||||
| <Typography variant="caption" color="error"> | |||||
| {t(formState.errors.root.message, { DAILY_NORMAL_MAX_HOURS })} | |||||
| </Typography> | |||||
| )} | |||||
| <Box display="flex" justifyContent="flex-end" gap={1}> | <Box display="flex" justifyContent="flex-end" gap={1}> | ||||
| {onDelete && ( | {onDelete && ( | ||||
| <Button | <Button | ||||
| @@ -1,10 +1,14 @@ | |||||
| import { Add, Check, Close, Delete } from "@mui/icons-material"; | import { Add, Check, Close, Delete } from "@mui/icons-material"; | ||||
| import { Box, Button, Typography } from "@mui/material"; | |||||
| import { Box, Button, Tooltip, Typography } from "@mui/material"; | |||||
| import { | import { | ||||
| FooterPropsOverrides, | FooterPropsOverrides, | ||||
| GridActionsCellItem, | GridActionsCellItem, | ||||
| GridCellParams, | |||||
| GridColDef, | GridColDef, | ||||
| GridEditInputCell, | |||||
| GridEditSingleSelectCell, | |||||
| GridEventListener, | GridEventListener, | ||||
| GridRenderEditCellParams, | |||||
| GridRowId, | GridRowId, | ||||
| GridRowModel, | GridRowModel, | ||||
| GridRowModes, | GridRowModes, | ||||
| @@ -21,7 +25,11 @@ import { manhourFormatter } from "@/app/utils/formatUtil"; | |||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import isBetween from "dayjs/plugin/isBetween"; | import isBetween from "dayjs/plugin/isBetween"; | ||||
| import { LeaveType } from "@/app/api/timesheets"; | import { LeaveType } from "@/app/api/timesheets"; | ||||
| import { isValidLeaveEntry } from "@/app/api/timesheets/utils"; | |||||
| import { | |||||
| DAILY_NORMAL_MAX_HOURS, | |||||
| LeaveEntryError, | |||||
| validateLeaveEntry, | |||||
| } from "@/app/api/timesheets/utils"; | |||||
| import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; | import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; | ||||
| dayjs.extend(isBetween); | dayjs.extend(isBetween); | ||||
| @@ -35,11 +43,11 @@ interface Props { | |||||
| type LeaveEntryRow = Partial< | type LeaveEntryRow = Partial< | ||||
| LeaveEntry & { | LeaveEntry & { | ||||
| _isNew: boolean; | _isNew: boolean; | ||||
| _error: string; | |||||
| _error: LeaveEntryError; | |||||
| } | } | ||||
| >; | >; | ||||
| const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => { | |||||
| const EntryInputTable: React.FC<Props> = ({ day, leaveTypes, isHoliday }) => { | |||||
| const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
| const { getValues, setValue, clearErrors } = | const { getValues, setValue, clearErrors } = | ||||
| @@ -67,12 +75,12 @@ const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => { | |||||
| "", | "", | ||||
| ) as LeaveEntryRow; | ) as LeaveEntryRow; | ||||
| const error = isValidLeaveEntry(row); | |||||
| const error = validateLeaveEntry(row, isHoliday); | |||||
| apiRef.current.updateRows([{ id, _error: error }]); | apiRef.current.updateRows([{ id, _error: error }]); | ||||
| return !error; | return !error; | ||||
| }, | }, | ||||
| [apiRef], | |||||
| [apiRef, isHoliday], | |||||
| ); | ); | ||||
| const handleCancel = useCallback( | const handleCancel = useCallback( | ||||
| @@ -163,6 +171,20 @@ const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => { | |||||
| width: 200, | width: 200, | ||||
| editable: true, | editable: true, | ||||
| type: "singleSelect", | type: "singleSelect", | ||||
| renderEditCell(params: GridRenderEditCellParams<LeaveEntryRow>) { | |||||
| const errorMessage = | |||||
| params.row._error?.[params.field as keyof LeaveEntry]; | |||||
| const content = ( | |||||
| <GridEditSingleSelectCell variant="outlined" {...params} /> | |||||
| ); | |||||
| return errorMessage ? ( | |||||
| <Tooltip title={t(errorMessage)} placement="top"> | |||||
| <Box width="100%">{content}</Box> | |||||
| </Tooltip> | |||||
| ) : ( | |||||
| content | |||||
| ); | |||||
| }, | |||||
| valueOptions() { | valueOptions() { | ||||
| return leaveTypes.map((p) => ({ value: p.id, label: p.name })); | return leaveTypes.map((p) => ({ value: p.id, label: p.name })); | ||||
| }, | }, | ||||
| @@ -176,6 +198,18 @@ const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => { | |||||
| width: 150, | width: 150, | ||||
| editable: true, | editable: true, | ||||
| type: "number", | type: "number", | ||||
| renderEditCell(params: GridRenderEditCellParams<LeaveEntryRow>) { | |||||
| const errorMessage = | |||||
| params.row._error?.[params.field as keyof LeaveEntry]; | |||||
| const content = <GridEditInputCell {...params} />; | |||||
| return errorMessage ? ( | |||||
| <Tooltip title={t(errorMessage, { DAILY_NORMAL_MAX_HOURS })}> | |||||
| <Box width="100%">{content}</Box> | |||||
| </Tooltip> | |||||
| ) : ( | |||||
| content | |||||
| ); | |||||
| }, | |||||
| valueParser(value) { | valueParser(value) { | ||||
| return value ? roundToNearestQuarter(value) : value; | return value ? roundToNearestQuarter(value) : value; | ||||
| }, | }, | ||||
| @@ -248,16 +282,10 @@ const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => { | |||||
| onRowEditStop={handleEditStop} | onRowEditStop={handleEditStop} | ||||
| processRowUpdate={processRowUpdate} | processRowUpdate={processRowUpdate} | ||||
| columns={columns} | columns={columns} | ||||
| getCellClassName={(params) => { | |||||
| getCellClassName={(params: GridCellParams<LeaveEntryRow>) => { | |||||
| let classname = ""; | let classname = ""; | ||||
| if (params.row._error === params.field) { | |||||
| if (params.row._error?.[params.field as keyof LeaveEntry]) { | |||||
| classname = "hasError"; | classname = "hasError"; | ||||
| } else if ( | |||||
| params.field === "taskGroupId" && | |||||
| params.row.isPlanned !== undefined && | |||||
| !params.row.isPlanned | |||||
| ) { | |||||
| classname = "hasWarning"; | |||||
| } | } | ||||
| return classname; | return classname; | ||||
| }} | }} | ||||
| @@ -50,9 +50,9 @@ const MobileLeaveEntry: React.FC<Props> = ({ | |||||
| const openEditModal = useCallback( | const openEditModal = useCallback( | ||||
| (defaultValues?: LeaveEntry) => () => { | (defaultValues?: LeaveEntry) => () => { | ||||
| setEditModalProps({ | setEditModalProps({ | ||||
| defaultValues, | |||||
| defaultValues: defaultValues ? { ...defaultValues } : undefined, | |||||
| onDelete: defaultValues | onDelete: defaultValues | ||||
| ? () => { | |||||
| ? async () => { | |||||
| setValue( | setValue( | ||||
| date, | date, | ||||
| currentEntries.filter((entry) => entry.id !== defaultValues.id), | currentEntries.filter((entry) => entry.id !== defaultValues.id), | ||||
| @@ -139,6 +139,7 @@ const MobileLeaveEntry: React.FC<Props> = ({ | |||||
| open={editModalOpen} | open={editModalOpen} | ||||
| onClose={closeEditModal} | onClose={closeEditModal} | ||||
| onSave={onSaveEntry} | onSave={onSaveEntry} | ||||
| isHoliday={Boolean(isHoliday)} | |||||
| {...editModalProps} | {...editModalProps} | ||||
| /> | /> | ||||
| </Box> | </Box> | ||||
| @@ -20,6 +20,8 @@ import { ProjectWithTasks } from "@/app/api/projects"; | |||||
| import { | import { | ||||
| LeaveEntry, | LeaveEntry, | ||||
| TimeEntry, | TimeEntry, | ||||
| deleteMemberEntry, | |||||
| deleteMemberLeave, | |||||
| saveMemberEntry, | saveMemberEntry, | ||||
| saveMemberLeave, | saveMemberLeave, | ||||
| } from "@/app/api/timesheets/actions"; | } from "@/app/api/timesheets/actions"; | ||||
| @@ -29,6 +31,8 @@ import TimesheetEditModal, { | |||||
| import { Props as LeaveEditModalProps } from "../LeaveTable/LeaveEditModal"; | import { Props as LeaveEditModalProps } from "../LeaveTable/LeaveEditModal"; | ||||
| import LeaveEditModal from "../LeaveTable/LeaveEditModal"; | import LeaveEditModal from "../LeaveTable/LeaveEditModal"; | ||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import { checkTotalHours } from "@/app/api/timesheets/utils"; | |||||
| import unionBy from "lodash/unionBy"; | |||||
| export interface Props { | export interface Props { | ||||
| leaveTypes: LeaveType[]; | leaveTypes: LeaveType[]; | ||||
| @@ -119,13 +123,30 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||||
| const openEditModal = useCallback( | const openEditModal = useCallback( | ||||
| (defaultValues?: TimeEntry, recordDate?: string, isHoliday?: boolean) => { | (defaultValues?: TimeEntry, recordDate?: string, isHoliday?: boolean) => { | ||||
| setEditModalProps({ | setEditModalProps({ | ||||
| defaultValues, | |||||
| defaultValues: defaultValues ? { ...defaultValues } : undefined, | |||||
| recordDate, | recordDate, | ||||
| isHoliday, | isHoliday, | ||||
| onDelete: defaultValues | |||||
| ? async () => { | |||||
| const intStaffId = parseInt(selectedStaff.id); | |||||
| const newMemberTimesheets = await deleteMemberEntry({ | |||||
| staffId: intStaffId, | |||||
| entryId: defaultValues.id, | |||||
| }); | |||||
| setLocalTeamTimesheets((timesheets) => ({ | |||||
| ...timesheets, | |||||
| [intStaffId]: { | |||||
| ...timesheets[intStaffId], | |||||
| timeEntries: newMemberTimesheets, | |||||
| }, | |||||
| })); | |||||
| setEditModalOpen(false); | |||||
| } | |||||
| : undefined, | |||||
| }); | }); | ||||
| setEditModalOpen(true); | setEditModalOpen(true); | ||||
| }, | }, | ||||
| [], | |||||
| [selectedStaff.id], | |||||
| ); | ); | ||||
| const closeEditModal = useCallback(() => { | const closeEditModal = useCallback(() => { | ||||
| @@ -141,13 +162,30 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||||
| const openLeaveEditModal = useCallback( | const openLeaveEditModal = useCallback( | ||||
| (defaultValues?: LeaveEntry, recordDate?: string, isHoliday?: boolean) => { | (defaultValues?: LeaveEntry, recordDate?: string, isHoliday?: boolean) => { | ||||
| setLeaveEditModalProps({ | setLeaveEditModalProps({ | ||||
| defaultValues, | |||||
| defaultValues: defaultValues ? { ...defaultValues } : undefined, | |||||
| recordDate, | recordDate, | ||||
| isHoliday, | isHoliday, | ||||
| onDelete: defaultValues | |||||
| ? async () => { | |||||
| const intStaffId = parseInt(selectedStaff.id); | |||||
| const newMemberLeaves = await deleteMemberLeave({ | |||||
| staffId: intStaffId, | |||||
| entryId: defaultValues.id, | |||||
| }); | |||||
| setLocalTeamLeaves((leaves) => ({ | |||||
| ...leaves, | |||||
| [intStaffId]: { | |||||
| ...leaves[intStaffId], | |||||
| leaveEntries: newMemberLeaves, | |||||
| }, | |||||
| })); | |||||
| setLeaveEditModalOpen(false); | |||||
| } | |||||
| : undefined, | |||||
| }); | }); | ||||
| setLeaveEditModalOpen(true); | setLeaveEditModalOpen(true); | ||||
| }, | }, | ||||
| [], | |||||
| [selectedStaff.id], | |||||
| ); | ); | ||||
| const closeLeaveEditModal = useCallback(() => { | const closeLeaveEditModal = useCallback(() => { | ||||
| @@ -260,10 +298,44 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||||
| [companyHolidays, openEditModal], | [companyHolidays, openEditModal], | ||||
| ); | ); | ||||
| const checkTotalHoursForDate = useCallback( | |||||
| (newEntry: TimeEntry | LeaveEntry, date?: string) => { | |||||
| if (!date) { | |||||
| throw Error("Invalid date"); | |||||
| } | |||||
| const intStaffId = parseInt(selectedStaff.id); | |||||
| const leaves = localTeamLeaves[intStaffId].leaveEntries[date] || []; | |||||
| const timesheets = | |||||
| localTeamTimesheets[intStaffId].timeEntries[date] || []; | |||||
| let totalHourError; | |||||
| if ((newEntry as LeaveEntry).leaveTypeId) { | |||||
| // newEntry is a leave entry | |||||
| const leavesWithNewEntry = unionBy( | |||||
| [newEntry as LeaveEntry], | |||||
| leaves, | |||||
| "id", | |||||
| ); | |||||
| totalHourError = checkTotalHours(timesheets, leavesWithNewEntry); | |||||
| } else { | |||||
| // newEntry is a timesheet entry | |||||
| const timesheetsWithNewEntry = unionBy( | |||||
| [newEntry as TimeEntry], | |||||
| timesheets, | |||||
| "id", | |||||
| ); | |||||
| totalHourError = checkTotalHours(timesheetsWithNewEntry, leaves); | |||||
| } | |||||
| if (totalHourError) throw Error(totalHourError); | |||||
| }, | |||||
| [localTeamLeaves, localTeamTimesheets, selectedStaff.id], | |||||
| ); | |||||
| const handleSave = useCallback( | const handleSave = useCallback( | ||||
| async (timeEntry: TimeEntry, recordDate?: string) => { | async (timeEntry: TimeEntry, recordDate?: string) => { | ||||
| // TODO: should be fine, but can handle parse error | // TODO: should be fine, but can handle parse error | ||||
| const intStaffId = parseInt(selectedStaff.id); | const intStaffId = parseInt(selectedStaff.id); | ||||
| checkTotalHoursForDate(timeEntry, recordDate); | |||||
| const newMemberTimesheets = await saveMemberEntry({ | const newMemberTimesheets = await saveMemberEntry({ | ||||
| staffId: intStaffId, | staffId: intStaffId, | ||||
| entry: timeEntry, | entry: timeEntry, | ||||
| @@ -278,12 +350,13 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||||
| })); | })); | ||||
| setEditModalOpen(false); | setEditModalOpen(false); | ||||
| }, | }, | ||||
| [selectedStaff.id], | |||||
| [checkTotalHoursForDate, selectedStaff.id], | |||||
| ); | ); | ||||
| const handleSaveLeave = useCallback( | const handleSaveLeave = useCallback( | ||||
| async (leaveEntry: LeaveEntry, recordDate?: string) => { | async (leaveEntry: LeaveEntry, recordDate?: string) => { | ||||
| const intStaffId = parseInt(selectedStaff.id); | const intStaffId = parseInt(selectedStaff.id); | ||||
| checkTotalHoursForDate(leaveEntry, recordDate); | |||||
| const newMemberLeaves = await saveMemberLeave({ | const newMemberLeaves = await saveMemberLeave({ | ||||
| staffId: intStaffId, | staffId: intStaffId, | ||||
| recordDate, | recordDate, | ||||
| @@ -298,7 +371,7 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||||
| })); | })); | ||||
| setLeaveEditModalOpen(false); | setLeaveEditModalOpen(false); | ||||
| }, | }, | ||||
| [selectedStaff.id], | |||||
| [checkTotalHoursForDate, selectedStaff.id], | |||||
| ); | ); | ||||
| return ( | return ( | ||||
| @@ -328,7 +328,7 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| const content = <GridEditInputCell {...params} />; | const content = <GridEditInputCell {...params} />; | ||||
| return errorMessage ? ( | return errorMessage ? ( | ||||
| <Tooltip title={t(errorMessage, { DAILY_NORMAL_MAX_HOURS })}> | <Tooltip title={t(errorMessage, { DAILY_NORMAL_MAX_HOURS })}> | ||||
| {content} | |||||
| <Box width="100%">{content}</Box> | |||||
| </Tooltip> | </Tooltip> | ||||
| ) : ( | ) : ( | ||||
| content | content | ||||
| @@ -352,7 +352,9 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| params.row._error?.[params.field as keyof TimeEntry]; | params.row._error?.[params.field as keyof TimeEntry]; | ||||
| const content = <GridEditInputCell {...params} />; | const content = <GridEditInputCell {...params} />; | ||||
| return errorMessage ? ( | return errorMessage ? ( | ||||
| <Tooltip title={t(errorMessage)}>{content}</Tooltip> | |||||
| <Tooltip title={t(errorMessage)}> | |||||
| <Box width="100%">{content}</Box> | |||||
| </Tooltip> | |||||
| ) : ( | ) : ( | ||||
| content | content | ||||
| ); | ); | ||||
| @@ -375,7 +377,9 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| params.row._error?.[params.field as keyof TimeEntry]; | params.row._error?.[params.field as keyof TimeEntry]; | ||||
| const content = <GridEditInputCell {...params} />; | const content = <GridEditInputCell {...params} />; | ||||
| return errorMessage ? ( | return errorMessage ? ( | ||||
| <Tooltip title={t(errorMessage)}>{content}</Tooltip> | |||||
| <Tooltip title={t(errorMessage)}> | |||||
| <Box width="100%">{content}</Box> | |||||
| </Tooltip> | |||||
| ) : ( | ) : ( | ||||
| content | content | ||||
| ); | ); | ||||
| @@ -207,7 +207,7 @@ const FastTimeEntryModal: React.FC<Props> = ({ | |||||
| validate: (value) => { | validate: (value) => { | ||||
| if (value) { | if (value) { | ||||
| if (isHoliday) { | if (isHoliday) { | ||||
| return t("Cannot input normal hours for holidays"); | |||||
| return t("Cannot input normal hours on holidays"); | |||||
| } | } | ||||
| return ( | return ( | ||||
| @@ -60,9 +60,9 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
| const openEditModal = useCallback( | const openEditModal = useCallback( | ||||
| (defaultValues?: TimeEntry) => () => { | (defaultValues?: TimeEntry) => () => { | ||||
| setEditModalProps({ | setEditModalProps({ | ||||
| defaultValues, | |||||
| defaultValues: defaultValues ? { ...defaultValues } : undefined, | |||||
| onDelete: defaultValues | onDelete: defaultValues | ||||
| ? () => { | |||||
| ? async () => { | |||||
| setValue( | setValue( | ||||
| date, | date, | ||||
| currentEntries.filter((entry) => entry.id !== defaultValues.id), | currentEntries.filter((entry) => entry.id !== defaultValues.id), | ||||
| @@ -27,7 +27,7 @@ import { DAILY_NORMAL_MAX_HOURS } from "@/app/api/timesheets/utils"; | |||||
| export interface Props extends Omit<ModalProps, "children"> { | export interface Props extends Omit<ModalProps, "children"> { | ||||
| onSave: (timeEntry: TimeEntry, recordDate?: string) => Promise<void>; | onSave: (timeEntry: TimeEntry, recordDate?: string) => Promise<void>; | ||||
| onDelete?: () => void; | |||||
| onDelete?: () => Promise<void>; | |||||
| defaultValues?: Partial<TimeEntry>; | defaultValues?: Partial<TimeEntry>; | ||||
| allProjects: ProjectWithTasks[]; | allProjects: ProjectWithTasks[]; | ||||
| assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
| @@ -94,6 +94,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||||
| getValues, | getValues, | ||||
| setValue, | setValue, | ||||
| trigger, | trigger, | ||||
| setError, | |||||
| formState, | formState, | ||||
| watch, | watch, | ||||
| } = useForm<TimeEntry>(); | } = useForm<TimeEntry>(); | ||||
| @@ -105,10 +106,16 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||||
| const saveHandler = useCallback(async () => { | const saveHandler = useCallback(async () => { | ||||
| const valid = await trigger(); | const valid = await trigger(); | ||||
| if (valid) { | if (valid) { | ||||
| await onSave(getValues(), recordDate); | |||||
| reset({ id: Date.now() }); | |||||
| 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, trigger]); | |||||
| }, [getValues, onSave, recordDate, reset, setError, trigger]); | |||||
| const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | ||||
| (...args) => { | (...args) => { | ||||
| @@ -227,7 +234,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||||
| validate: (value) => { | validate: (value) => { | ||||
| if (value) { | if (value) { | ||||
| if (isHoliday) { | if (isHoliday) { | ||||
| return t("Cannot input normal hours for holidays"); | |||||
| return t("Cannot input normal hours on holidays"); | |||||
| } | } | ||||
| return ( | return ( | ||||
| @@ -268,6 +275,11 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||||
| })} | })} | ||||
| helperText={formState.errors.remark?.message} | helperText={formState.errors.remark?.message} | ||||
| /> | /> | ||||
| {formState.errors.root?.message && ( | |||||
| <Typography variant="caption" color="error"> | |||||
| {t(formState.errors.root.message, { DAILY_NORMAL_MAX_HOURS })} | |||||
| </Typography> | |||||
| )} | |||||
| <Box display="flex" justifyContent="flex-end" gap={1}> | <Box display="flex" justifyContent="flex-end" gap={1}> | ||||
| {onDelete && ( | {onDelete && ( | ||||
| <Button | <Button | ||||