Browse Source

Fast time entry

tags/Baseline_30082024_FRONTEND_UAT
Wayne 1 year ago
parent
commit
b4f077da34
12 changed files with 582 additions and 80 deletions
  1. +13
    -5
      src/app/api/timesheets/utils.ts
  2. +16
    -0
      src/app/utils/manhourUtils.ts
  3. +15
    -2
      src/components/TimesheetModal/TimesheetModal.tsx
  4. +85
    -45
      src/components/TimesheetTable/EntryInputTable.tsx
  5. +309
    -0
      src/components/TimesheetTable/FastTimeEntryModal.tsx
  6. +42
    -13
      src/components/TimesheetTable/MobileTimesheetEntry.tsx
  7. +8
    -1
      src/components/TimesheetTable/MobileTimesheetTable.tsx
  8. +81
    -13
      src/components/TimesheetTable/ProjectSelect.tsx
  9. +5
    -0
      src/components/TimesheetTable/TimesheetEditModal.tsx
  10. +3
    -1
      src/components/TimesheetTable/TimesheetTable.tsx
  11. +3
    -0
      src/components/UserWorkspacePage/UserWorkspacePage.tsx
  12. +2
    -0
      src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx

+ 13
- 5
src/app/api/timesheets/utils.ts View File

@@ -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";


+ 16
- 0
src/app/utils/manhourUtils.ts View File

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

+ 15
- 2
src/components/TimesheetModal/TimesheetModal.tsx View File

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


+ 85
- 45
src/components/TimesheetTable/EntryInputTable.tsx View File

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



+ 309
- 0
src/components/TimesheetTable/FastTimeEntryModal.tsx View File

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

+ 42
- 13
src/components/TimesheetTable/MobileTimesheetEntry.tsx View File

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


+ 8
- 1
src/components/TimesheetTable/MobileTimesheetTable.tsx View File

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


+ 81
- 13
src/components/TimesheetTable/ProjectSelect.tsx View File

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


+ 5
- 0
src/components/TimesheetTable/TimesheetEditModal.tsx View File

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


+ 3
- 1
src/components/TimesheetTable/TimesheetTable.tsx View File

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


+ 3
- 0
src/components/UserWorkspacePage/UserWorkspacePage.tsx View File

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


+ 2
- 0
src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx View File

@@ -44,6 +44,8 @@ const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => {
defaultLeaveRecords={leaves}
leaveTypes={leaveTypes}
holidays={holidays}
// Change to access check
fastEntryEnabled={true}
/>
);
};


Loading…
Cancel
Save