| @@ -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} | |||
| /> | |||
| ); | |||
| }; | |||