Browse Source

Add more validation for timesheet entry

tags/Baseline_30082024_FRONTEND_UAT
Wayne 1 year ago
parent
commit
366865368b
10 changed files with 209 additions and 22 deletions
  1. +66
    -2
      src/app/api/timesheets/utils.ts
  2. +33
    -5
      src/components/DateHoursTable/DateHoursList.tsx
  3. +30
    -8
      src/components/DateHoursTable/DateHoursTable.tsx
  4. +28
    -0
      src/components/ErrorAlert/ErrorAlert.tsx
  5. +1
    -0
      src/components/ErrorAlert/index.ts
  6. +7
    -1
      src/components/LeaveTable/LeaveEditModal.tsx
  7. +32
    -1
      src/components/TimesheetModal/TimesheetModal.tsx
  8. +4
    -2
      src/components/TimesheetTable/EntryInputTable.tsx
  9. +5
    -3
      src/components/TimesheetTable/MobileTimesheetEntry.tsx
  10. +3
    -0
      src/components/TimesheetTable/MobileTimesheetTable.tsx

+ 66
- 2
src/app/api/timesheets/utils.ts View File

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

+ 33
- 5
src/components/DateHoursTable/DateHoursList.tsx View File

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


+ 30
- 8
src/components/DateHoursTable/DateHoursTable.tsx View File

@@ -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,
}, },
)} )}
> >


+ 28
- 0
src/components/ErrorAlert/ErrorAlert.tsx View File

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

+ 1
- 0
src/components/ErrorAlert/index.ts View File

@@ -0,0 +1 @@
export { default } from "./ErrorAlert";

+ 7
- 1
src/components/LeaveTable/LeaveEditModal.tsx View File

@@ -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")}


+ 32
- 1
src/components/TimesheetModal/TimesheetModal.tsx View File

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


+ 4
- 2
src/components/TimesheetTable/EntryInputTable.tsx View File

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


+ 5
- 3
src/components/TimesheetTable/MobileTimesheetEntry.tsx View File

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


+ 3
- 0
src/components/TimesheetTable/MobileTimesheetTable.tsx View File

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


Loading…
Cancel
Save