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