diff --git a/src/app/api/timesheets/utils.ts b/src/app/api/timesheets/utils.ts index c305e26..22d31ce 100644 --- a/src/app/api/timesheets/utils.ts +++ b/src/app/api/timesheets/utils.ts @@ -1,36 +1,49 @@ import { LeaveEntry, TimeEntry } from "./actions"; +export type TimeEntryError = { + [field in keyof TimeEntry]?: string; +}; + /** * @param entry - the time entry * @returns the field where there is an error, or an empty string if there is none */ -export const isValidTimeEntry = (entry: Partial): string => { +export const validateTimeEntry = ( + entry: Partial, + isHoliday: boolean, +): TimeEntryError | undefined => { // Test for errors - let error: keyof TimeEntry | "" = ""; + const error: TimeEntryError = {}; // Either normal or other hours need to be inputted 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) { - 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) { - error = "otHours"; + error.otHours = "Hours should be bigger than 0"; } // If there is a project id, there should also be taskGroupId, taskId, inputHours if (entry.projectId) { if (!entry.taskGroupId) { - error = "taskGroupId"; + error.taskGroupId = "Required"; } else if (!entry.taskId) { - error = "taskId"; + error.taskId = "Required"; } } else { 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): string => { @@ -45,5 +58,6 @@ export const isValidLeaveEntry = (entry: Partial): string => { return error; }; +export const DAILY_NORMAL_MAX_HOURS = 8; export const LEAVE_DAILY_MAX_HOURS = 8; export const TIMESHEET_DAILY_MAX_HOURS = 20; diff --git a/src/components/DateHoursTable/DateHoursTable.tsx b/src/components/DateHoursTable/DateHoursTable.tsx index 7a659d0..8328bd9 100644 --- a/src/components/DateHoursTable/DateHoursTable.tsx +++ b/src/components/DateHoursTable/DateHoursTable.tsx @@ -33,7 +33,7 @@ interface Props { timesheetEntries: RecordTimesheetInput; companyHolidays: HolidaysResult[]; EntryTableComponent: React.FunctionComponent< - EntryTableProps & { day: string } + EntryTableProps & { day: string; isHoliday: boolean } >; entryTableProps: EntryTableProps; } @@ -86,14 +86,14 @@ function DayRow({ timesheetEntries, entryTableProps, EntryTableComponent, - companyHolidays + companyHolidays, }: { day: string; companyHolidays: HolidaysResult[]; leaveEntries: RecordLeaveInput; timesheetEntries: RecordTimesheetInput; EntryTableComponent: React.FunctionComponent< - EntryTableProps & { day: string } + EntryTableProps & { day: string; isHoliday: boolean } >; entryTableProps: EntryTableProps; }) { @@ -200,7 +200,15 @@ function DayRow({ colSpan={5} > - {} + + { + + } + diff --git a/src/components/LeaveTable/LeaveEditModal.tsx b/src/components/LeaveTable/LeaveEditModal.tsx index e5c7d83..2434e7a 100644 --- a/src/components/LeaveTable/LeaveEditModal.tsx +++ b/src/components/LeaveTable/LeaveEditModal.tsx @@ -1,5 +1,6 @@ import { LeaveType } from "@/app/api/timesheets"; import { LeaveEntry } from "@/app/api/timesheets/actions"; +import { LEAVE_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils"; import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; import { Check, Delete } from "@mui/icons-material"; import { @@ -100,7 +101,7 @@ const LeaveEditModal: React.FC = ({ fullWidth {...register("inputHours", { setValueAs: (value) => roundToNearestQuarter(parseFloat(value)), - validate: (value) => value > 0, + validate: (value) => 0 < value && value <= LEAVE_DAILY_MAX_HOURS, })} error={Boolean(formState.errors.inputHours)} /> diff --git a/src/components/LeaveTable/LeaveEntryTable.tsx b/src/components/LeaveTable/LeaveEntryTable.tsx index d612112..d00d50e 100644 --- a/src/components/LeaveTable/LeaveEntryTable.tsx +++ b/src/components/LeaveTable/LeaveEntryTable.tsx @@ -28,6 +28,7 @@ dayjs.extend(isBetween); interface Props { day: string; + isHoliday: boolean; leaveTypes: LeaveType[]; } diff --git a/src/components/TimesheetTable/EntryInputTable.tsx b/src/components/TimesheetTable/EntryInputTable.tsx index effd88b..bccd9df 100644 --- a/src/components/TimesheetTable/EntryInputTable.tsx +++ b/src/components/TimesheetTable/EntryInputTable.tsx @@ -1,9 +1,11 @@ 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 { FooterPropsOverrides, GridActionsCellItem, + GridCellParams, GridColDef, + GridEditInputCell, GridEventListener, GridRenderEditCellParams, GridRowId, @@ -27,13 +29,18 @@ import isBetween from "dayjs/plugin/isBetween"; import ProjectSelect from "./ProjectSelect"; import TaskGroupSelect from "./TaskGroupSelect"; 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"; dayjs.extend(isBetween); interface Props { day: string; + isHoliday: boolean; allProjects: ProjectWithTasks[]; assignedProjects: AssignedProject[]; } @@ -41,7 +48,7 @@ interface Props { export type TimeEntryRow = Partial< TimeEntry & { _isNew: boolean; - _error: string; + _error: TimeEntryError; isPlanned?: boolean; } >; @@ -50,6 +57,7 @@ const EntryInputTable: React.FC = ({ day, allProjects, assignedProjects, + isHoliday, }) => { const { t } = useTranslation("home"); const taskGroupsByProject = useMemo(() => { @@ -105,7 +113,7 @@ const EntryInputTable: React.FC = ({ "", ) as TimeEntryRow; - const error = isValidTimeEntry(row); + const error = validateTimeEntry(row, isHoliday); // Test for warnings let isPlanned; @@ -124,7 +132,7 @@ const EntryInputTable: React.FC = ({ apiRef.current.updateRows([{ id, _error: error, isPlanned }]); return !error; }, - [apiRef, day, milestonesByProject], + [apiRef, day, isHoliday, milestonesByProject], ); const handleCancel = useCallback( @@ -309,6 +317,18 @@ const EntryInputTable: React.FC = ({ width: 100, editable: true, type: "number", + renderEditCell(params: GridRenderEditCellParams) { + const errorMessage = + params.row._error?.[params.field as keyof TimeEntry]; + const content = ; + return errorMessage ? ( + + {content} + + ) : ( + content + ); + }, valueParser(value) { return value ? roundToNearestQuarter(value) : value; }, @@ -322,6 +342,16 @@ const EntryInputTable: React.FC = ({ width: 150, editable: true, type: "number", + renderEditCell(params: GridRenderEditCellParams) { + const errorMessage = + params.row._error?.[params.field as keyof TimeEntry]; + const content = ; + return errorMessage ? ( + {content} + ) : ( + content + ); + }, valueParser(value) { return value ? roundToNearestQuarter(value) : value; }, @@ -335,6 +365,16 @@ const EntryInputTable: React.FC = ({ sortable: false, flex: 1, editable: true, + renderEditCell(params: GridRenderEditCellParams) { + const errorMessage = + params.row._error?.[params.field as keyof TimeEntry]; + const content = ; + return errorMessage ? ( + {content} + ) : ( + content + ); + }, }, ], [ @@ -406,9 +446,9 @@ const EntryInputTable: React.FC = ({ onRowEditStop={handleEditStop} processRowUpdate={processRowUpdate} columns={columns} - getCellClassName={(params) => { + getCellClassName={(params: GridCellParams) => { let classname = ""; - if (params.row._error === params.field) { + if (params.row._error?.[params.field as keyof TimeEntry]) { classname = "hasError"; } else if ( params.field === "taskGroupId" && diff --git a/src/components/TimesheetTable/MobileTimesheetEntry.tsx b/src/components/TimesheetTable/MobileTimesheetEntry.tsx index 174f22b..96669da 100644 --- a/src/components/TimesheetTable/MobileTimesheetEntry.tsx +++ b/src/components/TimesheetTable/MobileTimesheetEntry.tsx @@ -158,6 +158,7 @@ const MobileTimesheetEntry: React.FC = ({ open={editModalOpen} onClose={closeEditModal} onSave={onSaveEntry} + isHoliday={Boolean(isHoliday)} {...editModalProps} /> diff --git a/src/components/TimesheetTable/TimesheetEditModal.tsx b/src/components/TimesheetTable/TimesheetEditModal.tsx index 7a24f25..6fe33d5 100644 --- a/src/components/TimesheetTable/TimesheetEditModal.tsx +++ b/src/components/TimesheetTable/TimesheetEditModal.tsx @@ -23,6 +23,7 @@ import { TaskGroup } from "@/app/api/tasks"; import uniqBy from "lodash/uniqBy"; import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; import { shortDateFormatter } from "@/app/utils/formatUtil"; +import { DAILY_NORMAL_MAX_HOURS } from "@/app/api/timesheets/utils"; export interface Props extends Omit { onSave: (timeEntry: TimeEntry, recordDate?: string) => Promise; @@ -32,6 +33,7 @@ export interface Props extends Omit { assignedProjects: AssignedProject[]; modalSx?: SxProps; recordDate?: string; + isHoliday?: boolean; } const modalSx: SxProps = { @@ -56,6 +58,7 @@ const TimesheetEditModal: React.FC = ({ assignedProjects, modalSx: mSx, recordDate, + isHoliday, }) => { const { t, @@ -212,10 +215,26 @@ const TimesheetEditModal: React.FC = ({ fullWidth {...register("inputHours", { 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)} + helperText={formState.errors.inputHours?.message} /> = ({ rows={2} error={Boolean(formState.errors.remark)} {...register("remark", { - validate: (value) => Boolean(projectId || value), + validate: (value) => + Boolean(projectId || value) || + t("Required for non-billable tasks"), })} + helperText={formState.errors.remark?.message} /> {onDelete && (