Ver a proveniência

Add validation for leave and limit projects with fast entry

tags/Baseline_30082024_FRONTEND_UAT
Wayne há 1 ano
ascendente
cometimento
353fc53e22
11 ficheiros alterados com 166 adições e 71 eliminações
  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 Ver ficheiro

@@ -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 Ver ficheiro

@@ -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 Ver ficheiro

@@ -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 Ver ficheiro

@@ -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 Ver ficheiro

@@ -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 Ver ficheiro

@@ -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 Ver ficheiro

@@ -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 Ver ficheiro

@@ -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 Ver ficheiro

@@ -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 Ver ficheiro

@@ -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 Ver ficheiro

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


Carregando…
Cancelar
Guardar