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 = {
[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;

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

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


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

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


+ 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
{...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")}


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

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


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

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


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

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


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

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


Loading…
Cancel
Save