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