@@ -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} | |||||
/> | /> | ||||
); | ); | ||||
}; | }; | ||||