diff --git a/src/app/api/timesheets/utils.ts b/src/app/api/timesheets/utils.ts index 7a6dad3..81b543d 100644 --- a/src/app/api/timesheets/utils.ts +++ b/src/app/api/timesheets/utils.ts @@ -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, 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"; diff --git a/src/app/utils/manhourUtils.ts b/src/app/utils/manhourUtils.ts index 37914ba..a1f681c 100644 --- a/src/app/utils/manhourUtils.ts +++ b/src/app/utils/manhourUtils.ts @@ -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); +}; diff --git a/src/components/TimesheetModal/TimesheetModal.tsx b/src/components/TimesheetModal/TimesheetModal.tsx index 66ccd76..439f027 100644 --- a/src/components/TimesheetModal/TimesheetModal.tsx +++ b/src/components/TimesheetModal/TimesheetModal.tsx @@ -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 = ({ defaultTimesheets, leaveRecords, companyHolidays, + fastEntryEnabled, }) => { const { t } = useTranslation("home"); @@ -83,7 +85,9 @@ const TimesheetModal: React.FC = ({ const onSubmit = useCallback>( 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 = ({ 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 = ({ assignedProjects={assignedProjects} allProjects={allProjects} leaveRecords={leaveRecords} + fastEntryEnabled={fastEntryEnabled} /> {errorComponent} @@ -202,6 +214,7 @@ const TimesheetModal: React.FC = ({ {t("Timesheet Input")} = ({ allProjects, assignedProjects, isHoliday, + fastEntryEnabled, }) => { const { t } = useTranslation("home"); const taskGroupsByProject = useMemo(() => { @@ -114,7 +117,9 @@ const EntryInputTable: React.FC = ({ "", ) 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 = ({ 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 = ({ renderEditCell(params: GridRenderEditCellParams) { return ( = ({ (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 = ( + {hasOutOfPlannedStages && ( {t("There are entries for stages out of planned dates!")} @@ -426,49 +454,61 @@ const EntryInputTable: React.FC = ({ ); return ( - ) => { - 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 }, - }} - /> + <> + ) => { + 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 && ( + + )} + ); }; diff --git a/src/components/TimesheetTable/FastTimeEntryModal.tsx b/src/components/TimesheetTable/FastTimeEntryModal.tsx new file mode 100644 index 0000000..37ec8b6 --- /dev/null +++ b/src/components/TimesheetTable/FastTimeEntryModal.tsx @@ -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 { + onSave: (timeEntries: TimeEntry[], recordDate?: string) => Promise; + 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 = ({ + onSave, + open, + onClose, + allProjects, + assignedProjects, + modalSx: mSx, + recordDate, + isHoliday, +}) => { + const { + t, + i18n: { language }, + } = useTranslation("home"); + + const { register, control, reset, trigger, formState, watch } = + useForm({ + 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>( + (...args) => { + onClose?.(...args); + reset(); + }, + [onClose, reset], + ); + + return ( + + + {recordDate && ( + + {shortDateFormatter(language).format(new Date(recordDate))} + + )} + + {t("Project Code and Name")} + ( + { + field.onChange( + newIds.map((id) => (id === "" ? undefined : id)), + ); + }} + /> + )} + rules={{ + validate: (value) => + value.length > 0 || t("Please choose at least 1 project."), + }} + /> + + {formState.errors.projectIds?.message || + t( + "The inputted time will be evenly distributed among the selected projects.", + )} + + + 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} + /> + roundToNearestQuarter(parseFloat(value)), + validate: (value) => (value ? value > 0 : true), + })} + error={Boolean(formState.errors.otHours)} + /> + + 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") + } + /> + + }> + + {t("Hour distribution preview")} + + + + {projectIds.length > 0 ? ( + + ) : ( + + {t("Please select some projects.")} + + )} + + + + + + + + ); +}; + +const ProjectHourSummary: React.FC<{ + projectsWithHours: [ProjectWithTasks?, number?, number?][]; +}> = ({ projectsWithHours }) => { + const { t } = useTranslation("home"); + + return ( + + {projectsWithHours.map(([project, manhour, otManhour], index) => { + return ( + + + {project + ? `${project.code} - ${project.name}` + : t("Non-billable Task")} + + + + + {t("Hours")} + + + {manhourFormatter.format(manhour || 0)} + + + + + {t("Other Hours")} + + + {manhourFormatter.format(otManhour || 0)} + + + + + ); + })} + + ); +}; + +export default FastTimeEntryModal; diff --git a/src/components/TimesheetTable/MobileTimesheetEntry.tsx b/src/components/TimesheetTable/MobileTimesheetEntry.tsx index a5eab75..eeefbde 100644 --- a/src/components/TimesheetTable/MobileTimesheetEntry.tsx +++ b/src/components/TimesheetTable/MobileTimesheetEntry.tsx @@ -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 = ({ @@ -33,6 +28,7 @@ const MobileTimesheetEntry: React.FC = ({ allProjects, assignedProjects, companyHolidays, + fastEntryEnabled, }) => { const { t, @@ -51,7 +47,8 @@ const MobileTimesheetEntry: React.FC = ({ const holiday = getHolidayForDate(date, companyHolidays); const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; - const { watch, setValue, clearErrors } = useFormContext(); + const { watch, setValue, clearErrors } = + useFormContext(); const currentEntries = watch(date); // Edit modal @@ -103,6 +100,22 @@ const MobileTimesheetEntry: React.FC = ({ [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 ( <> = ({ {t("Add some time entries!")} )} - + - + {fastEntryEnabled && ( + + )} + = ({ onClose={closeEditModal} onSave={onSaveEntry} isHoliday={Boolean(isHoliday)} + fastEntryEnabled={fastEntryEnabled} {...editModalProps} /> + {fastEntryEnabled && ( + + )} ); diff --git a/src/components/TimesheetTable/MobileTimesheetTable.tsx b/src/components/TimesheetTable/MobileTimesheetTable.tsx index f09306f..bab4351 100644 --- a/src/components/TimesheetTable/MobileTimesheetTable.tsx +++ b/src/components/TimesheetTable/MobileTimesheetTable.tsx @@ -15,6 +15,7 @@ interface Props { leaveRecords: RecordLeaveInput; companyHolidays: HolidaysResult[]; errorComponent?: React.ReactNode; + fastEntryEnabled?: boolean; } const MobileTimesheetTable: React.FC = ({ @@ -23,6 +24,7 @@ const MobileTimesheetTable: React.FC = ({ leaveRecords, companyHolidays, errorComponent, + fastEntryEnabled, }) => { const { watch } = useFormContext(); const currentInput = watch(); @@ -35,7 +37,12 @@ const MobileTimesheetTable: React.FC = ({ leaveEntries={leaveRecords} timesheetEntries={currentInput} EntryComponent={MobileTimesheetEntry} - entryComponentProps={{ allProjects, assignedProjects, companyHolidays }} + entryComponentProps={{ + allProjects, + assignedProjects, + companyHolidays, + fastEntryEnabled, + }} errorComponent={errorComponent} /> ); diff --git a/src/components/TimesheetTable/ProjectSelect.tsx b/src/components/TimesheetTable/ProjectSelect.tsx index c79e89e..a90b120 100644 --- a/src/components/TimesheetTable/ProjectSelect.tsx +++ b/src/components/TimesheetTable/ProjectSelect.tsx @@ -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) => 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 = ({ assignedProjects, value, onProjectSelect, + error, + multiple, }) => { const { t } = useTranslation("home"); const nonAssignedProjects = useMemo(() => { @@ -63,17 +82,32 @@ const AutocompleteProjectSelect: React.FC = ({ ]; }, [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 ( = ({ 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 ( + + ); + }) + : undefined + } renderGroup={(params) => ( - <> - - {getGroupName(t, params.group)} - + + {getGroupName(t, params.group)} {params.children} - + )} - renderOption={(params, option) => { + renderOption={( + params: React.HTMLAttributes & { key?: React.Key }, + option, + { selected }, + ) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { key, ...rest } = params; return ( - + + {multiple && ( + + )} {option.label} ); }} - renderInput={(params) => } + renderInput={(params) => } /> ); }; diff --git a/src/components/TimesheetTable/TimesheetEditModal.tsx b/src/components/TimesheetTable/TimesheetEditModal.tsx index 6fe33d5..3319bf4 100644 --- a/src/components/TimesheetTable/TimesheetEditModal.tsx +++ b/src/components/TimesheetTable/TimesheetEditModal.tsx @@ -34,6 +34,7 @@ export interface Props extends Omit { modalSx?: SxProps; recordDate?: string; isHoliday?: boolean; + fastEntryEnabled?: boolean; } const modalSx: SxProps = { @@ -59,6 +60,7 @@ const TimesheetEditModal: React.FC = ({ modalSx: mSx, recordDate, isHoliday, + fastEntryEnabled, }) => { const { t, @@ -135,6 +137,7 @@ const TimesheetEditModal: React.FC = ({ name="projectId" render={({ field }) => ( = ({ 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 = ({ 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)); diff --git a/src/components/TimesheetTable/TimesheetTable.tsx b/src/components/TimesheetTable/TimesheetTable.tsx index 3b4bfff..02f91df 100644 --- a/src/components/TimesheetTable/TimesheetTable.tsx +++ b/src/components/TimesheetTable/TimesheetTable.tsx @@ -14,6 +14,7 @@ interface Props { assignedProjects: AssignedProject[]; leaveRecords: RecordLeaveInput; companyHolidays: HolidaysResult[]; + fastEntryEnabled?: boolean; } const TimesheetTable: React.FC = ({ @@ -21,6 +22,7 @@ const TimesheetTable: React.FC = ({ assignedProjects, leaveRecords, companyHolidays, + fastEntryEnabled, }) => { const { watch } = useFormContext(); const currentInput = watch(); @@ -33,7 +35,7 @@ const TimesheetTable: React.FC = ({ leaveEntries={leaveRecords} timesheetEntries={currentInput} EntryTableComponent={EntryInputTable} - entryTableProps={{ assignedProjects, allProjects }} + entryTableProps={{ assignedProjects, allProjects, fastEntryEnabled }} /> ); }; diff --git a/src/components/UserWorkspacePage/UserWorkspacePage.tsx b/src/components/UserWorkspacePage/UserWorkspacePage.tsx index 7bbf537..ff1030c 100644 --- a/src/components/UserWorkspacePage/UserWorkspacePage.tsx +++ b/src/components/UserWorkspacePage/UserWorkspacePage.tsx @@ -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 = ({ defaultTimesheets, holidays, teamTimesheets, + fastEntryEnabled, }) => { const [anchorEl, setAnchorEl] = useState(null); @@ -170,6 +172,7 @@ const UserWorkspacePage: React.FC = ({ leaveTypes={leaveTypes} /> = async ({ username }) => { defaultLeaveRecords={leaves} leaveTypes={leaveTypes} holidays={holidays} + // Change to access check + fastEntryEnabled={true} /> ); };