Просмотр исходного кода

Add validation for leave and limit projects with fast entry

tags/Baseline_30082024_FRONTEND_UAT
Wayne 1 год назад
Родитель
Сommit
353fc53e22
11 измененных файлов: 166 добавлений и 71 удалений
  1. +54
    -17
      src/app/api/timesheets/utils.ts
  2. +14
    -10
      src/components/DateHoursTable/DateHoursList.tsx
  3. +15
    -9
      src/components/DateHoursTable/DateHoursTable.tsx
  4. +32
    -1
      src/components/LeaveModal/LeaveModal.tsx
  5. +4
    -2
      src/components/LeaveTable/LeaveEntryTable.tsx
  6. +5
    -3
      src/components/LeaveTable/MobileLeaveEntry.tsx
  7. +3
    -0
      src/components/LeaveTable/MobileLeaveTable.tsx
  8. +2
    -11
      src/components/TimesheetModal/TimesheetModal.tsx
  9. +2
    -4
      src/components/TimesheetTable/EntryInputTable.tsx
  10. +35
    -13
      src/components/TimesheetTable/FastTimeEntryModal.tsx
  11. +0
    -1
      src/components/TimesheetTable/ProjectSelect.tsx

+ 54
- 17
src/app/api/timesheets/utils.ts Просмотреть файл

@@ -13,10 +13,6 @@ 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
@@ -24,7 +20,6 @@ interface TimeEntryValidationOptions {
export const validateTimeEntry = (
entry: Partial<TimeEntry>,
isHoliday: boolean,
options: TimeEntryValidationOptions = {},
): TimeEntryError | undefined => {
// Test for errors
const error: TimeEntryError = {};
@@ -46,12 +41,10 @@ export const validateTimeEntry = (

// If there is a project id, there should also be taskGroupId, taskId, inputHours
if (entry.projectId) {
if (!options.skipTaskValidation) {
if (!entry.taskGroupId) {
error.taskGroupId = "Required";
} else if (!entry.taskId) {
error.taskId = "Required";
}
if (!entry.taskGroupId) {
error.taskGroupId = "Required";
} else if (!entry.taskId) {
error.taskId = "Required";
}
} else {
if (!entry.remark) {
@@ -78,7 +71,6 @@ export const validateTimesheet = (
timesheet: RecordTimesheetInput,
leaveRecords: RecordLeaveInput,
companyHolidays: HolidaysResult[],
options: TimeEntryValidationOptions = {},
): { [date: string]: string } | undefined => {
const errors: { [date: string]: string } = {};

@@ -94,7 +86,7 @@ export const validateTimesheet = (

// Check each entry
for (const entry of timeEntries) {
const entryErrors = validateTimeEntry(entry, holidays.has(date), options);
const entryErrors = validateTimeEntry(entry, holidays.has(date));

if (entryErrors) {
errors[date] = "There are errors in the entries";
@@ -107,7 +99,52 @@ export const validateTimesheet = (
const leaveHours =
leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0;

const totalNormalHours = timeEntries.reduce((acc, entry) => {
const totalInputHours = timeEntries.reduce((acc, entry) => {
return acc + (entry.inputHours || 0);
}, 0);

const totalOtHours = timeEntries.reduce((acc, entry) => {
return acc + (entry.otHours || 0);
}, 0);

if (totalInputHours + leaveHours > DAILY_NORMAL_MAX_HOURS) {
errors[date] =
"The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours or decrease the leave hours.";
} else if (
totalInputHours + totalOtHours + leaveHours >
TIMESHEET_DAILY_MAX_HOURS
) {
errors[date] =
"The daily total hours cannot be more than {{TIMESHEET_DAILY_MAX_HOURS}}";
}
});

return Object.keys(errors).length > 0 ? errors : undefined;
};

export const validateLeaveRecord = (
leaveRecords: RecordLeaveInput,
timesheet: RecordTimesheetInput,
): { [date: string]: string } | undefined => {
const errors: { [date: string]: string } = {};

Object.keys(leaveRecords).forEach((date) => {
const leaves = leaveRecords[date];

// Check each leave entry
for (const entry of leaves) {
const entryError = isValidLeaveEntry(entry);
if (entryError) {
errors[date] = "There are errors in the entries";
}
}

// Check total hours
const timeEntries = timesheet[date] || [];

const leaveHours = leaves.reduce((acc, entry) => acc + entry.inputHours, 0);

const totalInputHours = timeEntries.reduce((acc, entry) => {
return acc + (entry.inputHours || 0);
}, 0);

@@ -115,11 +152,11 @@ export const validateTimesheet = (
return acc + (entry.otHours || 0);
}, 0);

if (totalNormalHours > DAILY_NORMAL_MAX_HOURS) {
if (totalInputHours + leaveHours > DAILY_NORMAL_MAX_HOURS) {
errors[date] =
"The daily normal hours cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours.";
"The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours or decrease the leave hours.";
} else if (
totalNormalHours + totalOtHours + leaveHours >
totalInputHours + totalOtHours + leaveHours >
TIMESHEET_DAILY_MAX_HOURS
) {
errors[date] =


+ 14
- 10
src/components/DateHoursTable/DateHoursList.tsx Просмотреть файл

@@ -18,7 +18,6 @@ import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import {
DAILY_NORMAL_MAX_HOURS,
LEAVE_DAILY_MAX_HOURS,
TIMESHEET_DAILY_MAX_HOURS,
} from "@/app/api/timesheets/utils";
import { HolidaysResult } from "@/app/api/holidays";
@@ -101,8 +100,7 @@ function DateHoursList<EntryTableProps>({
const dailyTotal = leaveHours + timesheetHours;

const normalHoursExceeded =
timesheetNormalHours > DAILY_NORMAL_MAX_HOURS;
const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS;
timesheetNormalHours + leaveHours > DAILY_NORMAL_MAX_HOURS;
const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS;

return (
@@ -148,11 +146,12 @@ function DateHoursList<EntryTableProps>({
component="div"
width="100%"
variant="caption"
paddingInlineEnd="40%"
>
{t(
"The daily normal hours cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours.",
"The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}} (timesheet hours: {{timesheetNormalHours}}, leave hours: {{leaveHours}}). Please use other hours for exceeding hours or decrease the leave hours.",
{
timesheetNormalHours,
leaveHours,
DAILY_NORMAL_MAX_HOURS,
},
)}
@@ -165,7 +164,7 @@ function DateHoursList<EntryTableProps>({
justifyContent: "space-between",
flexWrap: "wrap",
alignItems: "baseline",
color: leaveExceeded ? "error.main" : undefined,
color: normalHoursExceeded ? "error.main" : undefined,
}}
>
<Typography variant="body2">
@@ -174,15 +173,20 @@ function DateHoursList<EntryTableProps>({
<Typography>
{manhourFormatter.format(leaveHours)}
</Typography>
{leaveExceeded && (
{normalHoursExceeded && (
<Typography
component="div"
width="100%"
variant="caption"
>
{t("Leave hours cannot be more than {{hours}}", {
hours: LEAVE_DAILY_MAX_HOURS,
})}
{t(
"The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}} (timesheet hours: {{timesheetNormalHours}}, leave hours: {{leaveHours}}). Please use other hours for exceeding hours or decrease the leave hours.",
{
timesheetNormalHours,
leaveHours,
DAILY_NORMAL_MAX_HOURS,
},
)}
</Typography>
)}
</Box>


+ 15
- 9
src/components/DateHoursTable/DateHoursTable.tsx Просмотреть файл

@@ -22,7 +22,6 @@ import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import {
DAILY_NORMAL_MAX_HOURS,
LEAVE_DAILY_MAX_HOURS,
TIMESHEET_DAILY_MAX_HOURS,
} from "@/app/api/timesheets/utils";
import { HolidaysResult } from "@/app/api/holidays";
@@ -121,8 +120,8 @@ function DayRow<EntryTableProps>({

const dailyTotal = leaveHours + timesheetHours;

const normalHoursExceeded = timesheetNormalHours > DAILY_NORMAL_MAX_HOURS;
const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS;
const normalHoursExceeded =
timesheetNormalHours + leaveHours > DAILY_NORMAL_MAX_HOURS;
const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS;

return (
@@ -158,8 +157,10 @@ function DayRow<EntryTableProps>({
{normalHoursExceeded && (
<Tooltip
title={t(
"The daily normal hours cannot be more than {{DAILY_NORMAL_MAX_HOURS}}. Please use other hours for exceeding hours.",
"The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}} (timesheet hours: {{timesheetNormalHours}}, leave hours: {{leaveHours}}). Please use other hours for exceeding hours or decrease the leave hours.",
{
timesheetNormalHours,
leaveHours,
DAILY_NORMAL_MAX_HOURS,
},
)}
@@ -172,16 +173,21 @@ function DayRow<EntryTableProps>({
{/* Leave total */}
<TableCell
sx={{
color: leaveExceeded ? "error.main" : undefined,
color: normalHoursExceeded ? "error.main" : undefined,
}}
>
<Box display="flex" gap={1} alignItems="center">
{manhourFormatter.format(leaveHours)}
{leaveExceeded && (
{normalHoursExceeded && (
<Tooltip
title={t("Leave hours cannot be more than {{hours}}", {
hours: LEAVE_DAILY_MAX_HOURS,
})}
title={t(
"The daily normal hours (timesheet hours + leave hours) cannot be more than {{DAILY_NORMAL_MAX_HOURS}} (timesheet hours: {{timesheetNormalHours}}, leave hours: {{leaveHours}}). Please use other hours for exceeding hours or decrease the leave hours.",
{
timesheetNormalHours,
leaveHours,
DAILY_NORMAL_MAX_HOURS,
},
)}
>
<Info fontSize="small" />
</Tooltip>


+ 32
- 1
src/components/LeaveModal/LeaveModal.tsx Просмотреть файл

@@ -26,6 +26,12 @@ import FullscreenModal from "../FullscreenModal";
import MobileLeaveTable from "../LeaveTable/MobileLeaveTable";
import useIsMobile from "@/app/utils/useIsMobile";
import { HolidaysResult } from "@/app/api/holidays";
import {
DAILY_NORMAL_MAX_HOURS,
TIMESHEET_DAILY_MAX_HOURS,
validateLeaveRecord,
} from "@/app/api/timesheets/utils";
import ErrorAlert from "../ErrorAlert";

interface Props {
isOpen: boolean;
@@ -75,6 +81,15 @@ const LeaveModal: React.FC<Props> = ({

const onSubmit = useCallback<SubmitHandler<RecordLeaveInput>>(
async (data) => {
const errors = validateLeaveRecord(data, timesheetRecords);
if (errors) {
Object.keys(errors).forEach((date) =>
formProps.setError(date, {
message: errors[date],
}),
);
return;
}
const savedRecords = await saveLeave(data, username);

const today = dayjs();
@@ -91,7 +106,7 @@ const LeaveModal: React.FC<Props> = ({
formProps.reset(newFormValues);
onClose();
},
[formProps, onClose, username],
[formProps, onClose, timesheetRecords, username],
);

const onCancel = useCallback(() => {
@@ -108,6 +123,20 @@ const LeaveModal: React.FC<Props> = ({
[onCancel],
);

const errorComponent = (
<ErrorAlert
errors={Object.keys(formProps.formState.errors).map((date) => {
const error = formProps.formState.errors[date]?.message;
return error
? `${date}: ${t(error, {
TIMESHEET_DAILY_MAX_HOURS,
DAILY_NORMAL_MAX_HOURS,
})}`
: undefined;
})}
/>
);

const matches = useIsMobile();

return (
@@ -135,6 +164,7 @@ const LeaveModal: React.FC<Props> = ({
timesheetRecords={timesheetRecords}
/>
</Box>
{errorComponent}
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button
variant="outlined"
@@ -172,6 +202,7 @@ const LeaveModal: React.FC<Props> = ({
companyHolidays={companyHolidays}
leaveTypes={leaveTypes}
timesheetRecords={timesheetRecords}
errorComponent={errorComponent}
/>
</Box>
</FullscreenModal>


+ 4
- 2
src/components/LeaveTable/LeaveEntryTable.tsx Просмотреть файл

@@ -42,7 +42,8 @@ type LeaveEntryRow = Partial<
const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => {
const { t } = useTranslation("home");

const { getValues, setValue } = useFormContext<RecordLeaveInput>();
const { getValues, setValue, clearErrors } =
useFormContext<RecordLeaveInput>();
const currentEntries = getValues(day);

const [entries, setEntries] = useState<LeaveEntryRow[]>(currentEntries || []);
@@ -207,7 +208,8 @@ const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => {
remark: e.remark,
})),
]);
}, [getValues, entries, setValue, day]);
clearErrors(day);
}, [getValues, entries, setValue, day, clearErrors]);

const footer = (
<Box display="flex" gap={2} alignItems="center">


+ 5
- 3
src/components/LeaveTable/MobileLeaveEntry.tsx Просмотреть файл

@@ -38,7 +38,7 @@ const MobileLeaveEntry: React.FC<Props> = ({
);
}, [leaveTypes]);

const { watch, setValue } = useFormContext<RecordLeaveInput>();
const { watch, setValue, clearErrors } = useFormContext<RecordLeaveInput>();
const currentEntries = watch(date);

// Edit modal
@@ -57,13 +57,14 @@ const MobileLeaveEntry: React.FC<Props> = ({
date,
currentEntries.filter((entry) => entry.id !== defaultValues.id),
);
clearErrors(date);
setEditModalOpen(false);
}
: undefined,
});
setEditModalOpen(true);
},
[currentEntries, date, setValue],
[clearErrors, currentEntries, date, setValue],
);

const closeEditModal = useCallback(() => {
@@ -80,12 +81,13 @@ const MobileLeaveEntry: React.FC<Props> = ({
...(e.id === existingEntry.id ? entry : e),
})),
);
clearErrors(date);
} else {
setValue(date, [...currentEntries, entry]);
}
setEditModalOpen(false);
},
[currentEntries, date, setValue],
[clearErrors, currentEntries, date, setValue],
);

return (


+ 3
- 0
src/components/LeaveTable/MobileLeaveTable.tsx Просмотреть файл

@@ -13,12 +13,14 @@ interface Props {
leaveTypes: LeaveType[];
timesheetRecords: RecordTimesheetInput;
companyHolidays: HolidaysResult[];
errorComponent?: React.ReactNode;
}

const MobileLeaveTable: React.FC<Props> = ({
timesheetRecords,
leaveTypes,
companyHolidays,
errorComponent,
}) => {
const { watch } = useFormContext<RecordLeaveInput>();
const currentInput = watch();
@@ -32,6 +34,7 @@ const MobileLeaveTable: React.FC<Props> = ({
timesheetEntries={timesheetRecords}
EntryComponent={MobileLeaveEntry}
entryComponentProps={{ leaveTypes, companyHolidays }}
errorComponent={errorComponent}
/>
);
};


+ 2
- 11
src/components/TimesheetModal/TimesheetModal.tsx Просмотреть файл

@@ -85,9 +85,7 @@ const TimesheetModal: React.FC<Props> = ({

const onSubmit = useCallback<SubmitHandler<RecordTimesheetInput>>(
async (data) => {
const errors = validateTimesheet(data, leaveRecords, companyHolidays, {
skipTaskValidation: fastEntryEnabled,
});
const errors = validateTimesheet(data, leaveRecords, companyHolidays);
if (errors) {
Object.keys(errors).forEach((date) =>
formProps.setError(date, {
@@ -112,14 +110,7 @@ const TimesheetModal: React.FC<Props> = ({
formProps.reset(newFormValues);
onClose();
},
[
companyHolidays,
fastEntryEnabled,
formProps,
leaveRecords,
onClose,
username,
],
[companyHolidays, formProps, leaveRecords, onClose, username],
);

const onCancel = useCallback(() => {


+ 2
- 4
src/components/TimesheetTable/EntryInputTable.tsx Просмотреть файл

@@ -117,9 +117,7 @@ const EntryInputTable: React.FC<Props> = ({
"",
) as TimeEntryRow;

const error = validateTimeEntry(row, isHoliday, {
skipTaskValidation: fastEntryEnabled,
});
const error = validateTimeEntry(row, isHoliday);

// Test for warnings
let isPlanned;
@@ -138,7 +136,7 @@ const EntryInputTable: React.FC<Props> = ({
apiRef.current.updateRows([{ id, _error: error, isPlanned }]);
return !error;
},
[apiRef, day, fastEntryEnabled, isHoliday, milestonesByProject],
[apiRef, day, isHoliday, milestonesByProject],
);

const handleCancel = useCallback(


+ 35
- 13
src/components/TimesheetTable/FastTimeEntryModal.tsx Просмотреть файл

@@ -29,6 +29,7 @@ import {
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil";
import { DAILY_NORMAL_MAX_HOURS } from "@/app/api/timesheets/utils";
import zip from "lodash/zip";
import intersectionBy from "lodash/intersectionBy";

export interface FastTimeEntryForm {
projectIds: TimeEntry["projectId"][];
@@ -66,6 +67,9 @@ const getID = () => {
return ++idOffset;
};

const MISC_TASK_GROUP_ID = 5;
const FAST_ENTRY_TASK_ID = 40;

const FastTimeEntryModal: React.FC<Props> = ({
onSave,
open,
@@ -81,6 +85,16 @@ const FastTimeEntryModal: React.FC<Props> = ({
i18n: { language },
} = useTranslation("home");

const allProjectsWithFastEntry = useMemo(() => {
return allProjects.filter((p) =>
p.tasks.find((t) => t.id === FAST_ENTRY_TASK_ID),
);
}, [allProjects]);

const allAssignedProjectsWithFastEntry = useMemo(() => {
return intersectionBy(assignedProjects, allProjectsWithFastEntry, "id");
}, [allProjectsWithFastEntry, assignedProjects]);

const { register, control, reset, trigger, formState, watch } =
useForm<FastTimeEntryForm>({
defaultValues: {
@@ -94,8 +108,10 @@ const FastTimeEntryModal: React.FC<Props> = ({
const remark = watch("remark");

const selectedProjects = useMemo(() => {
return projectIds.map((id) => allProjects.find((p) => p.id === id));
}, [allProjects, projectIds]);
return projectIds.map((id) =>
allProjectsWithFastEntry.find((p) => p.id === id),
);
}, [allProjectsWithFastEntry, projectIds]);

const normalHoursArray = distributeQuarters(
inputHours || 0,
@@ -116,13 +132,19 @@ const FastTimeEntryModal: React.FC<Props> = ({
const valid = await trigger();
if (valid) {
onSave(
projectsWithHours.map(([project, hour, othour]) => ({
id: getID(),
projectId: project?.id,
inputHours: hour,
otHours: othour,
remark,
})),
projectsWithHours.map(([project, hour, othour]) => {
const projectId = project?.id;

return {
id: getID(),
projectId,
inputHours: hour,
otHours: othour,
taskGroupId: projectId ? MISC_TASK_GROUP_ID : undefined,
taskId: projectId ? FAST_ENTRY_TASK_ID : undefined,
remark,
};
}),
recordDate,
);
reset();
@@ -154,8 +176,8 @@ const FastTimeEntryModal: React.FC<Props> = ({
<ProjectSelect
error={Boolean(formState.errors.projectIds)}
multiple
allProjects={allProjects}
assignedProjects={assignedProjects}
allProjects={allProjectsWithFastEntry}
assignedProjects={allAssignedProjectsWithFastEntry}
value={field.value}
onProjectSelect={(newIds) => {
field.onChange(
@@ -172,7 +194,7 @@ const FastTimeEntryModal: React.FC<Props> = ({
<FormHelperText>
{formState.errors.projectIds?.message ||
t(
"The inputted time will be evenly distributed among the selected projects.",
'The inputted time will be evenly distributed among the selected projects. Only projects with the "Management Timesheet Allocation" task can use the fast entry.',
)}
</FormHelperText>
</FormControl>
@@ -222,7 +244,7 @@ const FastTimeEntryModal: React.FC<Props> = ({
{...register("remark", {
validate: (value) =>
projectIds.every((id) => id) ||
value ||
Boolean(value) ||
t("Required for non-billable tasks"),
})}
helperText={


+ 0
- 1
src/components/TimesheetTable/ProjectSelect.tsx Просмотреть файл

@@ -87,7 +87,6 @@ const AutocompleteProjectSelect: React.FC<Props> = ({
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(
(


Загрузка…
Отмена
Сохранить