| @@ -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; | |||
| @@ -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> | |||
| ); | |||
| }; | |||
| @@ -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} | |||