From 25af9ac3166b118ebb3dd4f7189d76210fd9d52e Mon Sep 17 00:00:00 2001 From: Wayne Date: Tue, 28 May 2024 23:52:03 +0900 Subject: [PATCH] Save team member leave entry --- src/app/(main)/home/page.tsx | 2 + src/app/api/timesheets/actions.ts | 15 ++ src/app/api/timesheets/index.ts | 14 ++ src/components/LeaveTable/LeaveEditModal.tsx | 30 +++- .../LeaveTable/MobileLeaveEntry.tsx | 2 +- .../TimesheetAmendment/TimesheetAmendment.tsx | 146 ++++++++++++++++-- .../TimesheetAmendmentModal.tsx | 4 + .../TimesheetTable/TimesheetEditModal.tsx | 8 +- .../UserWorkspacePage/UserWorkspacePage.tsx | 6 +- .../UserWorkspaceWrapper.tsx | 4 + 10 files changed, 209 insertions(+), 22 deletions(-) diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx index 3103988..87aaad6 100644 --- a/src/app/(main)/home/page.tsx +++ b/src/app/(main)/home/page.tsx @@ -4,6 +4,7 @@ import UserWorkspacePage from "@/components/UserWorkspacePage"; import { fetchLeaveTypes, fetchLeaves, + fetchTeamMemberLeaves, fetchTeamMemberTimesheets, fetchTimesheets, } from "@/app/api/timesheets"; @@ -31,6 +32,7 @@ const Home: React.FC = async () => { fetchProjectWithTasks(); fetchHolidays(); fetchTeamMemberTimesheets(username); + fetchTeamMemberLeaves(username); return ( diff --git a/src/app/api/timesheets/actions.ts b/src/app/api/timesheets/actions.ts index 106e2d4..5d4ecb0 100644 --- a/src/app/api/timesheets/actions.ts +++ b/src/app/api/timesheets/actions.ts @@ -79,6 +79,21 @@ export const saveMemberEntry = async (data: { ); }; +export const saveMemberLeave = async (data: { + staffId: number; + entry: LeaveEntry; + recordDate?: string; +}) => { + return serverFetchJson( + `${BASE_API_URL}/timesheets/saveMemberLeave`, + { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }, + ); +}; + export const revalidateCacheAfterAmendment = () => { revalidatePath("/(main)/home"); }; diff --git a/src/app/api/timesheets/index.ts b/src/app/api/timesheets/index.ts index d6d558f..04702de 100644 --- a/src/app/api/timesheets/index.ts +++ b/src/app/api/timesheets/index.ts @@ -16,6 +16,14 @@ export type TeamTimeSheets = { }; }; +export type TeamLeaves = { + [memberId: number]: { + leaveEntries: RecordLeaveInput; + staffId: string; + name: string; + }; +}; + export const fetchTimesheets = cache(async (username: string) => { return serverFetchJson(`${BASE_API_URL}/timesheets`, { next: { tags: [`timesheets_${username}`] }, @@ -45,3 +53,9 @@ export const fetchTeamMemberTimesheets = cache(async (username: string) => { }, ); }); + +export const fetchTeamMemberLeaves = cache(async (username: string) => { + return serverFetchJson(`${BASE_API_URL}/timesheets/teamLeaves`, { + next: { tags: [`team_leaves_${username}`] }, + }); +}); diff --git a/src/components/LeaveTable/LeaveEditModal.tsx b/src/components/LeaveTable/LeaveEditModal.tsx index 3bbe857..524a268 100644 --- a/src/components/LeaveTable/LeaveEditModal.tsx +++ b/src/components/LeaveTable/LeaveEditModal.tsx @@ -1,6 +1,7 @@ import { LeaveType } from "@/app/api/timesheets"; import { LeaveEntry } from "@/app/api/timesheets/actions"; import { LEAVE_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils"; +import { shortDateFormatter } from "@/app/utils/formatUtil"; import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; import { Check, Delete } from "@mui/icons-material"; import { @@ -15,16 +16,20 @@ import { Select, SxProps, TextField, + Typography, } from "@mui/material"; import React, { useCallback, useEffect } from "react"; import { Controller, useForm } from "react-hook-form"; import { useTranslation } from "react-i18next"; export interface Props extends Omit { - onSave: (leaveEntry: LeaveEntry) => void; + onSave: (leaveEntry: LeaveEntry, recordDate?: string) => Promise; onDelete?: () => void; leaveTypes: LeaveType[]; defaultValues?: Partial; + modalSx?: SxProps; + recordDate?: string; + isHoliday?: boolean; } const modalSx: SxProps = { @@ -46,8 +51,14 @@ const LeaveEditModal: React.FC = ({ onClose, leaveTypes, defaultValues, + recordDate, + modalSx: mSx, + isHoliday, }) => { - const { t } = useTranslation("home"); + const { + t, + i18n: { language }, + } = useTranslation("home"); const { register, control, reset, getValues, trigger, formState } = useForm({ defaultValues: { @@ -62,10 +73,10 @@ const LeaveEditModal: React.FC = ({ const saveHandler = useCallback(async () => { const valid = await trigger(); if (valid) { - onSave(getValues()); + await onSave(getValues(), recordDate); reset({ id: Date.now() }); } - }, [getValues, onSave, reset, trigger]); + }, [getValues, onSave, recordDate, reset, trigger]); const closeHandler = useCallback>( (...args) => { @@ -77,7 +88,16 @@ const LeaveEditModal: React.FC = ({ return ( - + + {recordDate && ( + + {shortDateFormatter(language).format(new Date(recordDate))} + + )} {t("Leave Type")} = ({ }, []); const onSaveEntry = useCallback( - (entry: LeaveEntry) => { + async (entry: LeaveEntry) => { const existingEntry = currentEntries.find((e) => e.id === entry.id); if (existingEntry) { setValue( diff --git a/src/components/TimesheetAmendment/TimesheetAmendment.tsx b/src/components/TimesheetAmendment/TimesheetAmendment.tsx index 7ebcec8..ebd5b34 100644 --- a/src/components/TimesheetAmendment/TimesheetAmendment.tsx +++ b/src/components/TimesheetAmendment/TimesheetAmendment.tsx @@ -1,31 +1,44 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { HolidaysResult } from "@/app/api/holidays"; -import { TeamTimeSheets } from "@/app/api/timesheets"; +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 { getPublicHolidaysForNYears } from "@/app/utils/holidayUtils"; +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 { TimeEntry, saveMemberEntry } from "@/app/api/timesheets/actions"; +import { + LeaveEntry, + TimeEntry, + 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"; export interface Props { + leaveTypes: LeaveType[]; + teamLeaves: TeamLeaves; teamTimesheets: TeamTimeSheets; companyHolidays: HolidaysResult[]; allProjects: ProjectWithTasks[]; } -type MemberOption = TeamTimeSheets[0] & { id: string }; +type MemberOption = TeamTimeSheets[0] & TeamLeaves[0] & { id: string }; interface EventClickArg { event: { @@ -33,7 +46,7 @@ interface EventClickArg { startStr: string; extendedProps: { calendar?: string; - entry?: TimeEntry; + entry?: TimeEntry | LeaveEntry; memberId?: string; }; }; @@ -41,8 +54,10 @@ interface EventClickArg { const TimesheetAmendment: React.FC = ({ teamTimesheets, + teamLeaves, companyHolidays, allProjects, + leaveTypes, }) => { const { t } = useTranslation(["home", "common"]); @@ -56,23 +71,34 @@ const TimesheetAmendment: React.FC = ({ }, {}); }, [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, }); }, [], ); - }, [localTeamTimesheets]); + }, [localTeamLeaves, localTeamTimesheets]); + const [selectedStaff, setSelectedStaff] = useState( allMembers[0], ); @@ -91,10 +117,11 @@ const TimesheetAmendment: React.FC = ({ const [editModalOpen, setEditModalOpen] = useState(false); const openEditModal = useCallback( - (defaultValues?: TimeEntry, recordDate?: string) => { + (defaultValues?: TimeEntry, recordDate?: string, isHoliday?: boolean) => { setEditModalProps({ defaultValues, recordDate, + isHoliday, }); setEditModalOpen(true); }, @@ -105,6 +132,28 @@ const TimesheetAmendment: React.FC = ({ 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, + recordDate, + isHoliday, + }); + setLeaveEditModalOpen(true); + }, + [], + ); + + const closeLeaveEditModal = useCallback(() => { + setLeaveEditModalOpen(false); + }, []); + // calendar related const holidays = useMemo(() => { return [ @@ -123,11 +172,34 @@ const TimesheetAmendment: React.FC = ({ })); }, [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}-entry-${entry.id}`, + id: `${date}-${index}-time-${entry.id}`, date, title: `${t("{{count}} hour", { ns: "common", @@ -151,21 +223,41 @@ const TimesheetAmendment: React.FC = ({ 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, event.startStr); + 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), + ); } }, - [openEditModal], + [companyHolidays, openEditModal, openLeaveEditModal], ); const handleDateClick = useCallback( (e: { dateStr: string }) => { - openEditModal(undefined, e.dateStr); + 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)); }, - [openEditModal], + [companyHolidays, openEditModal], ); const handleSave = useCallback( @@ -189,6 +281,26 @@ const TimesheetAmendment: React.FC = ({ [selectedStaff.id], ); + const handleSaveLeave = useCallback( + async (leaveEntry: LeaveEntry, recordDate?: string) => { + const intStaffId = parseInt(selectedStaff.id); + const newMemberLeaves = await saveMemberLeave({ + staffId: intStaffId, + recordDate, + entry: leaveEntry, + }); + setLocalTeamLeaves((leaves) => ({ + ...leaves, + [intStaffId]: { + ...leaves[intStaffId], + leaveEntries: newMemberLeaves, + }, + })); + setLeaveEditModalOpen(false); + }, + [selectedStaff.id], + ); + return ( = ({ plugins={[dayGridPlugin, interactionPlugin]} initialView="dayGridMonth" buttonText={{ today: t("Today") }} - events={[...holidays, ...timeEntries]} + events={[...holidays, ...timeEntries, ...leaveEntries]} eventClick={handleEventClick} dateClick={handleDateClick} /> @@ -220,6 +332,14 @@ const TimesheetAmendment: React.FC = ({ onSave={handleSave} {...editModalProps} /> + ); }; diff --git a/src/components/TimesheetAmendment/TimesheetAmendmentModal.tsx b/src/components/TimesheetAmendment/TimesheetAmendmentModal.tsx index 405b217..c305441 100644 --- a/src/components/TimesheetAmendment/TimesheetAmendmentModal.tsx +++ b/src/components/TimesheetAmendment/TimesheetAmendmentModal.tsx @@ -32,6 +32,8 @@ interface Props extends TimesheetAmendmentProps { export const TimesheetAmendmentModal: React.FC = ({ open, onClose, + leaveTypes, + teamLeaves, teamTimesheets, companyHolidays, allProjects, @@ -42,6 +44,8 @@ export const TimesheetAmendmentModal: React.FC = ({ const title = t("Timesheet Amendment"); const content = ( = ({ const saveHandler = useCallback(async () => { const valid = await trigger(); if (valid) { - onSave(getValues(), recordDate); + await onSave(getValues(), recordDate); reset({ id: Date.now() }); } }, [getValues, onSave, recordDate, reset, trigger]); @@ -126,7 +126,11 @@ const TimesheetEditModal: React.FC = ({ {recordDate && ( - + {shortDateFormatter(language).format(new Date(recordDate))} )} diff --git a/src/components/UserWorkspacePage/UserWorkspacePage.tsx b/src/components/UserWorkspacePage/UserWorkspacePage.tsx index ff1030c..d2975a8 100644 --- a/src/components/UserWorkspacePage/UserWorkspacePage.tsx +++ b/src/components/UserWorkspacePage/UserWorkspacePage.tsx @@ -20,7 +20,7 @@ import { revalidateCacheAfterAmendment, } from "@/app/api/timesheets/actions"; import LeaveModal from "../LeaveModal"; -import { LeaveType, TeamTimeSheets } from "@/app/api/timesheets"; +import { LeaveType, TeamLeaves, TeamTimeSheets } from "@/app/api/timesheets"; import { CalendarIcon } from "@mui/x-date-pickers"; import PastEntryCalendarModal from "../PastEntryCalendar/PastEntryCalendarModal"; import { HolidaysResult } from "@/app/api/holidays"; @@ -35,6 +35,7 @@ export interface Props { defaultTimesheets: RecordTimesheetInput; holidays: HolidaysResult[]; teamTimesheets: TeamTimeSheets; + teamLeaves: TeamLeaves; fastEntryEnabled?: boolean; } @@ -52,6 +53,7 @@ const UserWorkspacePage: React.FC = ({ defaultTimesheets, holidays, teamTimesheets, + teamLeaves, fastEntryEnabled, }) => { const [anchorEl, setAnchorEl] = useState(null); @@ -201,7 +203,9 @@ const UserWorkspacePage: React.FC = ({ {showTimesheetAmendment && ( = async ({ username }) => { const [ + teamLeaves, teamTimesheets, assignedProjects, allProjects, @@ -25,6 +27,7 @@ const UserWorkspaceWrapper: React.FC = async ({ username }) => { leaveTypes, holidays, ] = await Promise.all([ + fetchTeamMemberLeaves(username), fetchTeamMemberTimesheets(username), fetchAssignedProjects(username), fetchProjectWithTasks(), @@ -36,6 +39,7 @@ const UserWorkspaceWrapper: React.FC = async ({ username }) => { return (