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