@@ -1,4 +1,13 @@ | |||
import { LeaveEntry, TimeEntry } from "./actions"; | |||
import { getPublicHolidaysForNYears } from "@/app/utils/holidayUtils"; | |||
import { HolidaysResult } from "../holidays"; | |||
import { | |||
LeaveEntry, | |||
RecordLeaveInput, | |||
RecordTimesheetInput, | |||
TimeEntry, | |||
} from "./actions"; | |||
import { convertDateArrayToString } from "@/app/utils/formatUtil"; | |||
import compact from "lodash/compact"; | |||
export type TimeEntryError = { | |||
[field in keyof TimeEntry]?: string; | |||
@@ -6,7 +15,7 @@ export type TimeEntryError = { | |||
/** | |||
* @param entry - the time entry | |||
* @returns the field where there is an error, or an empty string if there is none | |||
* @returns an object where the keys are the error fields and the values the error message, and undefined if there are no errors | |||
*/ | |||
export const validateTimeEntry = ( | |||
entry: Partial<TimeEntry>, | |||
@@ -58,6 +67,61 @@ export const isValidLeaveEntry = (entry: Partial<LeaveEntry>): string => { | |||
return error; | |||
}; | |||
export const validateTimesheet = ( | |||
timesheet: RecordTimesheetInput, | |||
leaveRecords: RecordLeaveInput, | |||
companyHolidays: HolidaysResult[], | |||
): { [date: string]: string } | undefined => { | |||
const errors: { [date: string]: string } = {}; | |||
const holidays = new Set( | |||
compact([ | |||
...getPublicHolidaysForNYears(2).map((h) => h.date), | |||
...companyHolidays.map((h) => convertDateArrayToString(h.date)), | |||
]), | |||
); | |||
Object.keys(timesheet).forEach((date) => { | |||
const timeEntries = timesheet[date]; | |||
// Check each entry | |||
for (const entry of timeEntries) { | |||
const entryErrors = validateTimeEntry(entry, holidays.has(date)); | |||
if (entryErrors) { | |||
errors[date] = "There are errors in the entries"; | |||
return; | |||
} | |||
} | |||
// Check total hours | |||
const leaves = leaveRecords[date]; | |||
const leaveHours = | |||
leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; | |||
const totalNormalHours = timeEntries.reduce((acc, entry) => { | |||
return acc + (entry.inputHours || 0); | |||
}, 0); | |||
const totalOtHours = timeEntries.reduce((acc, entry) => { | |||
return acc + (entry.otHours || 0); | |||
}, 0); | |||
if (totalNormalHours > DAILY_NORMAL_MAX_HOURS) { | |||
errors[date] = | |||
"The daily normal hours cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours."; | |||
} else if ( | |||
totalNormalHours + totalOtHours + leaveHours > | |||
TIMESHEET_DAILY_MAX_HOURS | |||
) { | |||
errors[date] = | |||
"The daily total hours cannot be more than {{TIMESHEET_DAILY_MAX_HOURS}}"; | |||
} | |||
}); | |||
return Object.keys(errors).length > 0 ? errors : undefined; | |||
}; | |||
export const DAILY_NORMAL_MAX_HOURS = 8; | |||
export const LEAVE_DAILY_MAX_HOURS = 8; | |||
export const TIMESHEET_DAILY_MAX_HOURS = 20; |
@@ -17,6 +17,7 @@ import dayjs from "dayjs"; | |||
import React, { useCallback, useState } from "react"; | |||
import { useTranslation } from "react-i18next"; | |||
import { | |||
DAILY_NORMAL_MAX_HOURS, | |||
LEAVE_DAILY_MAX_HOURS, | |||
TIMESHEET_DAILY_MAX_HOURS, | |||
} from "@/app/api/timesheets/utils"; | |||
@@ -32,6 +33,7 @@ interface Props<EntryComponentProps = object> { | |||
EntryComponentProps & { date: string } | |||
>; | |||
entryComponentProps: EntryComponentProps; | |||
errorComponent?: React.ReactNode; | |||
} | |||
function DateHoursList<EntryTableProps>({ | |||
@@ -41,6 +43,7 @@ function DateHoursList<EntryTableProps>({ | |||
EntryComponent, | |||
entryComponentProps, | |||
companyHolidays, | |||
errorComponent, | |||
}: Props<EntryTableProps>) { | |||
const { | |||
t, | |||
@@ -83,15 +86,22 @@ function DateHoursList<EntryTableProps>({ | |||
leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; | |||
const timesheet = timesheetEntries[day]; | |||
const timesheetHours = | |||
const timesheetNormalHours = | |||
timesheet?.reduce( | |||
(acc, entry) => | |||
acc + (entry.inputHours || 0) + (entry.otHours || 0), | |||
(acc, entry) => acc + (entry.inputHours || 0), | |||
0, | |||
) || 0; | |||
const timesheetOtHours = | |||
timesheet?.reduce( | |||
(acc, entry) => acc + (entry.otHours || 0), | |||
0, | |||
) || 0; | |||
const timesheetHours = timesheetNormalHours + timesheetOtHours; | |||
const dailyTotal = leaveHours + timesheetHours; | |||
const normalHoursExceeded = | |||
timesheetNormalHours > DAILY_NORMAL_MAX_HOURS; | |||
const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS; | |||
const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS; | |||
@@ -122,7 +132,9 @@ function DateHoursList<EntryTableProps>({ | |||
sx={{ | |||
display: "flex", | |||
justifyContent: "space-between", | |||
flexWrap: "wrap", | |||
alignItems: "baseline", | |||
color: normalHoursExceeded ? "error.main" : undefined, | |||
}} | |||
> | |||
<Typography variant="body2"> | |||
@@ -131,6 +143,21 @@ function DateHoursList<EntryTableProps>({ | |||
<Typography> | |||
{manhourFormatter.format(timesheetHours)} | |||
</Typography> | |||
{normalHoursExceeded && ( | |||
<Typography | |||
component="div" | |||
width="100%" | |||
variant="caption" | |||
paddingInlineEnd="40%" | |||
> | |||
{t( | |||
"The daily normal hours cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours.", | |||
{ | |||
DAILY_NORMAL_MAX_HOURS, | |||
}, | |||
)} | |||
</Typography> | |||
)} | |||
</Box> | |||
<Box | |||
sx={{ | |||
@@ -182,9 +209,9 @@ function DateHoursList<EntryTableProps>({ | |||
variant="caption" | |||
> | |||
{t( | |||
"The daily total hours cannot be more than {{hours}}", | |||
"The daily total hours cannot be more than {{TIMESHEET_DAILY_MAX_HOURS}}", | |||
{ | |||
hours: TIMESHEET_DAILY_MAX_HOURS, | |||
TIMESHEET_DAILY_MAX_HOURS, | |||
}, | |||
)} | |||
</Typography> | |||
@@ -198,6 +225,7 @@ function DateHoursList<EntryTableProps>({ | |||
})} | |||
</Box> | |||
)} | |||
{errorComponent} | |||
<Box padding={2} display="flex" justifyContent="flex-end"> | |||
{isDateSelected ? ( | |||
<Button | |||
@@ -21,6 +21,7 @@ import dayjs from "dayjs"; | |||
import React, { useState } from "react"; | |||
import { useTranslation } from "react-i18next"; | |||
import { | |||
DAILY_NORMAL_MAX_HOURS, | |||
LEAVE_DAILY_MAX_HOURS, | |||
TIMESHEET_DAILY_MAX_HOURS, | |||
} from "@/app/api/timesheets/utils"; | |||
@@ -112,14 +113,15 @@ function DayRow<EntryTableProps>({ | |||
leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; | |||
const timesheet = timesheetEntries[day]; | |||
const timesheetHours = | |||
timesheet?.reduce( | |||
(acc, entry) => acc + (entry.inputHours || 0) + (entry.otHours || 0), | |||
0, | |||
) || 0; | |||
const timesheetNormalHours = | |||
timesheet?.reduce((acc, entry) => acc + (entry.inputHours || 0), 0) || 0; | |||
const timesheetOtHours = | |||
timesheet?.reduce((acc, entry) => acc + (entry.otHours || 0), 0) || 0; | |||
const timesheetHours = timesheetNormalHours + timesheetOtHours; | |||
const dailyTotal = leaveHours + timesheetHours; | |||
const normalHoursExceeded = timesheetNormalHours > DAILY_NORMAL_MAX_HOURS; | |||
const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS; | |||
const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS; | |||
@@ -146,7 +148,27 @@ function DayRow<EntryTableProps>({ | |||
)} | |||
</TableCell> | |||
{/* Timesheet */} | |||
<TableCell>{manhourFormatter.format(timesheetHours)}</TableCell> | |||
<TableCell | |||
sx={{ | |||
color: normalHoursExceeded ? "error.main" : undefined, | |||
}} | |||
> | |||
<Box display="flex" gap={1} alignItems="center"> | |||
{manhourFormatter.format(timesheetHours)} | |||
{normalHoursExceeded && ( | |||
<Tooltip | |||
title={t( | |||
"The daily normal hours cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours.", | |||
{ | |||
DAILY_NORMAL_MAX_HOURS, | |||
}, | |||
)} | |||
> | |||
<Info fontSize="small" /> | |||
</Tooltip> | |||
)} | |||
</Box> | |||
</TableCell> | |||
{/* Leave total */} | |||
<TableCell | |||
sx={{ | |||
@@ -177,9 +199,9 @@ function DayRow<EntryTableProps>({ | |||
{dailyTotalExceeded && ( | |||
<Tooltip | |||
title={t( | |||
"The daily total hours cannot be more than {{hours}}", | |||
"The daily total hours cannot be more than {{TIMESHEET_DAILY_MAX_HOURS}}", | |||
{ | |||
hours: TIMESHEET_DAILY_MAX_HOURS, | |||
TIMESHEET_DAILY_MAX_HOURS, | |||
}, | |||
)} | |||
> | |||
@@ -0,0 +1,28 @@ | |||
import { Alert, AlertTitle, Box } from "@mui/material"; | |||
import compact from "lodash/compact"; | |||
import { useTranslation } from "react-i18next"; | |||
interface Props { | |||
errors: (string | undefined)[]; | |||
} | |||
const ErrorAlert: React.FC<Props> = ({ errors }) => { | |||
const { t } = useTranslation("common"); | |||
if (compact(errors).length === 0) return null; | |||
return ( | |||
<Alert severity="error"> | |||
<AlertTitle>{t("There are some errors")}</AlertTitle> | |||
<Box component="ul"> | |||
{errors.map((error, index) => ( | |||
<Box component="li" key={`${error}-${index}`}> | |||
{error} | |||
</Box> | |||
))} | |||
</Box> | |||
</Alert> | |||
); | |||
}; | |||
export default ErrorAlert; |
@@ -0,0 +1 @@ | |||
export { default } from "./ErrorAlert"; |
@@ -101,9 +101,15 @@ const LeaveEditModal: React.FC<Props> = ({ | |||
fullWidth | |||
{...register("inputHours", { | |||
setValueAs: (value) => roundToNearestQuarter(parseFloat(value)), | |||
validate: (value) => 0 < value && value <= LEAVE_DAILY_MAX_HOURS, | |||
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 }, | |||
), | |||
})} | |||
error={Boolean(formState.errors.inputHours)} | |||
helperText={formState.errors.inputHours?.message} | |||
/> | |||
<TextField | |||
label={t("Remark")} | |||
@@ -26,6 +26,12 @@ import FullscreenModal from "../FullscreenModal"; | |||
import MobileTimesheetTable from "../TimesheetTable/MobileTimesheetTable"; | |||
import useIsMobile from "@/app/utils/useIsMobile"; | |||
import { HolidaysResult } from "@/app/api/holidays"; | |||
import { | |||
DAILY_NORMAL_MAX_HOURS, | |||
TIMESHEET_DAILY_MAX_HOURS, | |||
validateTimesheet, | |||
} from "@/app/api/timesheets/utils"; | |||
import ErrorAlert from "../ErrorAlert"; | |||
interface Props { | |||
isOpen: boolean; | |||
@@ -77,6 +83,15 @@ const TimesheetModal: React.FC<Props> = ({ | |||
const onSubmit = useCallback<SubmitHandler<RecordTimesheetInput>>( | |||
async (data) => { | |||
const errors = validateTimesheet(data, leaveRecords, companyHolidays); | |||
if (errors) { | |||
Object.keys(errors).forEach((date) => | |||
formProps.setError(date, { | |||
message: errors[date], | |||
}), | |||
); | |||
return; | |||
} | |||
const savedRecords = await saveTimesheet(data, username); | |||
const today = dayjs(); | |||
@@ -93,7 +108,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||
formProps.reset(newFormValues); | |||
onClose(); | |||
}, | |||
[formProps, onClose, username], | |||
[companyHolidays, formProps, leaveRecords, onClose, username], | |||
); | |||
const onCancel = useCallback(() => { | |||
@@ -110,6 +125,20 @@ const TimesheetModal: React.FC<Props> = ({ | |||
[onClose], | |||
); | |||
const errorComponent = ( | |||
<ErrorAlert | |||
errors={Object.keys(formProps.formState.errors).map((date) => { | |||
const error = formProps.formState.errors[date]?.message; | |||
return error | |||
? `${date}: ${t(error, { | |||
TIMESHEET_DAILY_MAX_HOURS, | |||
DAILY_NORMAL_MAX_HOURS, | |||
})}` | |||
: undefined; | |||
})} | |||
/> | |||
); | |||
const matches = useIsMobile(); | |||
return ( | |||
@@ -138,6 +167,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||
leaveRecords={leaveRecords} | |||
/> | |||
</Box> | |||
{errorComponent} | |||
<CardActions sx={{ justifyContent: "flex-end" }}> | |||
<Button | |||
variant="outlined" | |||
@@ -176,6 +206,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||
assignedProjects={assignedProjects} | |||
allProjects={allProjects} | |||
leaveRecords={leaveRecords} | |||
errorComponent={errorComponent} | |||
/> | |||
</Box> | |||
</FullscreenModal> | |||
@@ -89,7 +89,8 @@ const EntryInputTable: React.FC<Props> = ({ | |||
}, {}); | |||
}, [assignedProjects]); | |||
const { getValues, setValue } = useFormContext<RecordTimesheetInput>(); | |||
const { getValues, setValue, clearErrors } = | |||
useFormContext<RecordTimesheetInput>(); | |||
const currentEntries = getValues(day); | |||
const [entries, setEntries] = useState<TimeEntryRow[]>(currentEntries || []); | |||
@@ -398,7 +399,8 @@ const EntryInputTable: React.FC<Props> = ({ | |||
...entry, | |||
})), | |||
]); | |||
}, [getValues, entries, setValue, day]); | |||
clearErrors(day); | |||
}, [getValues, entries, setValue, day, clearErrors]); | |||
const hasOutOfPlannedStages = entries.some( | |||
(entry) => entry.isPlanned !== undefined && !entry.isPlanned, | |||
@@ -51,7 +51,7 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||
const holiday = getHolidayForDate(date, companyHolidays); | |||
const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; | |||
const { watch, setValue } = useFormContext<RecordTimesheetInput>(); | |||
const { watch, setValue, clearErrors } = useFormContext<RecordTimesheetInput>(); | |||
const currentEntries = watch(date); | |||
// Edit modal | |||
@@ -70,13 +70,14 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||
date, | |||
currentEntries.filter((entry) => entry.id !== defaultValues.id), | |||
); | |||
clearErrors(date); | |||
setEditModalOpen(false); | |||
} | |||
: undefined, | |||
}); | |||
setEditModalOpen(true); | |||
}, | |||
[currentEntries, date, setValue], | |||
[clearErrors, currentEntries, date, setValue], | |||
); | |||
const closeEditModal = useCallback(() => { | |||
@@ -93,12 +94,13 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||
...(e.id === existingEntry.id ? entry : e), | |||
})), | |||
); | |||
clearErrors(date); | |||
} else { | |||
setValue(date, [...currentEntries, entry]); | |||
} | |||
setEditModalOpen(false); | |||
}, | |||
[currentEntries, date, setValue], | |||
[clearErrors, currentEntries, date, setValue], | |||
); | |||
return ( | |||
@@ -14,6 +14,7 @@ interface Props { | |||
assignedProjects: AssignedProject[]; | |||
leaveRecords: RecordLeaveInput; | |||
companyHolidays: HolidaysResult[]; | |||
errorComponent?: React.ReactNode; | |||
} | |||
const MobileTimesheetTable: React.FC<Props> = ({ | |||
@@ -21,6 +22,7 @@ const MobileTimesheetTable: React.FC<Props> = ({ | |||
assignedProjects, | |||
leaveRecords, | |||
companyHolidays, | |||
errorComponent, | |||
}) => { | |||
const { watch } = useFormContext<RecordTimesheetInput>(); | |||
const currentInput = watch(); | |||
@@ -34,6 +36,7 @@ const MobileTimesheetTable: React.FC<Props> = ({ | |||
timesheetEntries={currentInput} | |||
EntryComponent={MobileTimesheetEntry} | |||
entryComponentProps={{ allProjects, assignedProjects, companyHolidays }} | |||
errorComponent={errorComponent} | |||
/> | |||
); | |||
}; | |||