| @@ -13,6 +13,10 @@ 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 | ||||
| @@ -20,6 +24,7 @@ export type TimeEntryError = { | |||||
| 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 = {}; | ||||
| @@ -41,10 +46,12 @@ 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 (!entry.taskGroupId) { | |||||
| error.taskGroupId = "Required"; | |||||
| } else if (!entry.taskId) { | |||||
| error.taskId = "Required"; | |||||
| if (!options.skipTaskValidation) { | |||||
| if (!entry.taskGroupId) { | |||||
| error.taskGroupId = "Required"; | |||||
| } else if (!entry.taskId) { | |||||
| error.taskId = "Required"; | |||||
| } | |||||
| } | } | ||||
| } else { | } else { | ||||
| if (!entry.remark) { | if (!entry.remark) { | ||||
| @@ -71,6 +78,7 @@ 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 } = {}; | ||||
| @@ -86,7 +94,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)); | |||||
| const entryErrors = validateTimeEntry(entry, holidays.has(date), options); | |||||
| if (entryErrors) { | if (entryErrors) { | ||||
| errors[date] = "There are errors in the entries"; | errors[date] = "There are errors in the entries"; | ||||
| @@ -1,3 +1,19 @@ | |||||
| import zipWith from "lodash/zipWith"; | |||||
| export const roundToNearestQuarter = (n: number): number => { | export const roundToNearestQuarter = (n: number): number => { | ||||
| return Math.round(n / 0.25) * 0.25; | return Math.round(n / 0.25) * 0.25; | ||||
| }; | }; | ||||
| export const distributeQuarters = (hours: number, parts: number): number[] => { | |||||
| if (!parts) return []; | |||||
| const numQuarters = hours * 4; | |||||
| const equalParts = Math.floor(numQuarters / parts); | |||||
| const remainders = Array(numQuarters % parts).fill(1); | |||||
| return zipWith( | |||||
| Array(parts).fill(equalParts), | |||||
| remainders, | |||||
| (a, b) => a + (b || 0), | |||||
| ).map((quarters) => quarters / 4); | |||||
| }; | |||||
| @@ -42,6 +42,7 @@ interface Props { | |||||
| defaultTimesheets?: RecordTimesheetInput; | defaultTimesheets?: RecordTimesheetInput; | ||||
| leaveRecords: RecordLeaveInput; | leaveRecords: RecordLeaveInput; | ||||
| companyHolidays: HolidaysResult[]; | companyHolidays: HolidaysResult[]; | ||||
| fastEntryEnabled?: boolean; | |||||
| } | } | ||||
| const modalSx: SxProps = { | const modalSx: SxProps = { | ||||
| @@ -63,6 +64,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
| defaultTimesheets, | defaultTimesheets, | ||||
| leaveRecords, | leaveRecords, | ||||
| companyHolidays, | companyHolidays, | ||||
| fastEntryEnabled, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
| @@ -83,7 +85,9 @@ 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); | |||||
| const errors = validateTimesheet(data, leaveRecords, companyHolidays, { | |||||
| skipTaskValidation: fastEntryEnabled, | |||||
| }); | |||||
| if (errors) { | if (errors) { | ||||
| Object.keys(errors).forEach((date) => | Object.keys(errors).forEach((date) => | ||||
| formProps.setError(date, { | formProps.setError(date, { | ||||
| @@ -108,7 +112,14 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
| formProps.reset(newFormValues); | formProps.reset(newFormValues); | ||||
| onClose(); | onClose(); | ||||
| }, | }, | ||||
| [companyHolidays, formProps, leaveRecords, onClose, username], | |||||
| [ | |||||
| companyHolidays, | |||||
| fastEntryEnabled, | |||||
| formProps, | |||||
| leaveRecords, | |||||
| onClose, | |||||
| username, | |||||
| ], | |||||
| ); | ); | ||||
| const onCancel = useCallback(() => { | const onCancel = useCallback(() => { | ||||
| @@ -165,6 +176,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
| assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
| allProjects={allProjects} | allProjects={allProjects} | ||||
| leaveRecords={leaveRecords} | leaveRecords={leaveRecords} | ||||
| fastEntryEnabled={fastEntryEnabled} | |||||
| /> | /> | ||||
| </Box> | </Box> | ||||
| {errorComponent} | {errorComponent} | ||||
| @@ -202,6 +214,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
| {t("Timesheet Input")} | {t("Timesheet Input")} | ||||
| </Typography> | </Typography> | ||||
| <MobileTimesheetTable | <MobileTimesheetTable | ||||
| fastEntryEnabled={fastEntryEnabled} | |||||
| companyHolidays={companyHolidays} | companyHolidays={companyHolidays} | ||||
| assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
| allProjects={allProjects} | allProjects={allProjects} | ||||
| @@ -35,6 +35,7 @@ import { | |||||
| validateTimeEntry, | validateTimeEntry, | ||||
| } from "@/app/api/timesheets/utils"; | } from "@/app/api/timesheets/utils"; | ||||
| import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; | import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; | ||||
| import FastTimeEntryModal from "./FastTimeEntryModal"; | |||||
| dayjs.extend(isBetween); | dayjs.extend(isBetween); | ||||
| @@ -43,6 +44,7 @@ interface Props { | |||||
| isHoliday: boolean; | isHoliday: boolean; | ||||
| allProjects: ProjectWithTasks[]; | allProjects: ProjectWithTasks[]; | ||||
| assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
| fastEntryEnabled?: boolean; | |||||
| } | } | ||||
| export type TimeEntryRow = Partial< | export type TimeEntryRow = Partial< | ||||
| @@ -58,6 +60,7 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| allProjects, | allProjects, | ||||
| assignedProjects, | assignedProjects, | ||||
| isHoliday, | isHoliday, | ||||
| fastEntryEnabled, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
| const taskGroupsByProject = useMemo(() => { | const taskGroupsByProject = useMemo(() => { | ||||
| @@ -114,7 +117,9 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| "", | "", | ||||
| ) as TimeEntryRow; | ) as TimeEntryRow; | ||||
| const error = validateTimeEntry(row, isHoliday); | |||||
| const error = validateTimeEntry(row, isHoliday, { | |||||
| skipTaskValidation: fastEntryEnabled, | |||||
| }); | |||||
| // Test for warnings | // Test for warnings | ||||
| let isPlanned; | let isPlanned; | ||||
| @@ -133,7 +138,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, isHoliday, milestonesByProject], | |||||
| [apiRef, day, fastEntryEnabled, isHoliday, milestonesByProject], | |||||
| ); | ); | ||||
| const handleCancel = useCallback( | const handleCancel = useCallback( | ||||
| @@ -230,6 +235,7 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) { | renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) { | ||||
| return ( | return ( | ||||
| <ProjectSelect | <ProjectSelect | ||||
| multiple={false} | |||||
| allProjects={allProjects} | allProjects={allProjects} | ||||
| assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
| value={params.value} | value={params.value} | ||||
| @@ -406,6 +412,19 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| (entry) => entry.isPlanned !== undefined && !entry.isPlanned, | (entry) => entry.isPlanned !== undefined && !entry.isPlanned, | ||||
| ); | ); | ||||
| // Fast entry modal | |||||
| const [fastEntryModalOpen, setFastEntryModalOpen] = useState(false); | |||||
| const closeFastEntryModal = useCallback(() => { | |||||
| setFastEntryModalOpen(false); | |||||
| }, []); | |||||
| const openFastEntryModal = useCallback(() => { | |||||
| setFastEntryModalOpen(true); | |||||
| }, []); | |||||
| const onSaveFastEntry = useCallback(async (entries: TimeEntry[]) => { | |||||
| setEntries((e) => [...e, ...entries]); | |||||
| setFastEntryModalOpen(false); | |||||
| }, []); | |||||
| const footer = ( | const footer = ( | ||||
| <Box display="flex" gap={2} alignItems="center"> | <Box display="flex" gap={2} alignItems="center"> | ||||
| <Button | <Button | ||||
| @@ -417,6 +436,15 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| > | > | ||||
| {t("Record time")} | {t("Record time")} | ||||
| </Button> | </Button> | ||||
| <Button | |||||
| disableRipple | |||||
| variant="outlined" | |||||
| startIcon={<Add />} | |||||
| onClick={openFastEntryModal} | |||||
| size="small" | |||||
| > | |||||
| {t("Fast time entry")} | |||||
| </Button> | |||||
| {hasOutOfPlannedStages && ( | {hasOutOfPlannedStages && ( | ||||
| <Typography color="warning.main" variant="body2"> | <Typography color="warning.main" variant="body2"> | ||||
| {t("There are entries for stages out of planned dates!")} | {t("There are entries for stages out of planned dates!")} | ||||
| @@ -426,49 +454,61 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| ); | ); | ||||
| return ( | return ( | ||||
| <StyledDataGrid | |||||
| apiRef={apiRef} | |||||
| autoHeight | |||||
| sx={{ | |||||
| "--DataGrid-overlayHeight": "100px", | |||||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasError": { | |||||
| border: "1px solid", | |||||
| borderColor: "error.main", | |||||
| }, | |||||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": { | |||||
| border: "1px solid", | |||||
| borderColor: "warning.main", | |||||
| }, | |||||
| }} | |||||
| disableColumnMenu | |||||
| editMode="row" | |||||
| rows={entries} | |||||
| rowModesModel={rowModesModel} | |||||
| onRowModesModelChange={setRowModesModel} | |||||
| onRowEditStop={handleEditStop} | |||||
| processRowUpdate={processRowUpdate} | |||||
| columns={columns} | |||||
| getCellClassName={(params: GridCellParams<TimeEntryRow>) => { | |||||
| let classname = ""; | |||||
| if (params.row._error?.[params.field as keyof TimeEntry]) { | |||||
| classname = "hasError"; | |||||
| } else if ( | |||||
| params.field === "taskGroupId" && | |||||
| params.row.isPlanned !== undefined && | |||||
| !params.row.isPlanned | |||||
| ) { | |||||
| classname = "hasWarning"; | |||||
| } | |||||
| return classname; | |||||
| }} | |||||
| slots={{ | |||||
| footer: FooterToolbar, | |||||
| noRowsOverlay: NoRowsOverlay, | |||||
| }} | |||||
| slotProps={{ | |||||
| footer: { child: footer }, | |||||
| }} | |||||
| /> | |||||
| <> | |||||
| <StyledDataGrid | |||||
| apiRef={apiRef} | |||||
| autoHeight | |||||
| sx={{ | |||||
| "--DataGrid-overlayHeight": "100px", | |||||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasError": { | |||||
| border: "1px solid", | |||||
| borderColor: "error.main", | |||||
| }, | |||||
| ".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": { | |||||
| border: "1px solid", | |||||
| borderColor: "warning.main", | |||||
| }, | |||||
| }} | |||||
| disableColumnMenu | |||||
| editMode="row" | |||||
| rows={entries} | |||||
| rowModesModel={rowModesModel} | |||||
| onRowModesModelChange={setRowModesModel} | |||||
| onRowEditStop={handleEditStop} | |||||
| processRowUpdate={processRowUpdate} | |||||
| columns={columns} | |||||
| getCellClassName={(params: GridCellParams<TimeEntryRow>) => { | |||||
| let classname = ""; | |||||
| if (params.row._error?.[params.field as keyof TimeEntry]) { | |||||
| classname = "hasError"; | |||||
| } else if ( | |||||
| params.field === "taskGroupId" && | |||||
| params.row.isPlanned !== undefined && | |||||
| !params.row.isPlanned | |||||
| ) { | |||||
| classname = "hasWarning"; | |||||
| } | |||||
| return classname; | |||||
| }} | |||||
| slots={{ | |||||
| footer: FooterToolbar, | |||||
| noRowsOverlay: NoRowsOverlay, | |||||
| }} | |||||
| slotProps={{ | |||||
| footer: { child: footer }, | |||||
| }} | |||||
| /> | |||||
| {fastEntryEnabled && ( | |||||
| <FastTimeEntryModal | |||||
| allProjects={allProjects} | |||||
| assignedProjects={assignedProjects} | |||||
| open={fastEntryModalOpen} | |||||
| isHoliday={Boolean(isHoliday)} | |||||
| onClose={closeFastEntryModal} | |||||
| onSave={onSaveFastEntry} | |||||
| /> | |||||
| )} | |||||
| </> | |||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -0,0 +1,309 @@ | |||||
| import { TimeEntry } from "@/app/api/timesheets/actions"; | |||||
| import { Check, ExpandMore } from "@mui/icons-material"; | |||||
| import { | |||||
| Accordion, | |||||
| AccordionDetails, | |||||
| AccordionSummary, | |||||
| Alert, | |||||
| Box, | |||||
| Button, | |||||
| FormControl, | |||||
| FormHelperText, | |||||
| InputLabel, | |||||
| Modal, | |||||
| ModalProps, | |||||
| Paper, | |||||
| SxProps, | |||||
| TextField, | |||||
| Typography, | |||||
| } from "@mui/material"; | |||||
| import React, { useCallback, useMemo } from "react"; | |||||
| import { Controller, useForm } from "react-hook-form"; | |||||
| import { useTranslation } from "react-i18next"; | |||||
| import ProjectSelect from "./ProjectSelect"; | |||||
| import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||||
| import { | |||||
| distributeQuarters, | |||||
| roundToNearestQuarter, | |||||
| } from "@/app/utils/manhourUtils"; | |||||
| import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; | |||||
| import { DAILY_NORMAL_MAX_HOURS } from "@/app/api/timesheets/utils"; | |||||
| import zip from "lodash/zip"; | |||||
| export interface FastTimeEntryForm { | |||||
| projectIds: TimeEntry["projectId"][]; | |||||
| inputHours: TimeEntry["inputHours"]; | |||||
| otHours: TimeEntry["otHours"]; | |||||
| remark: TimeEntry["remark"]; | |||||
| } | |||||
| export interface Props extends Omit<ModalProps, "children"> { | |||||
| onSave: (timeEntries: TimeEntry[], recordDate?: string) => Promise<void>; | |||||
| onDelete?: () => void; | |||||
| allProjects: ProjectWithTasks[]; | |||||
| assignedProjects: AssignedProject[]; | |||||
| modalSx?: SxProps; | |||||
| recordDate?: string; | |||||
| isHoliday?: boolean; | |||||
| } | |||||
| const modalSx: SxProps = { | |||||
| position: "absolute", | |||||
| top: "50%", | |||||
| left: "50%", | |||||
| transform: "translate(-50%, -50%)", | |||||
| width: "90%", | |||||
| maxWidth: "sm", | |||||
| maxHeight: "90%", | |||||
| padding: 3, | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| gap: 2, | |||||
| }; | |||||
| let idOffset = Date.now(); | |||||
| const getID = () => { | |||||
| return ++idOffset; | |||||
| }; | |||||
| const FastTimeEntryModal: React.FC<Props> = ({ | |||||
| onSave, | |||||
| open, | |||||
| onClose, | |||||
| allProjects, | |||||
| assignedProjects, | |||||
| modalSx: mSx, | |||||
| recordDate, | |||||
| isHoliday, | |||||
| }) => { | |||||
| const { | |||||
| t, | |||||
| i18n: { language }, | |||||
| } = useTranslation("home"); | |||||
| const { register, control, reset, trigger, formState, watch } = | |||||
| useForm<FastTimeEntryForm>({ | |||||
| defaultValues: { | |||||
| projectIds: [], | |||||
| }, | |||||
| }); | |||||
| const projectIds = watch("projectIds"); | |||||
| const inputHours = watch("inputHours"); | |||||
| const otHours = watch("otHours"); | |||||
| const remark = watch("remark"); | |||||
| const selectedProjects = useMemo(() => { | |||||
| return projectIds.map((id) => allProjects.find((p) => p.id === id)); | |||||
| }, [allProjects, projectIds]); | |||||
| const normalHoursArray = distributeQuarters( | |||||
| inputHours || 0, | |||||
| selectedProjects.length, | |||||
| ); | |||||
| const otHoursArray = distributeQuarters( | |||||
| otHours || 0, | |||||
| selectedProjects.length, | |||||
| ); | |||||
| const projectsWithHours = zip( | |||||
| selectedProjects, | |||||
| normalHoursArray, | |||||
| otHoursArray, | |||||
| ); | |||||
| const saveHandler = useCallback(async () => { | |||||
| const valid = await trigger(); | |||||
| if (valid) { | |||||
| onSave( | |||||
| projectsWithHours.map(([project, hour, othour]) => ({ | |||||
| id: getID(), | |||||
| projectId: project?.id, | |||||
| inputHours: hour, | |||||
| otHours: othour, | |||||
| remark, | |||||
| })), | |||||
| recordDate, | |||||
| ); | |||||
| reset(); | |||||
| } | |||||
| }, [projectsWithHours, trigger, onSave, recordDate, reset, remark]); | |||||
| const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | |||||
| (...args) => { | |||||
| onClose?.(...args); | |||||
| reset(); | |||||
| }, | |||||
| [onClose, reset], | |||||
| ); | |||||
| return ( | |||||
| <Modal open={open} onClose={closeHandler}> | |||||
| <Paper sx={{ ...modalSx, ...mSx }}> | |||||
| {recordDate && ( | |||||
| <Typography variant="h6" marginBlockEnd={2}> | |||||
| {shortDateFormatter(language).format(new Date(recordDate))} | |||||
| </Typography> | |||||
| )} | |||||
| <FormControl fullWidth error={Boolean(formState.errors.projectIds)}> | |||||
| <InputLabel shrink>{t("Project Code and Name")}</InputLabel> | |||||
| <Controller | |||||
| control={control} | |||||
| name="projectIds" | |||||
| render={({ field }) => ( | |||||
| <ProjectSelect | |||||
| error={Boolean(formState.errors.projectIds)} | |||||
| multiple | |||||
| allProjects={allProjects} | |||||
| assignedProjects={assignedProjects} | |||||
| value={field.value} | |||||
| onProjectSelect={(newIds) => { | |||||
| field.onChange( | |||||
| newIds.map((id) => (id === "" ? undefined : id)), | |||||
| ); | |||||
| }} | |||||
| /> | |||||
| )} | |||||
| rules={{ | |||||
| validate: (value) => | |||||
| value.length > 0 || t("Please choose at least 1 project."), | |||||
| }} | |||||
| /> | |||||
| <FormHelperText> | |||||
| {formState.errors.projectIds?.message || | |||||
| t( | |||||
| "The inputted time will be evenly distributed among the selected projects.", | |||||
| )} | |||||
| </FormHelperText> | |||||
| </FormControl> | |||||
| <TextField | |||||
| type="number" | |||||
| label={t("Hours")} | |||||
| fullWidth | |||||
| {...register("inputHours", { | |||||
| setValueAs: (value) => roundToNearestQuarter(parseFloat(value)), | |||||
| 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} | |||||
| /> | |||||
| <TextField | |||||
| type="number" | |||||
| label={t("Other Hours")} | |||||
| fullWidth | |||||
| {...register("otHours", { | |||||
| setValueAs: (value) => roundToNearestQuarter(parseFloat(value)), | |||||
| validate: (value) => (value ? value > 0 : true), | |||||
| })} | |||||
| error={Boolean(formState.errors.otHours)} | |||||
| /> | |||||
| <TextField | |||||
| label={t("Remark")} | |||||
| fullWidth | |||||
| multiline | |||||
| rows={2} | |||||
| error={Boolean(formState.errors.remark)} | |||||
| {...register("remark", { | |||||
| validate: (value) => | |||||
| projectIds.every((id) => id) || | |||||
| value || | |||||
| t("Required for non-billable tasks"), | |||||
| })} | |||||
| helperText={ | |||||
| formState.errors.remark?.message || | |||||
| t("The remark will be added to all selected projects") | |||||
| } | |||||
| /> | |||||
| <Accordion variant="outlined" sx={{ overflowY: "scroll" }}> | |||||
| <AccordionSummary expandIcon={<ExpandMore />}> | |||||
| <Typography variant="subtitle2"> | |||||
| {t("Hour distribution preview")} | |||||
| </Typography> | |||||
| </AccordionSummary> | |||||
| <AccordionDetails> | |||||
| {projectIds.length > 0 ? ( | |||||
| <ProjectHourSummary projectsWithHours={projectsWithHours} /> | |||||
| ) : ( | |||||
| <Alert severity="warning"> | |||||
| {t("Please select some projects.")} | |||||
| </Alert> | |||||
| )} | |||||
| </AccordionDetails> | |||||
| </Accordion> | |||||
| <Box display="flex" justifyContent="flex-end"> | |||||
| <Button | |||||
| variant="contained" | |||||
| startIcon={<Check />} | |||||
| onClick={saveHandler} | |||||
| > | |||||
| {t("Save")} | |||||
| </Button> | |||||
| </Box> | |||||
| </Paper> | |||||
| </Modal> | |||||
| ); | |||||
| }; | |||||
| const ProjectHourSummary: React.FC<{ | |||||
| projectsWithHours: [ProjectWithTasks?, number?, number?][]; | |||||
| }> = ({ projectsWithHours }) => { | |||||
| const { t } = useTranslation("home"); | |||||
| return ( | |||||
| <Box | |||||
| sx={{ | |||||
| display: "flex", | |||||
| flexDirection: "column", | |||||
| gap: 2, | |||||
| }} | |||||
| > | |||||
| {projectsWithHours.map(([project, manhour, otManhour], index) => { | |||||
| return ( | |||||
| <Box key={`${index}-${project?.id || "none"}`}> | |||||
| <Typography variant="body2" component="div" fontWeight="bold"> | |||||
| {project | |||||
| ? `${project.code} - ${project.name}` | |||||
| : t("Non-billable Task")} | |||||
| </Typography> | |||||
| <Box display="flex" gap={2}> | |||||
| <Box> | |||||
| <Typography variant="body2" component="div" fontWeight="bold"> | |||||
| {t("Hours")} | |||||
| </Typography> | |||||
| <Typography component="p"> | |||||
| {manhourFormatter.format(manhour || 0)} | |||||
| </Typography> | |||||
| </Box> | |||||
| <Box> | |||||
| <Typography variant="body2" component="div" fontWeight="bold"> | |||||
| {t("Other Hours")} | |||||
| </Typography> | |||||
| <Typography component="p"> | |||||
| {manhourFormatter.format(otManhour || 0)} | |||||
| </Typography> | |||||
| </Box> | |||||
| </Box> | |||||
| </Box> | |||||
| ); | |||||
| })} | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default FastTimeEntryModal; | |||||
| @@ -1,14 +1,7 @@ | |||||
| import { TimeEntry, RecordTimesheetInput } from "@/app/api/timesheets/actions"; | import { TimeEntry, RecordTimesheetInput } from "@/app/api/timesheets/actions"; | ||||
| import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; | |||||
| import { Add, Edit } from "@mui/icons-material"; | |||||
| import { | |||||
| Box, | |||||
| Button, | |||||
| Card, | |||||
| CardContent, | |||||
| IconButton, | |||||
| Typography, | |||||
| } from "@mui/material"; | |||||
| import { shortDateFormatter } from "@/app/utils/formatUtil"; | |||||
| import { Add } from "@mui/icons-material"; | |||||
| import { Box, Button, Stack, Typography } from "@mui/material"; | |||||
| import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
| import React, { useCallback, useMemo, useState } from "react"; | import React, { useCallback, useMemo, useState } from "react"; | ||||
| import { useFormContext } from "react-hook-form"; | import { useFormContext } from "react-hook-form"; | ||||
| @@ -20,12 +13,14 @@ import TimesheetEditModal, { | |||||
| import TimeEntryCard from "./TimeEntryCard"; | import TimeEntryCard from "./TimeEntryCard"; | ||||
| import { HolidaysResult } from "@/app/api/holidays"; | import { HolidaysResult } from "@/app/api/holidays"; | ||||
| import { getHolidayForDate } from "@/app/utils/holidayUtils"; | import { getHolidayForDate } from "@/app/utils/holidayUtils"; | ||||
| import FastTimeEntryModal from "./FastTimeEntryModal"; | |||||
| interface Props { | interface Props { | ||||
| date: string; | date: string; | ||||
| allProjects: ProjectWithTasks[]; | allProjects: ProjectWithTasks[]; | ||||
| assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
| companyHolidays: HolidaysResult[]; | companyHolidays: HolidaysResult[]; | ||||
| fastEntryEnabled?: boolean; | |||||
| } | } | ||||
| const MobileTimesheetEntry: React.FC<Props> = ({ | const MobileTimesheetEntry: React.FC<Props> = ({ | ||||
| @@ -33,6 +28,7 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
| allProjects, | allProjects, | ||||
| assignedProjects, | assignedProjects, | ||||
| companyHolidays, | companyHolidays, | ||||
| fastEntryEnabled, | |||||
| }) => { | }) => { | ||||
| const { | const { | ||||
| t, | t, | ||||
| @@ -51,7 +47,8 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
| const holiday = getHolidayForDate(date, companyHolidays); | const holiday = getHolidayForDate(date, companyHolidays); | ||||
| const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; | const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; | ||||
| const { watch, setValue, clearErrors } = useFormContext<RecordTimesheetInput>(); | |||||
| const { watch, setValue, clearErrors } = | |||||
| useFormContext<RecordTimesheetInput>(); | |||||
| const currentEntries = watch(date); | const currentEntries = watch(date); | ||||
| // Edit modal | // Edit modal | ||||
| @@ -103,6 +100,22 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
| [clearErrors, currentEntries, date, setValue], | [clearErrors, currentEntries, date, setValue], | ||||
| ); | ); | ||||
| // Fast entry modal | |||||
| const [fastEntryModalOpen, setFastEntryModalOpen] = useState(false); | |||||
| const closeFastEntryModal = useCallback(() => { | |||||
| setFastEntryModalOpen(false); | |||||
| }, []); | |||||
| const openFastEntryModal = useCallback(() => { | |||||
| setFastEntryModalOpen(true); | |||||
| }, []); | |||||
| const onSaveFastEntry = useCallback( | |||||
| async (entries: TimeEntry[]) => { | |||||
| setValue(date, [...currentEntries, ...entries]); | |||||
| setFastEntryModalOpen(false); | |||||
| }, | |||||
| [currentEntries, date, setValue], | |||||
| ); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Typography | <Typography | ||||
| @@ -149,11 +162,16 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
| {t("Add some time entries!")} | {t("Add some time entries!")} | ||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| <Box> | |||||
| <Stack alignItems={"flex-start"} spacing={1}> | |||||
| <Button startIcon={<Add />} onClick={openEditModal()}> | <Button startIcon={<Add />} onClick={openEditModal()}> | ||||
| {t("Record time")} | {t("Record time")} | ||||
| </Button> | </Button> | ||||
| </Box> | |||||
| {fastEntryEnabled && ( | |||||
| <Button startIcon={<Add />} onClick={openFastEntryModal}> | |||||
| {t("Fast time entry")} | |||||
| </Button> | |||||
| )} | |||||
| </Stack> | |||||
| <TimesheetEditModal | <TimesheetEditModal | ||||
| allProjects={allProjects} | allProjects={allProjects} | ||||
| assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
| @@ -161,8 +179,19 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
| onClose={closeEditModal} | onClose={closeEditModal} | ||||
| onSave={onSaveEntry} | onSave={onSaveEntry} | ||||
| isHoliday={Boolean(isHoliday)} | isHoliday={Boolean(isHoliday)} | ||||
| fastEntryEnabled={fastEntryEnabled} | |||||
| {...editModalProps} | {...editModalProps} | ||||
| /> | /> | ||||
| {fastEntryEnabled && ( | |||||
| <FastTimeEntryModal | |||||
| allProjects={allProjects} | |||||
| assignedProjects={assignedProjects} | |||||
| open={fastEntryModalOpen} | |||||
| isHoliday={Boolean(isHoliday)} | |||||
| onClose={closeFastEntryModal} | |||||
| onSave={onSaveFastEntry} | |||||
| /> | |||||
| )} | |||||
| </Box> | </Box> | ||||
| </> | </> | ||||
| ); | ); | ||||
| @@ -15,6 +15,7 @@ interface Props { | |||||
| leaveRecords: RecordLeaveInput; | leaveRecords: RecordLeaveInput; | ||||
| companyHolidays: HolidaysResult[]; | companyHolidays: HolidaysResult[]; | ||||
| errorComponent?: React.ReactNode; | errorComponent?: React.ReactNode; | ||||
| fastEntryEnabled?: boolean; | |||||
| } | } | ||||
| const MobileTimesheetTable: React.FC<Props> = ({ | const MobileTimesheetTable: React.FC<Props> = ({ | ||||
| @@ -23,6 +24,7 @@ const MobileTimesheetTable: React.FC<Props> = ({ | |||||
| leaveRecords, | leaveRecords, | ||||
| companyHolidays, | companyHolidays, | ||||
| errorComponent, | errorComponent, | ||||
| fastEntryEnabled, | |||||
| }) => { | }) => { | ||||
| const { watch } = useFormContext<RecordTimesheetInput>(); | const { watch } = useFormContext<RecordTimesheetInput>(); | ||||
| const currentInput = watch(); | const currentInput = watch(); | ||||
| @@ -35,7 +37,12 @@ const MobileTimesheetTable: React.FC<Props> = ({ | |||||
| leaveEntries={leaveRecords} | leaveEntries={leaveRecords} | ||||
| timesheetEntries={currentInput} | timesheetEntries={currentInput} | ||||
| EntryComponent={MobileTimesheetEntry} | EntryComponent={MobileTimesheetEntry} | ||||
| entryComponentProps={{ allProjects, assignedProjects, companyHolidays }} | |||||
| entryComponentProps={{ | |||||
| allProjects, | |||||
| assignedProjects, | |||||
| companyHolidays, | |||||
| fastEntryEnabled, | |||||
| }} | |||||
| errorComponent={errorComponent} | errorComponent={errorComponent} | ||||
| /> | /> | ||||
| ); | ); | ||||
| @@ -1,6 +1,8 @@ | |||||
| import React, { useCallback, useMemo } from "react"; | import React, { useCallback, useMemo } from "react"; | ||||
| import { | import { | ||||
| Autocomplete, | Autocomplete, | ||||
| Checkbox, | |||||
| Chip, | |||||
| ListSubheader, | ListSubheader, | ||||
| MenuItem, | MenuItem, | ||||
| TextField, | TextField, | ||||
| @@ -8,15 +10,30 @@ import { | |||||
| import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | ||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import differenceBy from "lodash/differenceBy"; | import differenceBy from "lodash/differenceBy"; | ||||
| import intersectionWith from "lodash/intersectionWith"; | |||||
| import { TFunction } from "i18next"; | import { TFunction } from "i18next"; | ||||
| interface Props { | |||||
| interface CommonProps { | |||||
| allProjects: ProjectWithTasks[]; | allProjects: ProjectWithTasks[]; | ||||
| assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
| error?: boolean; | |||||
| multiple?: boolean; | |||||
| } | |||||
| interface SingleAutocompleteProps extends CommonProps { | |||||
| value: number | undefined; | value: number | undefined; | ||||
| onProjectSelect: (projectId: number | string) => void; | onProjectSelect: (projectId: number | string) => void; | ||||
| multiple: false; | |||||
| } | } | ||||
| interface MultiAutocompleteProps extends CommonProps { | |||||
| value: (number | undefined)[]; | |||||
| onProjectSelect: (projectIds: Array<number | string>) => void; | |||||
| multiple: true; | |||||
| } | |||||
| type Props = SingleAutocompleteProps | MultiAutocompleteProps; | |||||
| const getGroupName = (t: TFunction, groupName: string): string => { | const getGroupName = (t: TFunction, groupName: string): string => { | ||||
| switch (groupName) { | switch (groupName) { | ||||
| case "non-billable": | case "non-billable": | ||||
| @@ -37,6 +54,8 @@ const AutocompleteProjectSelect: React.FC<Props> = ({ | |||||
| assignedProjects, | assignedProjects, | ||||
| value, | value, | ||||
| onProjectSelect, | onProjectSelect, | ||||
| error, | |||||
| multiple, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
| const nonAssignedProjects = useMemo(() => { | const nonAssignedProjects = useMemo(() => { | ||||
| @@ -63,17 +82,32 @@ const AutocompleteProjectSelect: React.FC<Props> = ({ | |||||
| ]; | ]; | ||||
| }, [assignedProjects, nonAssignedProjects, t]); | }, [assignedProjects, nonAssignedProjects, t]); | ||||
| const currentValue = options.find((o) => o.value === value) || options[0]; | |||||
| const currentValue = multiple | |||||
| ? intersectionWith(options, value, (option, v) => { | |||||
| 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( | const onChange = useCallback( | ||||
| (event: React.SyntheticEvent, newValue: { value: number | string }) => { | |||||
| onProjectSelect(newValue.value); | |||||
| ( | |||||
| event: React.SyntheticEvent, | |||||
| newValue: { value: number | string } | { value: number | string }[], | |||||
| ) => { | |||||
| if (multiple) { | |||||
| const multiNewValue = newValue as { value: number | string }[]; | |||||
| onProjectSelect(multiNewValue.map(({ value }) => value)); | |||||
| } else { | |||||
| const singleNewVal = newValue as { value: number | string }; | |||||
| onProjectSelect(singleNewVal.value); | |||||
| } | |||||
| }, | }, | ||||
| [onProjectSelect], | |||||
| [onProjectSelect, multiple], | |||||
| ); | ); | ||||
| return ( | return ( | ||||
| <Autocomplete | <Autocomplete | ||||
| multiple={multiple} | |||||
| noOptionsText={t("No projects")} | noOptionsText={t("No projects")} | ||||
| disableClearable | disableClearable | ||||
| fullWidth | fullWidth | ||||
| @@ -82,22 +116,56 @@ const AutocompleteProjectSelect: React.FC<Props> = ({ | |||||
| groupBy={(option) => option.group} | groupBy={(option) => option.group} | ||||
| getOptionLabel={(option) => option.label} | getOptionLabel={(option) => option.label} | ||||
| options={options} | options={options} | ||||
| disableCloseOnSelect={multiple} | |||||
| renderTags={ | |||||
| multiple | |||||
| ? (value, getTagProps) => | |||||
| value.map((option, index) => { | |||||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | |||||
| const { key, ...chipProps } = getTagProps({ index }); | |||||
| return ( | |||||
| <Chip | |||||
| {...chipProps} | |||||
| key={`${option.value}--${option.label}`} | |||||
| label={option.label} | |||||
| /> | |||||
| ); | |||||
| }) | |||||
| : undefined | |||||
| } | |||||
| renderGroup={(params) => ( | renderGroup={(params) => ( | ||||
| <> | |||||
| <ListSubheader key={params.key}> | |||||
| {getGroupName(t, params.group)} | |||||
| </ListSubheader> | |||||
| <React.Fragment key={`${params.key}-${params.group}`}> | |||||
| <ListSubheader>{getGroupName(t, params.group)}</ListSubheader> | |||||
| {params.children} | {params.children} | ||||
| </> | |||||
| </React.Fragment> | |||||
| )} | )} | ||||
| renderOption={(params, option) => { | |||||
| renderOption={( | |||||
| params: React.HTMLAttributes<HTMLLIElement> & { key?: React.Key }, | |||||
| option, | |||||
| { selected }, | |||||
| ) => { | |||||
| // eslint-disable-next-line @typescript-eslint/no-unused-vars | |||||
| const { key, ...rest } = params; | |||||
| return ( | return ( | ||||
| <MenuItem {...params} key={option.value} value={option.value}> | |||||
| <MenuItem | |||||
| {...rest} | |||||
| disableRipple | |||||
| value={option.value} | |||||
| key={`${option.value}--${option.label}`} | |||||
| > | |||||
| {multiple && ( | |||||
| <Checkbox | |||||
| disableRipple | |||||
| key={`checkbox-${option.value}`} | |||||
| checked={selected} | |||||
| sx={{ transform: "translate(0)" }} | |||||
| /> | |||||
| )} | |||||
| {option.label} | {option.label} | ||||
| </MenuItem> | </MenuItem> | ||||
| ); | ); | ||||
| }} | }} | ||||
| renderInput={(params) => <TextField {...params} />} | |||||
| renderInput={(params) => <TextField {...params} error={error} />} | |||||
| /> | /> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -34,6 +34,7 @@ export interface Props extends Omit<ModalProps, "children"> { | |||||
| modalSx?: SxProps; | modalSx?: SxProps; | ||||
| recordDate?: string; | recordDate?: string; | ||||
| isHoliday?: boolean; | isHoliday?: boolean; | ||||
| fastEntryEnabled?: boolean; | |||||
| } | } | ||||
| const modalSx: SxProps = { | const modalSx: SxProps = { | ||||
| @@ -59,6 +60,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||||
| modalSx: mSx, | modalSx: mSx, | ||||
| recordDate, | recordDate, | ||||
| isHoliday, | isHoliday, | ||||
| fastEntryEnabled, | |||||
| }) => { | }) => { | ||||
| const { | const { | ||||
| t, | t, | ||||
| @@ -135,6 +137,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||||
| name="projectId" | name="projectId" | ||||
| render={({ field }) => ( | render={({ field }) => ( | ||||
| <ProjectSelect | <ProjectSelect | ||||
| multiple={false} | |||||
| allProjects={allProjects} | allProjects={allProjects} | ||||
| assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
| value={field.value} | value={field.value} | ||||
| @@ -173,6 +176,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||||
| if (!projectId) { | if (!projectId) { | ||||
| return !id; | return !id; | ||||
| } | } | ||||
| if (fastEntryEnabled) return true; | |||||
| const taskGroups = taskGroupsByProject[projectId]; | const taskGroups = taskGroupsByProject[projectId]; | ||||
| return taskGroups.some((tg) => tg.value === id); | return taskGroups.some((tg) => tg.value === id); | ||||
| }, | }, | ||||
| @@ -202,6 +206,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||||
| if (!projectId) { | if (!projectId) { | ||||
| return !id; | return !id; | ||||
| } | } | ||||
| if (fastEntryEnabled) return true; | |||||
| const projectTasks = allProjects.find((p) => p.id === projectId) | const projectTasks = allProjects.find((p) => p.id === projectId) | ||||
| ?.tasks; | ?.tasks; | ||||
| return Boolean(projectTasks?.some((task) => task.id === id)); | return Boolean(projectTasks?.some((task) => task.id === id)); | ||||
| @@ -14,6 +14,7 @@ interface Props { | |||||
| assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
| leaveRecords: RecordLeaveInput; | leaveRecords: RecordLeaveInput; | ||||
| companyHolidays: HolidaysResult[]; | companyHolidays: HolidaysResult[]; | ||||
| fastEntryEnabled?: boolean; | |||||
| } | } | ||||
| const TimesheetTable: React.FC<Props> = ({ | const TimesheetTable: React.FC<Props> = ({ | ||||
| @@ -21,6 +22,7 @@ const TimesheetTable: React.FC<Props> = ({ | |||||
| assignedProjects, | assignedProjects, | ||||
| leaveRecords, | leaveRecords, | ||||
| companyHolidays, | companyHolidays, | ||||
| fastEntryEnabled, | |||||
| }) => { | }) => { | ||||
| const { watch } = useFormContext<RecordTimesheetInput>(); | const { watch } = useFormContext<RecordTimesheetInput>(); | ||||
| const currentInput = watch(); | const currentInput = watch(); | ||||
| @@ -33,7 +35,7 @@ const TimesheetTable: React.FC<Props> = ({ | |||||
| leaveEntries={leaveRecords} | leaveEntries={leaveRecords} | ||||
| timesheetEntries={currentInput} | timesheetEntries={currentInput} | ||||
| EntryTableComponent={EntryInputTable} | EntryTableComponent={EntryInputTable} | ||||
| entryTableProps={{ assignedProjects, allProjects }} | |||||
| entryTableProps={{ assignedProjects, allProjects, fastEntryEnabled }} | |||||
| /> | /> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -35,6 +35,7 @@ export interface Props { | |||||
| defaultTimesheets: RecordTimesheetInput; | defaultTimesheets: RecordTimesheetInput; | ||||
| holidays: HolidaysResult[]; | holidays: HolidaysResult[]; | ||||
| teamTimesheets: TeamTimeSheets; | teamTimesheets: TeamTimeSheets; | ||||
| fastEntryEnabled?: boolean; | |||||
| } | } | ||||
| const menuItemSx: SxProps = { | const menuItemSx: SxProps = { | ||||
| @@ -51,6 +52,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| defaultTimesheets, | defaultTimesheets, | ||||
| holidays, | holidays, | ||||
| teamTimesheets, | teamTimesheets, | ||||
| fastEntryEnabled, | |||||
| }) => { | }) => { | ||||
| const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | ||||
| @@ -170,6 +172,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| leaveTypes={leaveTypes} | leaveTypes={leaveTypes} | ||||
| /> | /> | ||||
| <TimesheetModal | <TimesheetModal | ||||
| fastEntryEnabled={fastEntryEnabled} | |||||
| companyHolidays={holidays} | companyHolidays={holidays} | ||||
| isOpen={isTimeheetModalVisible} | isOpen={isTimeheetModalVisible} | ||||
| onClose={handleCloseTimesheetModal} | onClose={handleCloseTimesheetModal} | ||||
| @@ -44,6 +44,8 @@ const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => { | |||||
| defaultLeaveRecords={leaves} | defaultLeaveRecords={leaves} | ||||
| leaveTypes={leaveTypes} | leaveTypes={leaveTypes} | ||||
| holidays={holidays} | holidays={holidays} | ||||
| // Change to access check | |||||
| fastEntryEnabled={true} | |||||
| /> | /> | ||||
| ); | ); | ||||
| }; | }; | ||||