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