| @@ -13,6 +13,10 @@ export type TimeEntryError = { | |||
| [field in keyof TimeEntry]?: string; | |||
| }; | |||
| interface TimeEntryValidationOptions { | |||
| skipTaskValidation?: boolean; | |||
| } | |||
| /** | |||
| * @param entry - the time entry | |||
| * @returns an object where the keys are the error fields and the values the error message, and undefined if there are no errors | |||
| @@ -20,6 +24,7 @@ export type TimeEntryError = { | |||
| export const validateTimeEntry = ( | |||
| entry: Partial<TimeEntry>, | |||
| isHoliday: boolean, | |||
| options: TimeEntryValidationOptions = {}, | |||
| ): TimeEntryError | undefined => { | |||
| // Test for errors | |||
| const error: TimeEntryError = {}; | |||
| @@ -41,10 +46,12 @@ export const validateTimeEntry = ( | |||
| // If there is a project id, there should also be taskGroupId, taskId, inputHours | |||
| 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 { | |||
| if (!entry.remark) { | |||
| @@ -71,6 +78,7 @@ export const validateTimesheet = ( | |||
| timesheet: RecordTimesheetInput, | |||
| leaveRecords: RecordLeaveInput, | |||
| companyHolidays: HolidaysResult[], | |||
| options: TimeEntryValidationOptions = {}, | |||
| ): { [date: string]: string } | undefined => { | |||
| const errors: { [date: string]: string } = {}; | |||
| @@ -86,7 +94,7 @@ export const validateTimesheet = ( | |||
| // Check each entry | |||
| for (const entry of timeEntries) { | |||
| const entryErrors = validateTimeEntry(entry, holidays.has(date)); | |||
| const entryErrors = validateTimeEntry(entry, holidays.has(date), options); | |||
| if (entryErrors) { | |||
| errors[date] = "There are errors in the entries"; | |||
| @@ -1,3 +1,19 @@ | |||
| import zipWith from "lodash/zipWith"; | |||
| export const roundToNearestQuarter = (n: number): number => { | |||
| 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; | |||
| leaveRecords: RecordLeaveInput; | |||
| companyHolidays: HolidaysResult[]; | |||
| fastEntryEnabled?: boolean; | |||
| } | |||
| const modalSx: SxProps = { | |||
| @@ -63,6 +64,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||
| defaultTimesheets, | |||
| leaveRecords, | |||
| companyHolidays, | |||
| fastEntryEnabled, | |||
| }) => { | |||
| const { t } = useTranslation("home"); | |||
| @@ -83,7 +85,9 @@ const TimesheetModal: React.FC<Props> = ({ | |||
| const onSubmit = useCallback<SubmitHandler<RecordTimesheetInput>>( | |||
| async (data) => { | |||
| const errors = validateTimesheet(data, leaveRecords, companyHolidays); | |||
| const errors = validateTimesheet(data, leaveRecords, companyHolidays, { | |||
| skipTaskValidation: fastEntryEnabled, | |||
| }); | |||
| if (errors) { | |||
| Object.keys(errors).forEach((date) => | |||
| formProps.setError(date, { | |||
| @@ -108,7 +112,14 @@ const TimesheetModal: React.FC<Props> = ({ | |||
| formProps.reset(newFormValues); | |||
| onClose(); | |||
| }, | |||
| [companyHolidays, formProps, leaveRecords, onClose, username], | |||
| [ | |||
| companyHolidays, | |||
| fastEntryEnabled, | |||
| formProps, | |||
| leaveRecords, | |||
| onClose, | |||
| username, | |||
| ], | |||
| ); | |||
| const onCancel = useCallback(() => { | |||
| @@ -165,6 +176,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||
| assignedProjects={assignedProjects} | |||
| allProjects={allProjects} | |||
| leaveRecords={leaveRecords} | |||
| fastEntryEnabled={fastEntryEnabled} | |||
| /> | |||
| </Box> | |||
| {errorComponent} | |||
| @@ -202,6 +214,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||
| {t("Timesheet Input")} | |||
| </Typography> | |||
| <MobileTimesheetTable | |||
| fastEntryEnabled={fastEntryEnabled} | |||
| companyHolidays={companyHolidays} | |||
| assignedProjects={assignedProjects} | |||
| allProjects={allProjects} | |||
| @@ -35,6 +35,7 @@ import { | |||
| validateTimeEntry, | |||
| } from "@/app/api/timesheets/utils"; | |||
| import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; | |||
| import FastTimeEntryModal from "./FastTimeEntryModal"; | |||
| dayjs.extend(isBetween); | |||
| @@ -43,6 +44,7 @@ interface Props { | |||
| isHoliday: boolean; | |||
| allProjects: ProjectWithTasks[]; | |||
| assignedProjects: AssignedProject[]; | |||
| fastEntryEnabled?: boolean; | |||
| } | |||
| export type TimeEntryRow = Partial< | |||
| @@ -58,6 +60,7 @@ const EntryInputTable: React.FC<Props> = ({ | |||
| allProjects, | |||
| assignedProjects, | |||
| isHoliday, | |||
| fastEntryEnabled, | |||
| }) => { | |||
| const { t } = useTranslation("home"); | |||
| const taskGroupsByProject = useMemo(() => { | |||
| @@ -114,7 +117,9 @@ const EntryInputTable: React.FC<Props> = ({ | |||
| "", | |||
| ) as TimeEntryRow; | |||
| const error = validateTimeEntry(row, isHoliday); | |||
| const error = validateTimeEntry(row, isHoliday, { | |||
| skipTaskValidation: fastEntryEnabled, | |||
| }); | |||
| // Test for warnings | |||
| let isPlanned; | |||
| @@ -133,7 +138,7 @@ const EntryInputTable: React.FC<Props> = ({ | |||
| apiRef.current.updateRows([{ id, _error: error, isPlanned }]); | |||
| return !error; | |||
| }, | |||
| [apiRef, day, isHoliday, milestonesByProject], | |||
| [apiRef, day, fastEntryEnabled, isHoliday, milestonesByProject], | |||
| ); | |||
| const handleCancel = useCallback( | |||
| @@ -230,6 +235,7 @@ const EntryInputTable: React.FC<Props> = ({ | |||
| renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) { | |||
| return ( | |||
| <ProjectSelect | |||
| multiple={false} | |||
| allProjects={allProjects} | |||
| assignedProjects={assignedProjects} | |||
| value={params.value} | |||
| @@ -406,6 +412,19 @@ const EntryInputTable: React.FC<Props> = ({ | |||
| (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 = ( | |||
| <Box display="flex" gap={2} alignItems="center"> | |||
| <Button | |||
| @@ -417,6 +436,15 @@ const EntryInputTable: React.FC<Props> = ({ | |||
| > | |||
| {t("Record time")} | |||
| </Button> | |||
| <Button | |||
| disableRipple | |||
| variant="outlined" | |||
| startIcon={<Add />} | |||
| onClick={openFastEntryModal} | |||
| size="small" | |||
| > | |||
| {t("Fast time entry")} | |||
| </Button> | |||
| {hasOutOfPlannedStages && ( | |||
| <Typography color="warning.main" variant="body2"> | |||
| {t("There are entries for stages out of planned dates!")} | |||
| @@ -426,49 +454,61 @@ const EntryInputTable: React.FC<Props> = ({ | |||
| ); | |||
| 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 { 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 React, { useCallback, useMemo, useState } from "react"; | |||
| import { useFormContext } from "react-hook-form"; | |||
| @@ -20,12 +13,14 @@ import TimesheetEditModal, { | |||
| import TimeEntryCard from "./TimeEntryCard"; | |||
| import { HolidaysResult } from "@/app/api/holidays"; | |||
| import { getHolidayForDate } from "@/app/utils/holidayUtils"; | |||
| import FastTimeEntryModal from "./FastTimeEntryModal"; | |||
| interface Props { | |||
| date: string; | |||
| allProjects: ProjectWithTasks[]; | |||
| assignedProjects: AssignedProject[]; | |||
| companyHolidays: HolidaysResult[]; | |||
| fastEntryEnabled?: boolean; | |||
| } | |||
| const MobileTimesheetEntry: React.FC<Props> = ({ | |||
| @@ -33,6 +28,7 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||
| allProjects, | |||
| assignedProjects, | |||
| companyHolidays, | |||
| fastEntryEnabled, | |||
| }) => { | |||
| const { | |||
| t, | |||
| @@ -51,7 +47,8 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||
| const holiday = getHolidayForDate(date, companyHolidays); | |||
| 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); | |||
| // Edit modal | |||
| @@ -103,6 +100,22 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||
| [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 ( | |||
| <> | |||
| <Typography | |||
| @@ -149,11 +162,16 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||
| {t("Add some time entries!")} | |||
| </Typography> | |||
| )} | |||
| <Box> | |||
| <Stack alignItems={"flex-start"} spacing={1}> | |||
| <Button startIcon={<Add />} onClick={openEditModal()}> | |||
| {t("Record time")} | |||
| </Button> | |||
| </Box> | |||
| {fastEntryEnabled && ( | |||
| <Button startIcon={<Add />} onClick={openFastEntryModal}> | |||
| {t("Fast time entry")} | |||
| </Button> | |||
| )} | |||
| </Stack> | |||
| <TimesheetEditModal | |||
| allProjects={allProjects} | |||
| assignedProjects={assignedProjects} | |||
| @@ -161,8 +179,19 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||
| onClose={closeEditModal} | |||
| onSave={onSaveEntry} | |||
| isHoliday={Boolean(isHoliday)} | |||
| fastEntryEnabled={fastEntryEnabled} | |||
| {...editModalProps} | |||
| /> | |||
| {fastEntryEnabled && ( | |||
| <FastTimeEntryModal | |||
| allProjects={allProjects} | |||
| assignedProjects={assignedProjects} | |||
| open={fastEntryModalOpen} | |||
| isHoliday={Boolean(isHoliday)} | |||
| onClose={closeFastEntryModal} | |||
| onSave={onSaveFastEntry} | |||
| /> | |||
| )} | |||
| </Box> | |||
| </> | |||
| ); | |||
| @@ -15,6 +15,7 @@ interface Props { | |||
| leaveRecords: RecordLeaveInput; | |||
| companyHolidays: HolidaysResult[]; | |||
| errorComponent?: React.ReactNode; | |||
| fastEntryEnabled?: boolean; | |||
| } | |||
| const MobileTimesheetTable: React.FC<Props> = ({ | |||
| @@ -23,6 +24,7 @@ const MobileTimesheetTable: React.FC<Props> = ({ | |||
| leaveRecords, | |||
| companyHolidays, | |||
| errorComponent, | |||
| fastEntryEnabled, | |||
| }) => { | |||
| const { watch } = useFormContext<RecordTimesheetInput>(); | |||
| const currentInput = watch(); | |||
| @@ -35,7 +37,12 @@ const MobileTimesheetTable: React.FC<Props> = ({ | |||
| leaveEntries={leaveRecords} | |||
| timesheetEntries={currentInput} | |||
| EntryComponent={MobileTimesheetEntry} | |||
| entryComponentProps={{ allProjects, assignedProjects, companyHolidays }} | |||
| entryComponentProps={{ | |||
| allProjects, | |||
| assignedProjects, | |||
| companyHolidays, | |||
| fastEntryEnabled, | |||
| }} | |||
| errorComponent={errorComponent} | |||
| /> | |||
| ); | |||
| @@ -1,6 +1,8 @@ | |||
| import React, { useCallback, useMemo } from "react"; | |||
| import { | |||
| Autocomplete, | |||
| Checkbox, | |||
| Chip, | |||
| ListSubheader, | |||
| MenuItem, | |||
| TextField, | |||
| @@ -8,15 +10,30 @@ import { | |||
| import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import differenceBy from "lodash/differenceBy"; | |||
| import intersectionWith from "lodash/intersectionWith"; | |||
| import { TFunction } from "i18next"; | |||
| interface Props { | |||
| interface CommonProps { | |||
| allProjects: ProjectWithTasks[]; | |||
| assignedProjects: AssignedProject[]; | |||
| error?: boolean; | |||
| multiple?: boolean; | |||
| } | |||
| interface SingleAutocompleteProps extends CommonProps { | |||
| value: number | undefined; | |||
| 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 => { | |||
| switch (groupName) { | |||
| case "non-billable": | |||
| @@ -37,6 +54,8 @@ const AutocompleteProjectSelect: React.FC<Props> = ({ | |||
| assignedProjects, | |||
| value, | |||
| onProjectSelect, | |||
| error, | |||
| multiple, | |||
| }) => { | |||
| const { t } = useTranslation("home"); | |||
| const nonAssignedProjects = useMemo(() => { | |||
| @@ -63,17 +82,32 @@ const AutocompleteProjectSelect: React.FC<Props> = ({ | |||
| ]; | |||
| }, [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( | |||
| (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 ( | |||
| <Autocomplete | |||
| multiple={multiple} | |||
| noOptionsText={t("No projects")} | |||
| disableClearable | |||
| fullWidth | |||
| @@ -82,22 +116,56 @@ const AutocompleteProjectSelect: React.FC<Props> = ({ | |||
| groupBy={(option) => option.group} | |||
| getOptionLabel={(option) => option.label} | |||
| 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) => ( | |||
| <> | |||
| <ListSubheader key={params.key}> | |||
| {getGroupName(t, params.group)} | |||
| </ListSubheader> | |||
| <React.Fragment key={`${params.key}-${params.group}`}> | |||
| <ListSubheader>{getGroupName(t, params.group)}</ListSubheader> | |||
| {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 ( | |||
| <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} | |||
| </MenuItem> | |||
| ); | |||
| }} | |||
| renderInput={(params) => <TextField {...params} />} | |||
| renderInput={(params) => <TextField {...params} error={error} />} | |||
| /> | |||
| ); | |||
| }; | |||
| @@ -34,6 +34,7 @@ export interface Props extends Omit<ModalProps, "children"> { | |||
| modalSx?: SxProps; | |||
| recordDate?: string; | |||
| isHoliday?: boolean; | |||
| fastEntryEnabled?: boolean; | |||
| } | |||
| const modalSx: SxProps = { | |||
| @@ -59,6 +60,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||
| modalSx: mSx, | |||
| recordDate, | |||
| isHoliday, | |||
| fastEntryEnabled, | |||
| }) => { | |||
| const { | |||
| t, | |||
| @@ -135,6 +137,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||
| name="projectId" | |||
| render={({ field }) => ( | |||
| <ProjectSelect | |||
| multiple={false} | |||
| allProjects={allProjects} | |||
| assignedProjects={assignedProjects} | |||
| value={field.value} | |||
| @@ -173,6 +176,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||
| if (!projectId) { | |||
| return !id; | |||
| } | |||
| if (fastEntryEnabled) return true; | |||
| const taskGroups = taskGroupsByProject[projectId]; | |||
| return taskGroups.some((tg) => tg.value === id); | |||
| }, | |||
| @@ -202,6 +206,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||
| if (!projectId) { | |||
| return !id; | |||
| } | |||
| if (fastEntryEnabled) return true; | |||
| const projectTasks = allProjects.find((p) => p.id === projectId) | |||
| ?.tasks; | |||
| return Boolean(projectTasks?.some((task) => task.id === id)); | |||
| @@ -14,6 +14,7 @@ interface Props { | |||
| assignedProjects: AssignedProject[]; | |||
| leaveRecords: RecordLeaveInput; | |||
| companyHolidays: HolidaysResult[]; | |||
| fastEntryEnabled?: boolean; | |||
| } | |||
| const TimesheetTable: React.FC<Props> = ({ | |||
| @@ -21,6 +22,7 @@ const TimesheetTable: React.FC<Props> = ({ | |||
| assignedProjects, | |||
| leaveRecords, | |||
| companyHolidays, | |||
| fastEntryEnabled, | |||
| }) => { | |||
| const { watch } = useFormContext<RecordTimesheetInput>(); | |||
| const currentInput = watch(); | |||
| @@ -33,7 +35,7 @@ const TimesheetTable: React.FC<Props> = ({ | |||
| leaveEntries={leaveRecords} | |||
| timesheetEntries={currentInput} | |||
| EntryTableComponent={EntryInputTable} | |||
| entryTableProps={{ assignedProjects, allProjects }} | |||
| entryTableProps={{ assignedProjects, allProjects, fastEntryEnabled }} | |||
| /> | |||
| ); | |||
| }; | |||
| @@ -35,6 +35,7 @@ export interface Props { | |||
| defaultTimesheets: RecordTimesheetInput; | |||
| holidays: HolidaysResult[]; | |||
| teamTimesheets: TeamTimeSheets; | |||
| fastEntryEnabled?: boolean; | |||
| } | |||
| const menuItemSx: SxProps = { | |||
| @@ -51,6 +52,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| defaultTimesheets, | |||
| holidays, | |||
| teamTimesheets, | |||
| fastEntryEnabled, | |||
| }) => { | |||
| const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | |||
| @@ -170,6 +172,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| leaveTypes={leaveTypes} | |||
| /> | |||
| <TimesheetModal | |||
| fastEntryEnabled={fastEntryEnabled} | |||
| companyHolidays={holidays} | |||
| isOpen={isTimeheetModalVisible} | |||
| onClose={handleCloseTimesheetModal} | |||
| @@ -44,6 +44,8 @@ const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => { | |||
| defaultLeaveRecords={leaves} | |||
| leaveTypes={leaveTypes} | |||
| holidays={holidays} | |||
| // Change to access check | |||
| fastEntryEnabled={true} | |||
| /> | |||
| ); | |||
| }; | |||