@@ -13,10 +13,6 @@ export type TimeEntryError = { | |||
[field in keyof TimeEntry]?: string; | |||
}; | |||
interface TimeEntryValidationOptions { | |||
skipTaskValidation?: boolean; | |||
} | |||
/** | |||
* @param entry - the time entry | |||
* @returns an object where the keys are the error fields and the values the error message, and undefined if there are no errors | |||
@@ -24,7 +20,6 @@ interface TimeEntryValidationOptions { | |||
export const validateTimeEntry = ( | |||
entry: Partial<TimeEntry>, | |||
isHoliday: boolean, | |||
options: TimeEntryValidationOptions = {}, | |||
): TimeEntryError | undefined => { | |||
// Test for errors | |||
const error: TimeEntryError = {}; | |||
@@ -46,12 +41,10 @@ export const validateTimeEntry = ( | |||
// If there is a project id, there should also be taskGroupId, taskId, inputHours | |||
if (entry.projectId) { | |||
if (!options.skipTaskValidation) { | |||
if (!entry.taskGroupId) { | |||
error.taskGroupId = "Required"; | |||
} else if (!entry.taskId) { | |||
error.taskId = "Required"; | |||
} | |||
if (!entry.taskGroupId) { | |||
error.taskGroupId = "Required"; | |||
} else if (!entry.taskId) { | |||
error.taskId = "Required"; | |||
} | |||
} else { | |||
if (!entry.remark) { | |||
@@ -78,7 +71,6 @@ export const validateTimesheet = ( | |||
timesheet: RecordTimesheetInput, | |||
leaveRecords: RecordLeaveInput, | |||
companyHolidays: HolidaysResult[], | |||
options: TimeEntryValidationOptions = {}, | |||
): { [date: string]: string } | undefined => { | |||
const errors: { [date: string]: string } = {}; | |||
@@ -94,7 +86,7 @@ export const validateTimesheet = ( | |||
// Check each entry | |||
for (const entry of timeEntries) { | |||
const entryErrors = validateTimeEntry(entry, holidays.has(date), options); | |||
const entryErrors = validateTimeEntry(entry, holidays.has(date)); | |||
if (entryErrors) { | |||
errors[date] = "There are errors in the entries"; | |||
@@ -107,7 +99,52 @@ export const validateTimesheet = ( | |||
const leaveHours = | |||
leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; | |||
const totalNormalHours = timeEntries.reduce((acc, entry) => { | |||
const totalInputHours = timeEntries.reduce((acc, entry) => { | |||
return acc + (entry.inputHours || 0); | |||
}, 0); | |||
const totalOtHours = timeEntries.reduce((acc, entry) => { | |||
return acc + (entry.otHours || 0); | |||
}, 0); | |||
if (totalInputHours + leaveHours > DAILY_NORMAL_MAX_HOURS) { | |||
errors[date] = | |||
"The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours or decrease the leave hours."; | |||
} else if ( | |||
totalInputHours + 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 validateLeaveRecord = ( | |||
leaveRecords: RecordLeaveInput, | |||
timesheet: RecordTimesheetInput, | |||
): { [date: string]: string } | undefined => { | |||
const errors: { [date: string]: string } = {}; | |||
Object.keys(leaveRecords).forEach((date) => { | |||
const leaves = leaveRecords[date]; | |||
// Check each leave entry | |||
for (const entry of leaves) { | |||
const entryError = isValidLeaveEntry(entry); | |||
if (entryError) { | |||
errors[date] = "There are errors in the entries"; | |||
} | |||
} | |||
// Check total hours | |||
const timeEntries = timesheet[date] || []; | |||
const leaveHours = leaves.reduce((acc, entry) => acc + entry.inputHours, 0); | |||
const totalInputHours = timeEntries.reduce((acc, entry) => { | |||
return acc + (entry.inputHours || 0); | |||
}, 0); | |||
@@ -115,11 +152,11 @@ export const validateTimesheet = ( | |||
return acc + (entry.otHours || 0); | |||
}, 0); | |||
if (totalNormalHours > DAILY_NORMAL_MAX_HOURS) { | |||
if (totalInputHours + leaveHours > 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."; | |||
"The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours or decrease the leave hours."; | |||
} else if ( | |||
totalNormalHours + totalOtHours + leaveHours > | |||
totalInputHours + totalOtHours + leaveHours > | |||
TIMESHEET_DAILY_MAX_HOURS | |||
) { | |||
errors[date] = | |||
@@ -18,7 +18,6 @@ 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"; | |||
import { HolidaysResult } from "@/app/api/holidays"; | |||
@@ -101,8 +100,7 @@ function DateHoursList<EntryTableProps>({ | |||
const dailyTotal = leaveHours + timesheetHours; | |||
const normalHoursExceeded = | |||
timesheetNormalHours > DAILY_NORMAL_MAX_HOURS; | |||
const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS; | |||
timesheetNormalHours + leaveHours > DAILY_NORMAL_MAX_HOURS; | |||
const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS; | |||
return ( | |||
@@ -148,11 +146,12 @@ function DateHoursList<EntryTableProps>({ | |||
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.", | |||
"The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}} (timesheet hours: {{timesheetNormalHours}}, leave hours: {{leaveHours}}). Please use other hours for exceeding hours or decrease the leave hours.", | |||
{ | |||
timesheetNormalHours, | |||
leaveHours, | |||
DAILY_NORMAL_MAX_HOURS, | |||
}, | |||
)} | |||
@@ -165,7 +164,7 @@ function DateHoursList<EntryTableProps>({ | |||
justifyContent: "space-between", | |||
flexWrap: "wrap", | |||
alignItems: "baseline", | |||
color: leaveExceeded ? "error.main" : undefined, | |||
color: normalHoursExceeded ? "error.main" : undefined, | |||
}} | |||
> | |||
<Typography variant="body2"> | |||
@@ -174,15 +173,20 @@ function DateHoursList<EntryTableProps>({ | |||
<Typography> | |||
{manhourFormatter.format(leaveHours)} | |||
</Typography> | |||
{leaveExceeded && ( | |||
{normalHoursExceeded && ( | |||
<Typography | |||
component="div" | |||
width="100%" | |||
variant="caption" | |||
> | |||
{t("Leave hours cannot be more than {{hours}}", { | |||
hours: LEAVE_DAILY_MAX_HOURS, | |||
})} | |||
{t( | |||
"The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}} (timesheet hours: {{timesheetNormalHours}}, leave hours: {{leaveHours}}). Please use other hours for exceeding hours or decrease the leave hours.", | |||
{ | |||
timesheetNormalHours, | |||
leaveHours, | |||
DAILY_NORMAL_MAX_HOURS, | |||
}, | |||
)} | |||
</Typography> | |||
)} | |||
</Box> | |||
@@ -22,7 +22,6 @@ 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"; | |||
import { HolidaysResult } from "@/app/api/holidays"; | |||
@@ -121,8 +120,8 @@ function DayRow<EntryTableProps>({ | |||
const dailyTotal = leaveHours + timesheetHours; | |||
const normalHoursExceeded = timesheetNormalHours > DAILY_NORMAL_MAX_HOURS; | |||
const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS; | |||
const normalHoursExceeded = | |||
timesheetNormalHours + leaveHours > DAILY_NORMAL_MAX_HOURS; | |||
const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS; | |||
return ( | |||
@@ -158,8 +157,10 @@ function DayRow<EntryTableProps>({ | |||
{normalHoursExceeded && ( | |||
<Tooltip | |||
title={t( | |||
"The daily normal hours cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours.", | |||
"The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}} (timesheet hours: {{timesheetNormalHours}}, leave hours: {{leaveHours}}). Please use other hours for exceeding hours or decrease the leave hours.", | |||
{ | |||
timesheetNormalHours, | |||
leaveHours, | |||
DAILY_NORMAL_MAX_HOURS, | |||
}, | |||
)} | |||
@@ -172,16 +173,21 @@ function DayRow<EntryTableProps>({ | |||
{/* Leave total */} | |||
<TableCell | |||
sx={{ | |||
color: leaveExceeded ? "error.main" : undefined, | |||
color: normalHoursExceeded ? "error.main" : undefined, | |||
}} | |||
> | |||
<Box display="flex" gap={1} alignItems="center"> | |||
{manhourFormatter.format(leaveHours)} | |||
{leaveExceeded && ( | |||
{normalHoursExceeded && ( | |||
<Tooltip | |||
title={t("Leave hours cannot be more than {{hours}}", { | |||
hours: LEAVE_DAILY_MAX_HOURS, | |||
})} | |||
title={t( | |||
"The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}} (timesheet hours: {{timesheetNormalHours}}, leave hours: {{leaveHours}}). Please use other hours for exceeding hours or decrease the leave hours.", | |||
{ | |||
timesheetNormalHours, | |||
leaveHours, | |||
DAILY_NORMAL_MAX_HOURS, | |||
}, | |||
)} | |||
> | |||
<Info fontSize="small" /> | |||
</Tooltip> | |||
@@ -26,6 +26,12 @@ import FullscreenModal from "../FullscreenModal"; | |||
import MobileLeaveTable from "../LeaveTable/MobileLeaveTable"; | |||
import useIsMobile from "@/app/utils/useIsMobile"; | |||
import { HolidaysResult } from "@/app/api/holidays"; | |||
import { | |||
DAILY_NORMAL_MAX_HOURS, | |||
TIMESHEET_DAILY_MAX_HOURS, | |||
validateLeaveRecord, | |||
} from "@/app/api/timesheets/utils"; | |||
import ErrorAlert from "../ErrorAlert"; | |||
interface Props { | |||
isOpen: boolean; | |||
@@ -75,6 +81,15 @@ const LeaveModal: React.FC<Props> = ({ | |||
const onSubmit = useCallback<SubmitHandler<RecordLeaveInput>>( | |||
async (data) => { | |||
const errors = validateLeaveRecord(data, timesheetRecords); | |||
if (errors) { | |||
Object.keys(errors).forEach((date) => | |||
formProps.setError(date, { | |||
message: errors[date], | |||
}), | |||
); | |||
return; | |||
} | |||
const savedRecords = await saveLeave(data, username); | |||
const today = dayjs(); | |||
@@ -91,7 +106,7 @@ const LeaveModal: React.FC<Props> = ({ | |||
formProps.reset(newFormValues); | |||
onClose(); | |||
}, | |||
[formProps, onClose, username], | |||
[formProps, onClose, timesheetRecords, username], | |||
); | |||
const onCancel = useCallback(() => { | |||
@@ -108,6 +123,20 @@ const LeaveModal: React.FC<Props> = ({ | |||
[onCancel], | |||
); | |||
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 ( | |||
@@ -135,6 +164,7 @@ const LeaveModal: React.FC<Props> = ({ | |||
timesheetRecords={timesheetRecords} | |||
/> | |||
</Box> | |||
{errorComponent} | |||
<CardActions sx={{ justifyContent: "flex-end" }}> | |||
<Button | |||
variant="outlined" | |||
@@ -172,6 +202,7 @@ const LeaveModal: React.FC<Props> = ({ | |||
companyHolidays={companyHolidays} | |||
leaveTypes={leaveTypes} | |||
timesheetRecords={timesheetRecords} | |||
errorComponent={errorComponent} | |||
/> | |||
</Box> | |||
</FullscreenModal> | |||
@@ -42,7 +42,8 @@ type LeaveEntryRow = Partial< | |||
const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => { | |||
const { t } = useTranslation("home"); | |||
const { getValues, setValue } = useFormContext<RecordLeaveInput>(); | |||
const { getValues, setValue, clearErrors } = | |||
useFormContext<RecordLeaveInput>(); | |||
const currentEntries = getValues(day); | |||
const [entries, setEntries] = useState<LeaveEntryRow[]>(currentEntries || []); | |||
@@ -207,7 +208,8 @@ const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => { | |||
remark: e.remark, | |||
})), | |||
]); | |||
}, [getValues, entries, setValue, day]); | |||
clearErrors(day); | |||
}, [getValues, entries, setValue, day, clearErrors]); | |||
const footer = ( | |||
<Box display="flex" gap={2} alignItems="center"> | |||
@@ -38,7 +38,7 @@ const MobileLeaveEntry: React.FC<Props> = ({ | |||
); | |||
}, [leaveTypes]); | |||
const { watch, setValue } = useFormContext<RecordLeaveInput>(); | |||
const { watch, setValue, clearErrors } = useFormContext<RecordLeaveInput>(); | |||
const currentEntries = watch(date); | |||
// Edit modal | |||
@@ -57,13 +57,14 @@ const MobileLeaveEntry: 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(() => { | |||
@@ -80,12 +81,13 @@ const MobileLeaveEntry: 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 ( | |||
@@ -13,12 +13,14 @@ interface Props { | |||
leaveTypes: LeaveType[]; | |||
timesheetRecords: RecordTimesheetInput; | |||
companyHolidays: HolidaysResult[]; | |||
errorComponent?: React.ReactNode; | |||
} | |||
const MobileLeaveTable: React.FC<Props> = ({ | |||
timesheetRecords, | |||
leaveTypes, | |||
companyHolidays, | |||
errorComponent, | |||
}) => { | |||
const { watch } = useFormContext<RecordLeaveInput>(); | |||
const currentInput = watch(); | |||
@@ -32,6 +34,7 @@ const MobileLeaveTable: React.FC<Props> = ({ | |||
timesheetEntries={timesheetRecords} | |||
EntryComponent={MobileLeaveEntry} | |||
entryComponentProps={{ leaveTypes, companyHolidays }} | |||
errorComponent={errorComponent} | |||
/> | |||
); | |||
}; | |||
@@ -85,9 +85,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||
const onSubmit = useCallback<SubmitHandler<RecordTimesheetInput>>( | |||
async (data) => { | |||
const errors = validateTimesheet(data, leaveRecords, companyHolidays, { | |||
skipTaskValidation: fastEntryEnabled, | |||
}); | |||
const errors = validateTimesheet(data, leaveRecords, companyHolidays); | |||
if (errors) { | |||
Object.keys(errors).forEach((date) => | |||
formProps.setError(date, { | |||
@@ -112,14 +110,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||
formProps.reset(newFormValues); | |||
onClose(); | |||
}, | |||
[ | |||
companyHolidays, | |||
fastEntryEnabled, | |||
formProps, | |||
leaveRecords, | |||
onClose, | |||
username, | |||
], | |||
[companyHolidays, formProps, leaveRecords, onClose, username], | |||
); | |||
const onCancel = useCallback(() => { | |||
@@ -117,9 +117,7 @@ const EntryInputTable: React.FC<Props> = ({ | |||
"", | |||
) as TimeEntryRow; | |||
const error = validateTimeEntry(row, isHoliday, { | |||
skipTaskValidation: fastEntryEnabled, | |||
}); | |||
const error = validateTimeEntry(row, isHoliday); | |||
// Test for warnings | |||
let isPlanned; | |||
@@ -138,7 +136,7 @@ const EntryInputTable: React.FC<Props> = ({ | |||
apiRef.current.updateRows([{ id, _error: error, isPlanned }]); | |||
return !error; | |||
}, | |||
[apiRef, day, fastEntryEnabled, isHoliday, milestonesByProject], | |||
[apiRef, day, isHoliday, milestonesByProject], | |||
); | |||
const handleCancel = useCallback( | |||
@@ -29,6 +29,7 @@ import { | |||
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; | |||
import { DAILY_NORMAL_MAX_HOURS } from "@/app/api/timesheets/utils"; | |||
import zip from "lodash/zip"; | |||
import intersectionBy from "lodash/intersectionBy"; | |||
export interface FastTimeEntryForm { | |||
projectIds: TimeEntry["projectId"][]; | |||
@@ -66,6 +67,9 @@ const getID = () => { | |||
return ++idOffset; | |||
}; | |||
const MISC_TASK_GROUP_ID = 5; | |||
const FAST_ENTRY_TASK_ID = 40; | |||
const FastTimeEntryModal: React.FC<Props> = ({ | |||
onSave, | |||
open, | |||
@@ -81,6 +85,16 @@ const FastTimeEntryModal: React.FC<Props> = ({ | |||
i18n: { language }, | |||
} = useTranslation("home"); | |||
const allProjectsWithFastEntry = useMemo(() => { | |||
return allProjects.filter((p) => | |||
p.tasks.find((t) => t.id === FAST_ENTRY_TASK_ID), | |||
); | |||
}, [allProjects]); | |||
const allAssignedProjectsWithFastEntry = useMemo(() => { | |||
return intersectionBy(assignedProjects, allProjectsWithFastEntry, "id"); | |||
}, [allProjectsWithFastEntry, assignedProjects]); | |||
const { register, control, reset, trigger, formState, watch } = | |||
useForm<FastTimeEntryForm>({ | |||
defaultValues: { | |||
@@ -94,8 +108,10 @@ const FastTimeEntryModal: React.FC<Props> = ({ | |||
const remark = watch("remark"); | |||
const selectedProjects = useMemo(() => { | |||
return projectIds.map((id) => allProjects.find((p) => p.id === id)); | |||
}, [allProjects, projectIds]); | |||
return projectIds.map((id) => | |||
allProjectsWithFastEntry.find((p) => p.id === id), | |||
); | |||
}, [allProjectsWithFastEntry, projectIds]); | |||
const normalHoursArray = distributeQuarters( | |||
inputHours || 0, | |||
@@ -116,13 +132,19 @@ const FastTimeEntryModal: React.FC<Props> = ({ | |||
const valid = await trigger(); | |||
if (valid) { | |||
onSave( | |||
projectsWithHours.map(([project, hour, othour]) => ({ | |||
id: getID(), | |||
projectId: project?.id, | |||
inputHours: hour, | |||
otHours: othour, | |||
remark, | |||
})), | |||
projectsWithHours.map(([project, hour, othour]) => { | |||
const projectId = project?.id; | |||
return { | |||
id: getID(), | |||
projectId, | |||
inputHours: hour, | |||
otHours: othour, | |||
taskGroupId: projectId ? MISC_TASK_GROUP_ID : undefined, | |||
taskId: projectId ? FAST_ENTRY_TASK_ID : undefined, | |||
remark, | |||
}; | |||
}), | |||
recordDate, | |||
); | |||
reset(); | |||
@@ -154,8 +176,8 @@ const FastTimeEntryModal: React.FC<Props> = ({ | |||
<ProjectSelect | |||
error={Boolean(formState.errors.projectIds)} | |||
multiple | |||
allProjects={allProjects} | |||
assignedProjects={assignedProjects} | |||
allProjects={allProjectsWithFastEntry} | |||
assignedProjects={allAssignedProjectsWithFastEntry} | |||
value={field.value} | |||
onProjectSelect={(newIds) => { | |||
field.onChange( | |||
@@ -172,7 +194,7 @@ const FastTimeEntryModal: React.FC<Props> = ({ | |||
<FormHelperText> | |||
{formState.errors.projectIds?.message || | |||
t( | |||
"The inputted time will be evenly distributed among the selected projects.", | |||
'The inputted time will be evenly distributed among the selected projects. Only projects with the "Management Timesheet Allocation" task can use the fast entry.', | |||
)} | |||
</FormHelperText> | |||
</FormControl> | |||
@@ -222,7 +244,7 @@ const FastTimeEntryModal: React.FC<Props> = ({ | |||
{...register("remark", { | |||
validate: (value) => | |||
projectIds.every((id) => id) || | |||
value || | |||
Boolean(value) || | |||
t("Required for non-billable tasks"), | |||
})} | |||
helperText={ | |||
@@ -87,7 +87,6 @@ const AutocompleteProjectSelect: React.FC<Props> = ({ | |||
return option.value === (v ?? ""); | |||
}) | |||
: options.find((o) => o.value === value) || options[0]; | |||
// const currentValue = options.find((o) => o.value === value) || options[0]; | |||
const onChange = useCallback( | |||
( | |||