@@ -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 ( | |||
<I18nProvider namespaces={["home", "common"]}> | |||
@@ -79,6 +79,21 @@ export const saveMemberEntry = async (data: { | |||
); | |||
}; | |||
export const saveMemberLeave = async (data: { | |||
staffId: number; | |||
entry: LeaveEntry; | |||
recordDate?: string; | |||
}) => { | |||
return serverFetchJson<RecordLeaveInput>( | |||
`${BASE_API_URL}/timesheets/saveMemberLeave`, | |||
{ | |||
method: "POST", | |||
body: JSON.stringify(data), | |||
headers: { "Content-Type": "application/json" }, | |||
}, | |||
); | |||
}; | |||
export const revalidateCacheAfterAmendment = () => { | |||
revalidatePath("/(main)/home"); | |||
}; |
@@ -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<RecordTimesheetInput>(`${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<TeamLeaves>(`${BASE_API_URL}/timesheets/teamLeaves`, { | |||
next: { tags: [`team_leaves_${username}`] }, | |||
}); | |||
}); |
@@ -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<ModalProps, "children"> { | |||
onSave: (leaveEntry: LeaveEntry) => void; | |||
onSave: (leaveEntry: LeaveEntry, recordDate?: string) => Promise<void>; | |||
onDelete?: () => void; | |||
leaveTypes: LeaveType[]; | |||
defaultValues?: Partial<LeaveEntry>; | |||
modalSx?: SxProps; | |||
recordDate?: string; | |||
isHoliday?: boolean; | |||
} | |||
const modalSx: SxProps = { | |||
@@ -46,8 +51,14 @@ const LeaveEditModal: React.FC<Props> = ({ | |||
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<LeaveEntry>({ | |||
defaultValues: { | |||
@@ -62,10 +73,10 @@ const LeaveEditModal: React.FC<Props> = ({ | |||
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<NonNullable<ModalProps["onClose"]>>( | |||
(...args) => { | |||
@@ -77,7 +88,16 @@ const LeaveEditModal: React.FC<Props> = ({ | |||
return ( | |||
<Modal open={open} onClose={closeHandler}> | |||
<Paper sx={modalSx}> | |||
<Paper sx={{ ...modalSx, ...mSx }}> | |||
{recordDate && ( | |||
<Typography | |||
variant="h6" | |||
marginBlockEnd={2} | |||
color={isHoliday ? "error.main" : undefined} | |||
> | |||
{shortDateFormatter(language).format(new Date(recordDate))} | |||
</Typography> | |||
)} | |||
<FormControl fullWidth> | |||
<InputLabel>{t("Leave Type")}</InputLabel> | |||
<Controller | |||
@@ -72,7 +72,7 @@ const MobileLeaveEntry: React.FC<Props> = ({ | |||
}, []); | |||
const onSaveEntry = useCallback( | |||
(entry: LeaveEntry) => { | |||
async (entry: LeaveEntry) => { | |||
const existingEntry = currentEntries.find((e) => e.id === entry.id); | |||
if (existingEntry) { | |||
setValue( | |||
@@ -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<Props> = ({ | |||
teamTimesheets, | |||
teamLeaves, | |||
companyHolidays, | |||
allProjects, | |||
leaveTypes, | |||
}) => { | |||
const { t } = useTranslation(["home", "common"]); | |||
@@ -56,23 +71,34 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||
}, {}); | |||
}, [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, | |||
}); | |||
}, | |||
[], | |||
); | |||
}, [localTeamTimesheets]); | |||
}, [localTeamLeaves, localTeamTimesheets]); | |||
const [selectedStaff, setSelectedStaff] = useState<MemberOption>( | |||
allMembers[0], | |||
); | |||
@@ -91,10 +117,11 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||
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<Props> = ({ | |||
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, | |||
recordDate, | |||
isHoliday, | |||
}); | |||
setLeaveEditModalOpen(true); | |||
}, | |||
[], | |||
); | |||
const closeLeaveEditModal = useCallback(() => { | |||
setLeaveEditModalOpen(false); | |||
}, []); | |||
// calendar related | |||
const holidays = useMemo(() => { | |||
return [ | |||
@@ -123,11 +172,34 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||
})); | |||
}, [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<Props> = ({ | |||
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<Props> = ({ | |||
[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 ( | |||
<Stack spacing={2}> | |||
<Autocomplete | |||
@@ -207,7 +319,7 @@ const TimesheetAmendment: React.FC<Props> = ({ | |||
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<Props> = ({ | |||
onSave={handleSave} | |||
{...editModalProps} | |||
/> | |||
<LeaveEditModal | |||
modalSx={{ maxWidth: 400 }} | |||
leaveTypes={leaveTypes} | |||
open={leaveEditModalOpen} | |||
onClose={closeLeaveEditModal} | |||
onSave={handleSaveLeave} | |||
{...leaveEditModalProps} | |||
/> | |||
</Stack> | |||
); | |||
}; | |||
@@ -32,6 +32,8 @@ interface Props extends TimesheetAmendmentProps { | |||
export const TimesheetAmendmentModal: React.FC<Props> = ({ | |||
open, | |||
onClose, | |||
leaveTypes, | |||
teamLeaves, | |||
teamTimesheets, | |||
companyHolidays, | |||
allProjects, | |||
@@ -42,6 +44,8 @@ export const TimesheetAmendmentModal: React.FC<Props> = ({ | |||
const title = t("Timesheet Amendment"); | |||
const content = ( | |||
<TimesheetAmendment | |||
leaveTypes={leaveTypes} | |||
teamLeaves={teamLeaves} | |||
companyHolidays={companyHolidays} | |||
teamTimesheets={teamTimesheets} | |||
allProjects={allProjects} | |||
@@ -105,7 +105,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||
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<Props> = ({ | |||
<Modal open={open} onClose={closeHandler}> | |||
<Paper sx={{ ...modalSx, ...mSx }}> | |||
{recordDate && ( | |||
<Typography variant="h6" marginBlockEnd={2}> | |||
<Typography | |||
variant="h6" | |||
marginBlockEnd={2} | |||
color={isHoliday ? "error.main" : undefined} | |||
> | |||
{shortDateFormatter(language).format(new Date(recordDate))} | |||
</Typography> | |||
)} | |||
@@ -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<Props> = ({ | |||
defaultTimesheets, | |||
holidays, | |||
teamTimesheets, | |||
teamLeaves, | |||
fastEntryEnabled, | |||
}) => { | |||
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | |||
@@ -201,7 +203,9 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
{showTimesheetAmendment && ( | |||
<TimesheetAmendmentModal | |||
allProjects={allProjects} | |||
leaveTypes={leaveTypes} | |||
companyHolidays={holidays} | |||
teamLeaves={teamLeaves} | |||
teamTimesheets={teamTimesheets} | |||
open={isTimesheetAmendmentVisible} | |||
onClose={handleAmendmentClose} | |||
@@ -6,6 +6,7 @@ import UserWorkspacePage from "./UserWorkspacePage"; | |||
import { | |||
fetchLeaveTypes, | |||
fetchLeaves, | |||
fetchTeamMemberLeaves, | |||
fetchTeamMemberTimesheets, | |||
fetchTimesheets, | |||
} from "@/app/api/timesheets"; | |||
@@ -17,6 +18,7 @@ interface Props { | |||
const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => { | |||
const [ | |||
teamLeaves, | |||
teamTimesheets, | |||
assignedProjects, | |||
allProjects, | |||
@@ -25,6 +27,7 @@ const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => { | |||
leaveTypes, | |||
holidays, | |||
] = await Promise.all([ | |||
fetchTeamMemberLeaves(username), | |||
fetchTeamMemberTimesheets(username), | |||
fetchAssignedProjects(username), | |||
fetchProjectWithTasks(), | |||
@@ -36,6 +39,7 @@ const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => { | |||
return ( | |||
<UserWorkspacePage | |||
teamLeaves={teamLeaves} | |||
teamTimesheets={teamTimesheets} | |||
allProjects={allProjects} | |||
assignedProjects={assignedProjects} | |||