Pārlūkot izejas kodu

Add validation for leave and limit projects with fast entry

tags/Baseline_30082024_FRONTEND_UAT
Wayne pirms 1 gada
vecāks
revīzija
353fc53e22
11 mainītis faili ar 166 papildinājumiem un 71 dzēšanām
  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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

@@ -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 Parādīt failu

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


Notiek ielāde…
Atcelt
Saglabāt