import React, { useCallback, useEffect, useMemo, useState } from "react"; import { HolidaysResult } from "@/app/api/holidays"; import { LeaveType, TeamLeaves, TeamTimeSheets } from "@/app/api/timesheets"; import dayGridPlugin from "@fullcalendar/daygrid"; import interactionPlugin from "@fullcalendar/interaction"; import { Autocomplete, Stack, TextField, useTheme } from "@mui/material"; import { useTranslation } from "react-i18next"; import transform from "lodash/transform"; 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, TimeEntry, deleteMemberEntry, deleteMemberLeave, saveMemberEntry, saveMemberLeave, } from "@/app/api/timesheets/actions"; import TimesheetEditModal, { Props as TimesheetEditModalProps, } from "../TimesheetTable/TimesheetEditModal"; 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[]; teamLeaves: TeamLeaves; teamTimesheets: TeamTimeSheets; companyHolidays: HolidaysResult[]; allProjects: ProjectWithTasks[]; } type MemberOption = TeamTimeSheets[0] & TeamLeaves[0] & { id: string }; interface EventClickArg { event: { start: Date | null; startStr: string; extendedProps: { calendar?: string; entry?: TimeEntry | LeaveEntry; memberId?: string; }; }; } const TimesheetAmendment: React.FC = ({ teamTimesheets, teamLeaves, companyHolidays, allProjects, leaveTypes, }) => { 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]); // Use a local state to manage updates after a mutation const [localTeamTimesheets, setLocalTeamTimesheets] = useState(teamTimesheets); const [localTeamLeaves, setLocalTeamLeaves] = useState(teamLeaves); // member select const allMembers = useMemo(() => { return transform( localTeamTimesheets, (acc, memberTimesheet, id) => { const leaves = localTeamLeaves[parseInt(id)]; return acc.push({ ...leaves, ...memberTimesheet, id, }); }, [], ); }, [localTeamLeaves, localTeamTimesheets]); const [selectedStaff, setSelectedStaff] = useState( allMembers[0], ); useEffect(() => { setSelectedStaff( (currentStaff) => allMembers.find((member) => member.id === currentStaff.id) || allMembers[0], ); }, [allMembers]); // edit modal related const [editModalProps, setEditModalProps] = useState< Partial >({}); const [editModalOpen, setEditModalOpen] = useState(false); const openEditModal = useCallback( (defaultValues?: TimeEntry, recordDate?: string, isHoliday?: boolean) => { setEditModalProps({ defaultValues: defaultValues ? { ...defaultValues } : undefined, recordDate, isHoliday, onDelete: defaultValues ? async () => { const intStaffId = parseInt(selectedStaff.id); const newMemberTimesheets = await deleteMemberEntry({ staffId: intStaffId, entryId: defaultValues.id, }); setLocalTeamTimesheets((timesheets) => ({ ...timesheets, [intStaffId]: { ...timesheets[intStaffId], timeEntries: newMemberTimesheets, }, })); setEditModalOpen(false); } : undefined, }); setEditModalOpen(true); }, [selectedStaff.id], ); const closeEditModal = useCallback(() => { setEditModalOpen(false); }, []); // 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 () => { const intStaffId = parseInt(selectedStaff.id); const newMemberLeaves = await deleteMemberLeave({ staffId: intStaffId, entryId: defaultValues.id, }); setLocalTeamLeaves((leaves) => ({ ...leaves, [intStaffId]: { ...leaves[intStaffId], leaveEntries: newMemberLeaves, }, })); setLeaveEditModalOpen(false); } : undefined, }); setLeaveEditModalOpen(true); }, [selectedStaff.id], ); 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(selectedStaff.leaveEntries).flatMap((date, index) => { return selectedStaff.leaveEntries[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, memberId: selectedStaff.id, }, })); }), [leaveMap, selectedStaff, t, theme], ); const timeEntries = useMemo( () => Object.keys(selectedStaff.timeEntries).flatMap((date, index) => { return selectedStaff.timeEntries[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, memberId: selectedStaff.id, }, })); }), [projectMap, selectedStaff, 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 === "timeEntry" && event.extendedProps.entry ) { openEditModal( event.extendedProps.entry as TimeEntry, event.startStr, Boolean(isHoliday), ); } else if ( event.extendedProps.calendar === "leaveEntry" && event.extendedProps.entry ) { openLeaveEditModal( event.extendedProps.entry as LeaveEntry, event.startStr, Boolean(isHoliday), ); } }, [companyHolidays, openEditModal, openLeaveEditModal], ); const handleDateClick = useCallback( (e: { dateStr: string }) => { const dayJsObj = dayjs(e.dateStr); const holiday = getHolidayForDate(e.dateStr, companyHolidays); const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; openEditModal(undefined, e.dateStr, Boolean(isHoliday)); }, [companyHolidays, openEditModal], ); const checkTotalHoursForDate = useCallback( (newEntry: TimeEntry | LeaveEntry, date?: string) => { if (!date) { throw Error("Invalid date"); } const intStaffId = parseInt(selectedStaff.id); const leaves = localTeamLeaves[intStaffId].leaveEntries[date] || []; const timesheets = localTeamTimesheets[intStaffId].timeEntries[date] || []; let totalHourError; if ((newEntry as LeaveEntry).leaveTypeId) { // newEntry is a leave entry const leavesWithNewEntry = unionBy( [newEntry as LeaveEntry], leaves, "id", ); totalHourError = checkTotalHours(timesheets, leavesWithNewEntry); } else { // newEntry is a timesheet entry const timesheetsWithNewEntry = unionBy( [newEntry as TimeEntry], timesheets, "id", ); totalHourError = checkTotalHours(timesheetsWithNewEntry, leaves); } if (totalHourError) throw Error(totalHourError); }, [localTeamLeaves, localTeamTimesheets, selectedStaff.id], ); const handleSave = useCallback( async (timeEntry: TimeEntry, recordDate?: string) => { // TODO: should be fine, but can handle parse error const intStaffId = parseInt(selectedStaff.id); checkTotalHoursForDate(timeEntry, recordDate); const newMemberTimesheets = await saveMemberEntry({ staffId: intStaffId, entry: timeEntry, recordDate, }); setLocalTeamTimesheets((timesheets) => ({ ...timesheets, [intStaffId]: { ...timesheets[intStaffId], timeEntries: newMemberTimesheets, }, })); setEditModalOpen(false); }, [checkTotalHoursForDate, selectedStaff.id], ); const handleSaveLeave = useCallback( async (leaveEntry: LeaveEntry, recordDate?: string) => { const intStaffId = parseInt(selectedStaff.id); checkTotalHoursForDate(leaveEntry, recordDate); const newMemberLeaves = await saveMemberLeave({ staffId: intStaffId, recordDate, entry: leaveEntry, }); setLocalTeamLeaves((leaves) => ({ ...leaves, [intStaffId]: { ...leaves[intStaffId], leaveEntries: newMemberLeaves, }, })); setLeaveEditModalOpen(false); }, [checkTotalHoursForDate, selectedStaff.id], ); return ( { if (value) setSelectedStaff(value); }} options={allMembers} isOptionEqualToValue={(option, value) => option.id === value.id} getOptionLabel={(option) => `${option.staffId} - ${option.name}`} renderInput={(params) => } /> ); }; export default TimesheetAmendment;