Pārlūkot izejas kodu

Leave calendar

tags/Baseline_30082024_FRONTEND_UAT
Wayne pirms 1 gada
vecāks
revīzija
df00549d6e
3 mainītis faili ar 342 papildinājumiem un 185 dzēšanām
  1. +274
    -0
      src/components/LeaveModal/LeaveCalendar.tsx
  2. +43
    -184
      src/components/LeaveModal/LeaveModal.tsx
  3. +25
    -1
      src/components/UserWorkspacePage/UserWorkspacePage.tsx

+ 274
- 0
src/components/LeaveModal/LeaveCalendar.tsx Parādīt failu

@@ -0,0 +1,274 @@
import React, { useCallback, useMemo, useState } from "react";

import { HolidaysResult } from "@/app/api/holidays";
import { LeaveType } from "@/app/api/timesheets";
import dayGridPlugin from "@fullcalendar/daygrid";
import interactionPlugin from "@fullcalendar/interaction";
import { Box, useTheme } from "@mui/material";
import { useTranslation } from "react-i18next";
import {
getHolidayForDate,
getPublicHolidaysForNYears,
} from "@/app/utils/holidayUtils";
import {
INPUT_DATE_FORMAT,
convertDateArrayToString,
} from "@/app/utils/formatUtil";
import StyledFullCalendar from "../StyledFullCalendar";
import { ProjectWithTasks } from "@/app/api/projects";
import {
LeaveEntry,
RecordLeaveInput,
RecordTimesheetInput,
saveLeave,
} from "@/app/api/timesheets/actions";
import { Props as LeaveEditModalProps } from "../LeaveTable/LeaveEditModal";
import LeaveEditModal from "../LeaveTable/LeaveEditModal";
import dayjs from "dayjs";
import { checkTotalHours } from "@/app/api/timesheets/utils";
import unionBy from "lodash/unionBy";

export interface Props {
leaveTypes: LeaveType[];
companyHolidays: HolidaysResult[];
allProjects: ProjectWithTasks[];
leaveRecords: RecordLeaveInput;
timesheetRecords: RecordTimesheetInput;
}

interface EventClickArg {
event: {
start: Date | null;
startStr: string;
extendedProps: {
calendar?: string;
entry?: LeaveEntry;
};
};
}

const LeaveCalendar: React.FC<Props> = ({
companyHolidays,
allProjects,
leaveTypes,
timesheetRecords,
leaveRecords,
}) => {
const { t } = useTranslation(["home", "common"]);

const theme = useTheme();

const projectMap = useMemo(() => {
return allProjects.reduce<{
[id: ProjectWithTasks["id"]]: ProjectWithTasks;
}>((acc, project) => {
return { ...acc, [project.id]: project };
}, {});
}, [allProjects]);

const leaveMap = useMemo(() => {
return leaveTypes.reduce<{ [id: LeaveType["id"]]: string }>(
(acc, leaveType) => ({ ...acc, [leaveType.id]: leaveType.name }),
{},
);
}, [leaveTypes]);

const [localLeaveRecords, setLocalLeaveEntries] = useState(leaveRecords);

// leave edit modal related
const [leaveEditModalProps, setLeaveEditModalProps] = useState<
Partial<LeaveEditModalProps>
>({});
const [leaveEditModalOpen, setLeaveEditModalOpen] = useState(false);

const openLeaveEditModal = useCallback(
(defaultValues?: LeaveEntry, recordDate?: string, isHoliday?: boolean) => {
setLeaveEditModalProps({
defaultValues: defaultValues ? { ...defaultValues } : undefined,
recordDate,
isHoliday,
onDelete: defaultValues
? async () => {
if (!recordDate || !leaveRecords[recordDate]) {
return;
}
const leaveEntriesAtDate = leaveRecords[recordDate];
const newLeaveRecords = {
...leaveRecords,
[recordDate!]: leaveEntriesAtDate.filter(
(e) => e.id !== defaultValues.id,
),
};
const savedLeaveRecords = await saveLeave(newLeaveRecords);
setLocalLeaveEntries(savedLeaveRecords);
setLeaveEditModalOpen(false);
}
: undefined,
});
setLeaveEditModalOpen(true);
},
[leaveRecords],
);

const closeLeaveEditModal = useCallback(() => {
setLeaveEditModalOpen(false);
}, []);

// calendar related
const holidays = useMemo(() => {
return [
...getPublicHolidaysForNYears(2),
...companyHolidays.map((h) => ({
title: h.name,
date: convertDateArrayToString(h.date, INPUT_DATE_FORMAT),
extendedProps: {
calender: "holiday",
},
})),
].map((e) => ({
...e,
backgroundColor: theme.palette.error.main,
borderColor: theme.palette.error.main,
}));
}, [companyHolidays, theme.palette.error.main]);

const leaveEntries = useMemo(
() =>
Object.keys(localLeaveRecords).flatMap((date, index) => {
return localLeaveRecords[date].map((entry) => ({
id: `${date}-${index}-leave-${entry.id}`,
date,
title: `${t("{{count}} hour", {
ns: "common",
count: entry.inputHours || 0,
})} (${leaveMap[entry.leaveTypeId]})`,
backgroundColor: theme.palette.warning.light,
borderColor: theme.palette.warning.light,
textColor: theme.palette.text.primary,
extendedProps: {
calendar: "leaveEntry",
entry,
},
}));
}),
[leaveMap, localLeaveRecords, t, theme],
);

const timeEntries = useMemo(
() =>
Object.keys(timesheetRecords).flatMap((date, index) => {
return timesheetRecords[date].map((entry) => ({
id: `${date}-${index}-time-${entry.id}`,
date,
title: `${t("{{count}} hour", {
ns: "common",
count: (entry.inputHours || 0) + (entry.otHours || 0),
})} (${
entry.projectId
? projectMap[entry.projectId].code
: t("Non-billable task")
})`,
backgroundColor: theme.palette.info.main,
borderColor: theme.palette.info.main,
extendedProps: {
calendar: "timeEntry",
entry,
},
}));
}),
[projectMap, timesheetRecords, t, theme],
);

const handleEventClick = useCallback(
({ event }: EventClickArg) => {
const dayJsObj = dayjs(event.startStr);
const holiday = getHolidayForDate(event.startStr, companyHolidays);
const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6;

if (
event.extendedProps.calendar === "leaveEntry" &&
event.extendedProps.entry
) {
openLeaveEditModal(
event.extendedProps.entry as LeaveEntry,
event.startStr,
Boolean(isHoliday),
);
}
},
[companyHolidays, openLeaveEditModal],
);

const handleDateClick = useCallback(
(e: { dateStr: string; dayEl: HTMLElement }) => {
const dayJsObj = dayjs(e.dateStr);
const holiday = getHolidayForDate(e.dateStr, companyHolidays);
const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6;

openLeaveEditModal(undefined, e.dateStr, Boolean(isHoliday));
},
[companyHolidays, openLeaveEditModal],
);

const checkTotalHoursForDate = useCallback(
(newEntry: LeaveEntry, date?: string) => {
if (!date) {
throw Error("Invalid date");
}
const leaves = localLeaveRecords[date] || [];
const timesheets = timesheetRecords[date] || [];

const leavesWithNewEntry = unionBy(
[newEntry as LeaveEntry],
leaves,
"id",
);

const totalHourError = checkTotalHours(timesheets, leavesWithNewEntry);

if (totalHourError) throw Error(totalHourError);
},
[localLeaveRecords, timesheetRecords],
);

const handleSaveLeave = useCallback(
async (leaveEntry: LeaveEntry, recordDate?: string) => {
checkTotalHoursForDate(leaveEntry, recordDate);
const leaveEntriesAtDate = leaveRecords[recordDate!] || [];
const newLeaveRecords = {
...leaveRecords,
[recordDate!]: [
...leaveEntriesAtDate.filter((e) => e.id !== leaveEntry.id),
leaveEntry,
],
};
const savedLeaveRecords = await saveLeave(newLeaveRecords);
setLocalLeaveEntries(savedLeaveRecords);
setLeaveEditModalOpen(false);
},
[checkTotalHoursForDate, leaveRecords],
);

return (
<Box>
<StyledFullCalendar
plugins={[dayGridPlugin, interactionPlugin]}
initialView="dayGridMonth"
buttonText={{ today: t("Today") }}
events={[...holidays, ...timeEntries, ...leaveEntries]}
eventClick={handleEventClick}
dateClick={handleDateClick}
/>
<LeaveEditModal
modalSx={{ maxWidth: 400 }}
leaveTypes={leaveTypes}
open={leaveEditModalOpen}
onClose={closeLeaveEditModal}
onSave={handleSaveLeave}
{...leaveEditModalProps}
/>
</Box>
);
};

export default LeaveCalendar;

+ 43
- 184
src/components/LeaveModal/LeaveModal.tsx Parādīt failu

@@ -1,46 +1,16 @@
import React, { useCallback, useEffect, useMemo } from "react";
import useIsMobile from "@/app/utils/useIsMobile";
import React from "react";
import FullscreenModal from "../FullscreenModal";
import {
Box,
Button,
Card,
CardActions,
CardContent,
Modal,
ModalProps,
SxProps,
Typography,
} from "@mui/material";
import { useTranslation } from "react-i18next";
import { Check, Close } from "@mui/icons-material";
import { FormProvider, SubmitHandler, useForm } from "react-hook-form";
import {
RecordLeaveInput,
RecordTimesheetInput,
saveLeave,
} from "@/app/api/timesheets/actions";
import dayjs from "dayjs";
import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil";
import LeaveTable from "../LeaveTable";
import { LeaveType } from "@/app/api/timesheets";
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;
onClose: () => void;
defaultLeaveRecords?: RecordLeaveInput;
leaveTypes: LeaveType[];
timesheetRecords: RecordTimesheetInput;
companyHolidays: HolidaysResult[];
}
import LeaveCalendar, { Props as LeaveCalendarProps } from "./LeaveCalendar";

const modalSx: SxProps = {
position: "absolute",
@@ -52,167 +22,56 @@ const modalSx: SxProps = {
maxWidth: 1400,
};

interface Props extends LeaveCalendarProps {
open: boolean;
onClose: () => void;
}

const LeaveModal: React.FC<Props> = ({
isOpen,
open,
onClose,
defaultLeaveRecords,
timesheetRecords,
leaveTypes,
companyHolidays,
allProjects,
leaveRecords,
timesheetRecords,
}) => {
const { t } = useTranslation("home");
const isMobile = useIsMobile();

const defaultValues = useMemo(() => {
const today = dayjs();
return Array(7)
.fill(undefined)
.reduce<RecordLeaveInput>((acc, _, index) => {
const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT);
return {
...acc,
[date]: defaultLeaveRecords?.[date] ?? [],
};
}, {});
}, [defaultLeaveRecords]);

const formProps = useForm<RecordLeaveInput>({ defaultValues });
useEffect(() => {
formProps.reset(defaultValues);
}, [defaultValues, formProps]);

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

const today = dayjs();
const newFormValues = Array(7)
.fill(undefined)
.reduce<RecordLeaveInput>((acc, _, index) => {
const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT);
return {
...acc,
[date]: savedRecords[date] ?? [],
};
}, {});

formProps.reset(newFormValues);
onClose();
},
[companyHolidays, formProps, onClose, timesheetRecords],
);

const onCancel = useCallback(() => {
formProps.reset(defaultValues);
onClose();
}, [defaultValues, formProps, onClose]);

const onModalClose = useCallback<NonNullable<ModalProps["onClose"]>>(
(_, reason) => {
if (reason !== "backdropClick") {
onCancel();
}
},
[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 title = t("Record leave");
const content = (
<LeaveCalendar
leaveTypes={leaveTypes}
companyHolidays={companyHolidays}
allProjects={allProjects}
leaveRecords={leaveRecords}
timesheetRecords={timesheetRecords}
/>
);

const matches = useIsMobile();

return (
<FormProvider {...formProps}>
{!matches ? (
// Desktop version
<Modal open={isOpen} onClose={onModalClose}>
<Card sx={modalSx}>
<CardContent
component="form"
onSubmit={formProps.handleSubmit(onSubmit)}
>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{t("Record Leave")}
</Typography>
<Box
sx={{
marginInline: -3,
marginBlock: 4,
}}
>
<LeaveTable
companyHolidays={companyHolidays}
leaveTypes={leaveTypes}
timesheetRecords={timesheetRecords}
/>
</Box>
{errorComponent}
<CardActions sx={{ justifyContent: "flex-end" }}>
<Button
variant="outlined"
startIcon={<Close />}
onClick={onCancel}
>
{t("Cancel")}
</Button>
<Button variant="contained" startIcon={<Check />} type="submit">
{t("Save")}
</Button>
</CardActions>
</CardContent>
</Card>
</Modal>
) : (
// Mobile version
<FullscreenModal
open={isOpen}
onClose={onModalClose}
closeModal={onCancel}
>
<Box
display="flex"
flexDirection="column"
gap={2}
height="100%"
component="form"
onSubmit={formProps.handleSubmit(onSubmit)}
>
<Typography variant="h6" padding={2} flex="none">
{t("Record Leave")}
</Typography>
<MobileLeaveTable
companyHolidays={companyHolidays}
leaveTypes={leaveTypes}
timesheetRecords={timesheetRecords}
errorComponent={errorComponent}
/>
return isMobile ? (
<FullscreenModal open={open} onClose={onClose} closeModal={onClose}>
<Box display="flex" flexDirection="column" gap={2} height="100%">
<Typography variant="h6" flex="none" padding={2}>
{title}
</Typography>
<Box paddingInline={2}>{content}</Box>
</Box>
</FullscreenModal>
) : (
<Modal open={open} onClose={onClose}>
<Card sx={modalSx}>
<CardContent>
<Typography variant="overline" display="block" marginBlockEnd={1}>
{title}
</Typography>
<Box maxHeight={900} overflow="scroll">
{content}
</Box>
</FullscreenModal>
)}
</FormProvider>
</CardContent>
</Card>
</Modal>
);
};



+ 25
- 1
src/components/UserWorkspacePage/UserWorkspacePage.tsx Parādīt failu

@@ -4,7 +4,7 @@ import React, { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import Button from "@mui/material/Button";
import Stack from "@mui/material/Stack";
import { CalendarMonth, EditCalendar, MoreTime } from "@mui/icons-material";
import { CalendarMonth, EditCalendar, Luggage, MoreTime } from "@mui/icons-material";
import { Menu, MenuItem, SxProps, Typography } from "@mui/material";
import AssignedProjects from "./AssignedProjects";
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
@@ -19,6 +19,7 @@ import PastEntryCalendarModal from "../PastEntryCalendar/PastEntryCalendarModal"
import { HolidaysResult } from "@/app/api/holidays";
import { TimesheetAmendmentModal } from "../TimesheetAmendment/TimesheetAmendmentModal";
import TimeLeaveModal from "../TimeLeaveModal/TimeLeaveModal";
import LeaveModal from "../LeaveModal";

export interface Props {
leaveTypes: LeaveType[];
@@ -55,6 +56,7 @@ const UserWorkspacePage: React.FC<Props> = ({
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);

const [isTimeLeaveModalVisible, setTimeLeaveModalVisible] = useState(false);
const [isLeaveCalendarVisible, setLeaveCalendarVisible] = useState(false);
const [isPastEventModalVisible, setPastEventModalVisible] = useState(false);
const [isTimesheetAmendmentVisible, setisTimesheetAmendmentVisible] =
useState(false);
@@ -81,6 +83,15 @@ const UserWorkspacePage: React.FC<Props> = ({
setTimeLeaveModalVisible(false);
}, []);

const handleOpenLeaveCalendarButton = useCallback(() => {
setAnchorEl(null);
setLeaveCalendarVisible(true);
}, []);

const handleCloseLeaveCalendarButton = useCallback(() => {
setLeaveCalendarVisible(false);
}, []);

const handlePastEventClick = useCallback(() => {
setAnchorEl(null);
setPastEventModalVisible(true);
@@ -136,6 +147,10 @@ const UserWorkspacePage: React.FC<Props> = ({
<MoreTime />
{t("Enter Timesheet")}
</MenuItem>
<MenuItem onClick={handleOpenLeaveCalendarButton} sx={menuItemSx}>
<Luggage />
{t("Record Leave")}
</MenuItem>
<MenuItem onClick={handlePastEventClick} sx={menuItemSx}>
<CalendarMonth />
{t("View Past Entries")}
@@ -167,6 +182,15 @@ const UserWorkspacePage: React.FC<Props> = ({
timesheetRecords={defaultTimesheets}
leaveRecords={defaultLeaveRecords}
/>
<LeaveModal
open={isLeaveCalendarVisible}
onClose={handleCloseLeaveCalendarButton}
leaveTypes={leaveTypes}
companyHolidays={holidays}
allProjects={allProjects}
leaveRecords={defaultLeaveRecords}
timesheetRecords={defaultTimesheets}
/>
{assignedProjects.length > 0 ? (
<AssignedProjects
assignedProjects={assignedProjects}


Notiek ielāde…
Atcelt
Saglabāt