@@ -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 = { | export type TimeEntryError = { | ||||
[field in keyof TimeEntry]?: string; | [field in keyof TimeEntry]?: string; | ||||
@@ -6,7 +15,7 @@ export type TimeEntryError = { | |||||
/** | /** | ||||
* @param entry - the time entry | * @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 = ( | export const validateTimeEntry = ( | ||||
entry: Partial<TimeEntry>, | entry: Partial<TimeEntry>, | ||||
@@ -58,6 +67,61 @@ export const isValidLeaveEntry = (entry: Partial<LeaveEntry>): string => { | |||||
return error; | 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 DAILY_NORMAL_MAX_HOURS = 8; | ||||
export const LEAVE_DAILY_MAX_HOURS = 8; | export const LEAVE_DAILY_MAX_HOURS = 8; | ||||
export const TIMESHEET_DAILY_MAX_HOURS = 20; | export const TIMESHEET_DAILY_MAX_HOURS = 20; |
@@ -17,6 +17,7 @@ import dayjs from "dayjs"; | |||||
import React, { useCallback, useState } from "react"; | import React, { useCallback, useState } from "react"; | ||||
import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
import { | import { | ||||
DAILY_NORMAL_MAX_HOURS, | |||||
LEAVE_DAILY_MAX_HOURS, | LEAVE_DAILY_MAX_HOURS, | ||||
TIMESHEET_DAILY_MAX_HOURS, | TIMESHEET_DAILY_MAX_HOURS, | ||||
} from "@/app/api/timesheets/utils"; | } from "@/app/api/timesheets/utils"; | ||||
@@ -32,6 +33,7 @@ interface Props<EntryComponentProps = object> { | |||||
EntryComponentProps & { date: string } | EntryComponentProps & { date: string } | ||||
>; | >; | ||||
entryComponentProps: EntryComponentProps; | entryComponentProps: EntryComponentProps; | ||||
errorComponent?: React.ReactNode; | |||||
} | } | ||||
function DateHoursList<EntryTableProps>({ | function DateHoursList<EntryTableProps>({ | ||||
@@ -41,6 +43,7 @@ function DateHoursList<EntryTableProps>({ | |||||
EntryComponent, | EntryComponent, | ||||
entryComponentProps, | entryComponentProps, | ||||
companyHolidays, | companyHolidays, | ||||
errorComponent, | |||||
}: Props<EntryTableProps>) { | }: Props<EntryTableProps>) { | ||||
const { | const { | ||||
t, | t, | ||||
@@ -83,15 +86,22 @@ function DateHoursList<EntryTableProps>({ | |||||
leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; | leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; | ||||
const timesheet = timesheetEntries[day]; | const timesheet = timesheetEntries[day]; | ||||
const timesheetHours = | |||||
const timesheetNormalHours = | |||||
timesheet?.reduce( | timesheet?.reduce( | ||||
(acc, entry) => | |||||
acc + (entry.inputHours || 0) + (entry.otHours || 0), | |||||
(acc, entry) => acc + (entry.inputHours || 0), | |||||
0, | 0, | ||||
) || 0; | ) || 0; | ||||
const timesheetOtHours = | |||||
timesheet?.reduce( | |||||
(acc, entry) => acc + (entry.otHours || 0), | |||||
0, | |||||
) || 0; | |||||
const timesheetHours = timesheetNormalHours + timesheetOtHours; | |||||
const dailyTotal = leaveHours + timesheetHours; | const dailyTotal = leaveHours + timesheetHours; | ||||
const normalHoursExceeded = | |||||
timesheetNormalHours > DAILY_NORMAL_MAX_HOURS; | |||||
const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS; | const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS; | ||||
const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS; | const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS; | ||||
@@ -122,7 +132,9 @@ function DateHoursList<EntryTableProps>({ | |||||
sx={{ | sx={{ | ||||
display: "flex", | display: "flex", | ||||
justifyContent: "space-between", | justifyContent: "space-between", | ||||
flexWrap: "wrap", | |||||
alignItems: "baseline", | alignItems: "baseline", | ||||
color: normalHoursExceeded ? "error.main" : undefined, | |||||
}} | }} | ||||
> | > | ||||
<Typography variant="body2"> | <Typography variant="body2"> | ||||
@@ -131,6 +143,21 @@ function DateHoursList<EntryTableProps>({ | |||||
<Typography> | <Typography> | ||||
{manhourFormatter.format(timesheetHours)} | {manhourFormatter.format(timesheetHours)} | ||||
</Typography> | </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> | ||||
<Box | <Box | ||||
sx={{ | sx={{ | ||||
@@ -182,9 +209,9 @@ function DateHoursList<EntryTableProps>({ | |||||
variant="caption" | variant="caption" | ||||
> | > | ||||
{t( | {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> | </Typography> | ||||
@@ -198,6 +225,7 @@ function DateHoursList<EntryTableProps>({ | |||||
})} | })} | ||||
</Box> | </Box> | ||||
)} | )} | ||||
{errorComponent} | |||||
<Box padding={2} display="flex" justifyContent="flex-end"> | <Box padding={2} display="flex" justifyContent="flex-end"> | ||||
{isDateSelected ? ( | {isDateSelected ? ( | ||||
<Button | <Button | ||||
@@ -21,6 +21,7 @@ import dayjs from "dayjs"; | |||||
import React, { useState } from "react"; | import React, { useState } from "react"; | ||||
import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
import { | import { | ||||
DAILY_NORMAL_MAX_HOURS, | |||||
LEAVE_DAILY_MAX_HOURS, | LEAVE_DAILY_MAX_HOURS, | ||||
TIMESHEET_DAILY_MAX_HOURS, | TIMESHEET_DAILY_MAX_HOURS, | ||||
} from "@/app/api/timesheets/utils"; | } from "@/app/api/timesheets/utils"; | ||||
@@ -112,14 +113,15 @@ function DayRow<EntryTableProps>({ | |||||
leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; | leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; | ||||
const timesheet = timesheetEntries[day]; | 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 dailyTotal = leaveHours + timesheetHours; | ||||
const normalHoursExceeded = timesheetNormalHours > DAILY_NORMAL_MAX_HOURS; | |||||
const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS; | const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS; | ||||
const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS; | const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS; | ||||
@@ -146,7 +148,27 @@ function DayRow<EntryTableProps>({ | |||||
)} | )} | ||||
</TableCell> | </TableCell> | ||||
{/* Timesheet */} | {/* 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 */} | {/* Leave total */} | ||||
<TableCell | <TableCell | ||||
sx={{ | sx={{ | ||||
@@ -177,9 +199,9 @@ function DayRow<EntryTableProps>({ | |||||
{dailyTotalExceeded && ( | {dailyTotalExceeded && ( | ||||
<Tooltip | <Tooltip | ||||
title={t( | 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 | fullWidth | ||||
{...register("inputHours", { | {...register("inputHours", { | ||||
setValueAs: (value) => roundToNearestQuarter(parseFloat(value)), | 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)} | error={Boolean(formState.errors.inputHours)} | ||||
helperText={formState.errors.inputHours?.message} | |||||
/> | /> | ||||
<TextField | <TextField | ||||
label={t("Remark")} | label={t("Remark")} | ||||
@@ -26,6 +26,12 @@ import FullscreenModal from "../FullscreenModal"; | |||||
import MobileTimesheetTable from "../TimesheetTable/MobileTimesheetTable"; | import MobileTimesheetTable from "../TimesheetTable/MobileTimesheetTable"; | ||||
import useIsMobile from "@/app/utils/useIsMobile"; | import useIsMobile from "@/app/utils/useIsMobile"; | ||||
import { HolidaysResult } from "@/app/api/holidays"; | 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 { | interface Props { | ||||
isOpen: boolean; | isOpen: boolean; | ||||
@@ -77,6 +83,15 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
const onSubmit = useCallback<SubmitHandler<RecordTimesheetInput>>( | const onSubmit = useCallback<SubmitHandler<RecordTimesheetInput>>( | ||||
async (data) => { | 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 savedRecords = await saveTimesheet(data, username); | ||||
const today = dayjs(); | const today = dayjs(); | ||||
@@ -93,7 +108,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
formProps.reset(newFormValues); | formProps.reset(newFormValues); | ||||
onClose(); | onClose(); | ||||
}, | }, | ||||
[formProps, onClose, username], | |||||
[companyHolidays, formProps, leaveRecords, onClose, username], | |||||
); | ); | ||||
const onCancel = useCallback(() => { | const onCancel = useCallback(() => { | ||||
@@ -110,6 +125,20 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
[onClose], | [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(); | const matches = useIsMobile(); | ||||
return ( | return ( | ||||
@@ -138,6 +167,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
leaveRecords={leaveRecords} | leaveRecords={leaveRecords} | ||||
/> | /> | ||||
</Box> | </Box> | ||||
{errorComponent} | |||||
<CardActions sx={{ justifyContent: "flex-end" }}> | <CardActions sx={{ justifyContent: "flex-end" }}> | ||||
<Button | <Button | ||||
variant="outlined" | variant="outlined" | ||||
@@ -176,6 +206,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
allProjects={allProjects} | allProjects={allProjects} | ||||
leaveRecords={leaveRecords} | leaveRecords={leaveRecords} | ||||
errorComponent={errorComponent} | |||||
/> | /> | ||||
</Box> | </Box> | ||||
</FullscreenModal> | </FullscreenModal> | ||||
@@ -89,7 +89,8 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
}, {}); | }, {}); | ||||
}, [assignedProjects]); | }, [assignedProjects]); | ||||
const { getValues, setValue } = useFormContext<RecordTimesheetInput>(); | |||||
const { getValues, setValue, clearErrors } = | |||||
useFormContext<RecordTimesheetInput>(); | |||||
const currentEntries = getValues(day); | const currentEntries = getValues(day); | ||||
const [entries, setEntries] = useState<TimeEntryRow[]>(currentEntries || []); | const [entries, setEntries] = useState<TimeEntryRow[]>(currentEntries || []); | ||||
@@ -398,7 +399,8 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
...entry, | ...entry, | ||||
})), | })), | ||||
]); | ]); | ||||
}, [getValues, entries, setValue, day]); | |||||
clearErrors(day); | |||||
}, [getValues, entries, setValue, day, clearErrors]); | |||||
const hasOutOfPlannedStages = entries.some( | const hasOutOfPlannedStages = entries.some( | ||||
(entry) => entry.isPlanned !== undefined && !entry.isPlanned, | (entry) => entry.isPlanned !== undefined && !entry.isPlanned, | ||||
@@ -51,7 +51,7 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
const holiday = getHolidayForDate(date, companyHolidays); | const holiday = getHolidayForDate(date, companyHolidays); | ||||
const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; | const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; | ||||
const { watch, setValue } = useFormContext<RecordTimesheetInput>(); | |||||
const { watch, setValue, clearErrors } = useFormContext<RecordTimesheetInput>(); | |||||
const currentEntries = watch(date); | const currentEntries = watch(date); | ||||
// Edit modal | // Edit modal | ||||
@@ -70,13 +70,14 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
date, | date, | ||||
currentEntries.filter((entry) => entry.id !== defaultValues.id), | currentEntries.filter((entry) => entry.id !== defaultValues.id), | ||||
); | ); | ||||
clearErrors(date); | |||||
setEditModalOpen(false); | setEditModalOpen(false); | ||||
} | } | ||||
: undefined, | : undefined, | ||||
}); | }); | ||||
setEditModalOpen(true); | setEditModalOpen(true); | ||||
}, | }, | ||||
[currentEntries, date, setValue], | |||||
[clearErrors, currentEntries, date, setValue], | |||||
); | ); | ||||
const closeEditModal = useCallback(() => { | const closeEditModal = useCallback(() => { | ||||
@@ -93,12 +94,13 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
...(e.id === existingEntry.id ? entry : e), | ...(e.id === existingEntry.id ? entry : e), | ||||
})), | })), | ||||
); | ); | ||||
clearErrors(date); | |||||
} else { | } else { | ||||
setValue(date, [...currentEntries, entry]); | setValue(date, [...currentEntries, entry]); | ||||
} | } | ||||
setEditModalOpen(false); | setEditModalOpen(false); | ||||
}, | }, | ||||
[currentEntries, date, setValue], | |||||
[clearErrors, currentEntries, date, setValue], | |||||
); | ); | ||||
return ( | return ( | ||||
@@ -14,6 +14,7 @@ interface Props { | |||||
assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
leaveRecords: RecordLeaveInput; | leaveRecords: RecordLeaveInput; | ||||
companyHolidays: HolidaysResult[]; | companyHolidays: HolidaysResult[]; | ||||
errorComponent?: React.ReactNode; | |||||
} | } | ||||
const MobileTimesheetTable: React.FC<Props> = ({ | const MobileTimesheetTable: React.FC<Props> = ({ | ||||
@@ -21,6 +22,7 @@ const MobileTimesheetTable: React.FC<Props> = ({ | |||||
assignedProjects, | assignedProjects, | ||||
leaveRecords, | leaveRecords, | ||||
companyHolidays, | companyHolidays, | ||||
errorComponent, | |||||
}) => { | }) => { | ||||
const { watch } = useFormContext<RecordTimesheetInput>(); | const { watch } = useFormContext<RecordTimesheetInput>(); | ||||
const currentInput = watch(); | const currentInput = watch(); | ||||
@@ -34,6 +36,7 @@ const MobileTimesheetTable: React.FC<Props> = ({ | |||||
timesheetEntries={currentInput} | timesheetEntries={currentInput} | ||||
EntryComponent={MobileTimesheetEntry} | EntryComponent={MobileTimesheetEntry} | ||||
entryComponentProps={{ allProjects, assignedProjects, companyHolidays }} | entryComponentProps={{ allProjects, assignedProjects, companyHolidays }} | ||||
errorComponent={errorComponent} | |||||
/> | /> | ||||
); | ); | ||||
}; | }; | ||||