|
- 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<Props> = ({
- 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<TeamTimeSheets[0], MemberOption[]>(
- localTeamTimesheets,
- (acc, memberTimesheet, id) => {
- const leaves = localTeamLeaves[parseInt(id)];
- return acc.push({
- ...leaves,
- ...memberTimesheet,
- id,
- });
- },
- [],
- );
- }, [localTeamLeaves, localTeamTimesheets]);
-
- const [selectedStaff, setSelectedStaff] = useState<MemberOption>(
- allMembers[0],
- );
- useEffect(() => {
- setSelectedStaff(
- (currentStaff) =>
- allMembers.find((member) => member.id === currentStaff.id) ||
- allMembers[0],
- );
- }, [allMembers]);
-
- // edit modal related
- const [editModalProps, setEditModalProps] = useState<
- Partial<TimesheetEditModalProps>
- >({});
- 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<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 () => {
- 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 (
- <Stack spacing={2}>
- <Autocomplete
- sx={{ maxWidth: 400 }}
- noOptionsText={t("No team members")}
- value={selectedStaff}
- onChange={(_, value) => {
- if (value) setSelectedStaff(value);
- }}
- options={allMembers}
- isOptionEqualToValue={(option, value) => option.id === value.id}
- getOptionLabel={(option) => `${option.staffId} - ${option.name}`}
- renderInput={(params) => <TextField {...params} />}
- />
- <StyledFullCalendar
- plugins={[dayGridPlugin, interactionPlugin]}
- initialView="dayGridMonth"
- buttonText={{ today: t("Today") }}
- events={[...holidays, ...timeEntries, ...leaveEntries]}
- eventClick={handleEventClick}
- dateClick={handleDateClick}
- />
- <TimesheetEditModal
- modalSx={{ maxWidth: 400 }}
- allProjects={allProjects}
- assignedProjects={[]}
- open={editModalOpen}
- onClose={closeEditModal}
- onSave={handleSave}
- {...editModalProps}
- />
- <LeaveEditModal
- modalSx={{ maxWidth: 400 }}
- leaveTypes={leaveTypes}
- open={leaveEditModalOpen}
- onClose={closeLeaveEditModal}
- onSave={handleSaveLeave}
- {...leaveEditModalProps}
- />
- </Stack>
- );
- };
-
- export default TimesheetAmendment;
|