Browse Source

Add some validation error message for timesheet entry

tags/Baseline_30082024_FRONTEND_UAT
Wayne 1 year ago
parent
commit
46d44db3a7
7 changed files with 111 additions and 24 deletions
  1. +23
    -9
      src/app/api/timesheets/utils.ts
  2. +12
    -4
      src/components/DateHoursTable/DateHoursTable.tsx
  3. +2
    -1
      src/components/LeaveTable/LeaveEditModal.tsx
  4. +1
    -0
      src/components/LeaveTable/LeaveEntryTable.tsx
  5. +47
    -7
      src/components/TimesheetTable/EntryInputTable.tsx
  6. +1
    -0
      src/components/TimesheetTable/MobileTimesheetEntry.tsx
  7. +25
    -3
      src/components/TimesheetTable/TimesheetEditModal.tsx

+ 23
- 9
src/app/api/timesheets/utils.ts View File

@@ -1,36 +1,49 @@
import { LeaveEntry, TimeEntry } from "./actions"; import { LeaveEntry, TimeEntry } from "./actions";


export type TimeEntryError = {
[field in keyof TimeEntry]?: string;
};

/** /**
* @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 the field where there is an error, or an empty string if there is none
*/ */
export const isValidTimeEntry = (entry: Partial<TimeEntry>): string => {
export const validateTimeEntry = (
entry: Partial<TimeEntry>,
isHoliday: boolean,
): TimeEntryError | undefined => {
// Test for errors // Test for errors
let error: keyof TimeEntry | "" = "";
const error: TimeEntryError = {};


// Either normal or other hours need to be inputted // Either normal or other hours need to be inputted
if (!entry.inputHours && !entry.otHours) { if (!entry.inputHours && !entry.otHours) {
error = "inputHours";
error[isHoliday ? "otHours" : "inputHours"] = "Required";
} else if (entry.inputHours && isHoliday) {
error.inputHours = "Cannot input normal hours for holidays";
} else if (entry.inputHours && entry.inputHours <= 0) { } else if (entry.inputHours && entry.inputHours <= 0) {
error = "inputHours";
error.inputHours =
"Input hours should be between 0 and {{DAILY_NORMAL_MAX_HOURS}}";
} else if (entry.inputHours && entry.inputHours > DAILY_NORMAL_MAX_HOURS) {
error.inputHours =
"Input hours should be between 0 and {{DAILY_NORMAL_MAX_HOURS}}";
} else if (entry.otHours && entry.otHours <= 0) { } else if (entry.otHours && entry.otHours <= 0) {
error = "otHours";
error.otHours = "Hours should be bigger than 0";
} }


// If there is a project id, there should also be taskGroupId, taskId, inputHours // If there is a project id, there should also be taskGroupId, taskId, inputHours
if (entry.projectId) { if (entry.projectId) {
if (!entry.taskGroupId) { if (!entry.taskGroupId) {
error = "taskGroupId";
error.taskGroupId = "Required";
} else if (!entry.taskId) { } else if (!entry.taskId) {
error = "taskId";
error.taskId = "Required";
} }
} else { } else {
if (!entry.remark) { if (!entry.remark) {
error = "remark";
error.remark = "Required for non-billable tasks";
} }
} }


return error;
return Object.keys(error).length > 0 ? error : undefined;
}; };


export const isValidLeaveEntry = (entry: Partial<LeaveEntry>): string => { export const isValidLeaveEntry = (entry: Partial<LeaveEntry>): string => {
@@ -45,5 +58,6 @@ export const isValidLeaveEntry = (entry: Partial<LeaveEntry>): string => {
return error; return error;
}; };


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;

+ 12
- 4
src/components/DateHoursTable/DateHoursTable.tsx View File

@@ -33,7 +33,7 @@ interface Props<EntryTableProps = object> {
timesheetEntries: RecordTimesheetInput; timesheetEntries: RecordTimesheetInput;
companyHolidays: HolidaysResult[]; companyHolidays: HolidaysResult[];
EntryTableComponent: React.FunctionComponent< EntryTableComponent: React.FunctionComponent<
EntryTableProps & { day: string }
EntryTableProps & { day: string; isHoliday: boolean }
>; >;
entryTableProps: EntryTableProps; entryTableProps: EntryTableProps;
} }
@@ -86,14 +86,14 @@ function DayRow<EntryTableProps>({
timesheetEntries, timesheetEntries,
entryTableProps, entryTableProps,
EntryTableComponent, EntryTableComponent,
companyHolidays
companyHolidays,
}: { }: {
day: string; day: string;
companyHolidays: HolidaysResult[]; companyHolidays: HolidaysResult[];
leaveEntries: RecordLeaveInput; leaveEntries: RecordLeaveInput;
timesheetEntries: RecordTimesheetInput; timesheetEntries: RecordTimesheetInput;
EntryTableComponent: React.FunctionComponent< EntryTableComponent: React.FunctionComponent<
EntryTableProps & { day: string }
EntryTableProps & { day: string; isHoliday: boolean }
>; >;
entryTableProps: EntryTableProps; entryTableProps: EntryTableProps;
}) { }) {
@@ -200,7 +200,15 @@ function DayRow<EntryTableProps>({
colSpan={5} colSpan={5}
> >
<Collapse in={open} timeout="auto" unmountOnExit> <Collapse in={open} timeout="auto" unmountOnExit>
<Box>{<EntryTableComponent day={day} {...entryTableProps} />}</Box>
<Box>
{
<EntryTableComponent
day={day}
isHoliday={Boolean(isHoliday)}
{...entryTableProps}
/>
}
</Box>
</Collapse> </Collapse>
</TableCell> </TableCell>
</TableRow> </TableRow>


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

@@ -1,5 +1,6 @@
import { LeaveType } from "@/app/api/timesheets"; import { LeaveType } from "@/app/api/timesheets";
import { LeaveEntry } from "@/app/api/timesheets/actions"; import { LeaveEntry } from "@/app/api/timesheets/actions";
import { LEAVE_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils";
import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; import { roundToNearestQuarter } from "@/app/utils/manhourUtils";
import { Check, Delete } from "@mui/icons-material"; import { Check, Delete } from "@mui/icons-material";
import { import {
@@ -100,7 +101,7 @@ const LeaveEditModal: React.FC<Props> = ({
fullWidth fullWidth
{...register("inputHours", { {...register("inputHours", {
setValueAs: (value) => roundToNearestQuarter(parseFloat(value)), setValueAs: (value) => roundToNearestQuarter(parseFloat(value)),
validate: (value) => value > 0,
validate: (value) => 0 < value && value <= LEAVE_DAILY_MAX_HOURS,
})} })}
error={Boolean(formState.errors.inputHours)} error={Boolean(formState.errors.inputHours)}
/> />


+ 1
- 0
src/components/LeaveTable/LeaveEntryTable.tsx View File

@@ -28,6 +28,7 @@ dayjs.extend(isBetween);


interface Props { interface Props {
day: string; day: string;
isHoliday: boolean;
leaveTypes: LeaveType[]; leaveTypes: LeaveType[];
} }




+ 47
- 7
src/components/TimesheetTable/EntryInputTable.tsx View File

@@ -1,9 +1,11 @@
import { Add, Check, Close, Delete } from "@mui/icons-material"; import { Add, Check, Close, Delete } from "@mui/icons-material";
import { Box, Button, Typography } from "@mui/material";
import { Box, Button, Tooltip, Typography } from "@mui/material";
import { import {
FooterPropsOverrides, FooterPropsOverrides,
GridActionsCellItem, GridActionsCellItem,
GridCellParams,
GridColDef, GridColDef,
GridEditInputCell,
GridEventListener, GridEventListener,
GridRenderEditCellParams, GridRenderEditCellParams,
GridRowId, GridRowId,
@@ -27,13 +29,18 @@ import isBetween from "dayjs/plugin/isBetween";
import ProjectSelect from "./ProjectSelect"; import ProjectSelect from "./ProjectSelect";
import TaskGroupSelect from "./TaskGroupSelect"; import TaskGroupSelect from "./TaskGroupSelect";
import TaskSelect from "./TaskSelect"; import TaskSelect from "./TaskSelect";
import { isValidTimeEntry } from "@/app/api/timesheets/utils";
import {
DAILY_NORMAL_MAX_HOURS,
TimeEntryError,
validateTimeEntry,
} from "@/app/api/timesheets/utils";
import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; import { roundToNearestQuarter } from "@/app/utils/manhourUtils";


dayjs.extend(isBetween); dayjs.extend(isBetween);


interface Props { interface Props {
day: string; day: string;
isHoliday: boolean;
allProjects: ProjectWithTasks[]; allProjects: ProjectWithTasks[];
assignedProjects: AssignedProject[]; assignedProjects: AssignedProject[];
} }
@@ -41,7 +48,7 @@ interface Props {
export type TimeEntryRow = Partial< export type TimeEntryRow = Partial<
TimeEntry & { TimeEntry & {
_isNew: boolean; _isNew: boolean;
_error: string;
_error: TimeEntryError;
isPlanned?: boolean; isPlanned?: boolean;
} }
>; >;
@@ -50,6 +57,7 @@ const EntryInputTable: React.FC<Props> = ({
day, day,
allProjects, allProjects,
assignedProjects, assignedProjects,
isHoliday,
}) => { }) => {
const { t } = useTranslation("home"); const { t } = useTranslation("home");
const taskGroupsByProject = useMemo(() => { const taskGroupsByProject = useMemo(() => {
@@ -105,7 +113,7 @@ const EntryInputTable: React.FC<Props> = ({
"", "",
) as TimeEntryRow; ) as TimeEntryRow;


const error = isValidTimeEntry(row);
const error = validateTimeEntry(row, isHoliday);


// Test for warnings // Test for warnings
let isPlanned; let isPlanned;
@@ -124,7 +132,7 @@ const EntryInputTable: React.FC<Props> = ({
apiRef.current.updateRows([{ id, _error: error, isPlanned }]); apiRef.current.updateRows([{ id, _error: error, isPlanned }]);
return !error; return !error;
}, },
[apiRef, day, milestonesByProject],
[apiRef, day, isHoliday, milestonesByProject],
); );


const handleCancel = useCallback( const handleCancel = useCallback(
@@ -309,6 +317,18 @@ const EntryInputTable: React.FC<Props> = ({
width: 100, width: 100,
editable: true, editable: true,
type: "number", type: "number",
renderEditCell(params: GridRenderEditCellParams<TimeEntryRow>) {
const errorMessage =
params.row._error?.[params.field as keyof TimeEntry];
const content = <GridEditInputCell {...params} />;
return errorMessage ? (
<Tooltip title={t(errorMessage, { DAILY_NORMAL_MAX_HOURS })}>
{content}
</Tooltip>
) : (
content
);
},
valueParser(value) { valueParser(value) {
return value ? roundToNearestQuarter(value) : value; return value ? roundToNearestQuarter(value) : value;
}, },
@@ -322,6 +342,16 @@ const EntryInputTable: React.FC<Props> = ({
width: 150, width: 150,
editable: true, editable: true,
type: "number", type: "number",
renderEditCell(params: GridRenderEditCellParams<TimeEntryRow>) {
const errorMessage =
params.row._error?.[params.field as keyof TimeEntry];
const content = <GridEditInputCell {...params} />;
return errorMessage ? (
<Tooltip title={t(errorMessage)}>{content}</Tooltip>
) : (
content
);
},
valueParser(value) { valueParser(value) {
return value ? roundToNearestQuarter(value) : value; return value ? roundToNearestQuarter(value) : value;
}, },
@@ -335,6 +365,16 @@ const EntryInputTable: React.FC<Props> = ({
sortable: false, sortable: false,
flex: 1, flex: 1,
editable: true, editable: true,
renderEditCell(params: GridRenderEditCellParams<TimeEntryRow>) {
const errorMessage =
params.row._error?.[params.field as keyof TimeEntry];
const content = <GridEditInputCell {...params} />;
return errorMessage ? (
<Tooltip title={t(errorMessage)}>{content}</Tooltip>
) : (
content
);
},
}, },
], ],
[ [
@@ -406,9 +446,9 @@ const EntryInputTable: React.FC<Props> = ({
onRowEditStop={handleEditStop} onRowEditStop={handleEditStop}
processRowUpdate={processRowUpdate} processRowUpdate={processRowUpdate}
columns={columns} columns={columns}
getCellClassName={(params) => {
getCellClassName={(params: GridCellParams<TimeEntryRow>) => {
let classname = ""; let classname = "";
if (params.row._error === params.field) {
if (params.row._error?.[params.field as keyof TimeEntry]) {
classname = "hasError"; classname = "hasError";
} else if ( } else if (
params.field === "taskGroupId" && params.field === "taskGroupId" &&


+ 1
- 0
src/components/TimesheetTable/MobileTimesheetEntry.tsx View File

@@ -158,6 +158,7 @@ const MobileTimesheetEntry: React.FC<Props> = ({
open={editModalOpen} open={editModalOpen}
onClose={closeEditModal} onClose={closeEditModal}
onSave={onSaveEntry} onSave={onSaveEntry}
isHoliday={Boolean(isHoliday)}
{...editModalProps} {...editModalProps}
/> />
</Box> </Box>


+ 25
- 3
src/components/TimesheetTable/TimesheetEditModal.tsx View File

@@ -23,6 +23,7 @@ import { TaskGroup } from "@/app/api/tasks";
import uniqBy from "lodash/uniqBy"; import uniqBy from "lodash/uniqBy";
import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; import { roundToNearestQuarter } from "@/app/utils/manhourUtils";
import { shortDateFormatter } from "@/app/utils/formatUtil"; import { shortDateFormatter } from "@/app/utils/formatUtil";
import { DAILY_NORMAL_MAX_HOURS } from "@/app/api/timesheets/utils";


export interface Props extends Omit<ModalProps, "children"> { export interface Props extends Omit<ModalProps, "children"> {
onSave: (timeEntry: TimeEntry, recordDate?: string) => Promise<void>; onSave: (timeEntry: TimeEntry, recordDate?: string) => Promise<void>;
@@ -32,6 +33,7 @@ export interface Props extends Omit<ModalProps, "children"> {
assignedProjects: AssignedProject[]; assignedProjects: AssignedProject[];
modalSx?: SxProps; modalSx?: SxProps;
recordDate?: string; recordDate?: string;
isHoliday?: boolean;
} }


const modalSx: SxProps = { const modalSx: SxProps = {
@@ -56,6 +58,7 @@ const TimesheetEditModal: React.FC<Props> = ({
assignedProjects, assignedProjects,
modalSx: mSx, modalSx: mSx,
recordDate, recordDate,
isHoliday,
}) => { }) => {
const { const {
t, t,
@@ -212,10 +215,26 @@ const TimesheetEditModal: React.FC<Props> = ({
fullWidth fullWidth
{...register("inputHours", { {...register("inputHours", {
setValueAs: (value) => roundToNearestQuarter(parseFloat(value)), setValueAs: (value) => roundToNearestQuarter(parseFloat(value)),
validate: (value) =>
value ? value > 0 : Boolean(value || otHours),
validate: (value) => {
if (value) {
if (isHoliday) {
return t("Cannot input normal hours for holidays");
}

return (
(0 < value && value <= DAILY_NORMAL_MAX_HOURS) ||
t(
"Input hours should be between 0 and {{DAILY_NORMAL_MAX_HOURS}}",
{ DAILY_NORMAL_MAX_HOURS },
)
);
} else {
return Boolean(value || otHours) || t("Required");
}
},
})} })}
error={Boolean(formState.errors.inputHours)} error={Boolean(formState.errors.inputHours)}
helperText={formState.errors.inputHours?.message}
/> />
<TextField <TextField
type="number" type="number"
@@ -234,8 +253,11 @@ const TimesheetEditModal: React.FC<Props> = ({
rows={2} rows={2}
error={Boolean(formState.errors.remark)} error={Boolean(formState.errors.remark)}
{...register("remark", { {...register("remark", {
validate: (value) => Boolean(projectId || value),
validate: (value) =>
Boolean(projectId || value) ||
t("Required for non-billable tasks"),
})} })}
helperText={formState.errors.remark?.message}
/> />
<Box display="flex" justifyContent="flex-end" gap={1}> <Box display="flex" justifyContent="flex-end" gap={1}>
{onDelete && ( {onDelete && (


Loading…
Cancel
Save