diff --git a/src/components/LeaveModal/LeaveCalendar.tsx b/src/components/LeaveModal/LeaveCalendar.tsx new file mode 100644 index 0000000..be3341b --- /dev/null +++ b/src/components/LeaveModal/LeaveCalendar.tsx @@ -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 = ({ + 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 + >({}); + 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 ( + + + + + ); +}; + +export default LeaveCalendar; diff --git a/src/components/LeaveModal/LeaveModal.tsx b/src/components/LeaveModal/LeaveModal.tsx index 531b1aa..7ae85ba 100644 --- a/src/components/LeaveModal/LeaveModal.tsx +++ b/src/components/LeaveModal/LeaveModal.tsx @@ -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 = ({ - 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((acc, _, index) => { - const date = today.subtract(index, "day").format(INPUT_DATE_FORMAT); - return { - ...acc, - [date]: defaultLeaveRecords?.[date] ?? [], - }; - }, {}); - }, [defaultLeaveRecords]); - - const formProps = useForm({ defaultValues }); - useEffect(() => { - formProps.reset(defaultValues); - }, [defaultValues, formProps]); - - const onSubmit = useCallback>( - 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((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>( - (_, reason) => { - if (reason !== "backdropClick") { - onCancel(); - } - }, - [onCancel], - ); - - const errorComponent = ( - { - 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 = ( + ); - const matches = useIsMobile(); - - return ( - - {!matches ? ( - // Desktop version - - - - - {t("Record Leave")} - - - - - {errorComponent} - - - - - - - - ) : ( - // Mobile version - - - - {t("Record Leave")} - - + return isMobile ? ( + + + + {title} + + {content} + + + ) : ( + + + + + {title} + + + {content} - - )} - + + + ); }; diff --git a/src/components/UserWorkspacePage/UserWorkspacePage.tsx b/src/components/UserWorkspacePage/UserWorkspacePage.tsx index 63ab31d..e92b39d 100644 --- a/src/components/UserWorkspacePage/UserWorkspacePage.tsx +++ b/src/components/UserWorkspacePage/UserWorkspacePage.tsx @@ -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 = ({ const [anchorEl, setAnchorEl] = useState(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 = ({ 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 = ({ {t("Enter Timesheet")} + + + {t("Record Leave")} + {t("View Past Entries")} @@ -167,6 +182,15 @@ const UserWorkspacePage: React.FC = ({ timesheetRecords={defaultTimesheets} leaveRecords={defaultLeaveRecords} /> + {assignedProjects.length > 0 ? (