|
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420 |
- 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;
|