diff --git a/src/app/(main)/home/page.tsx b/src/app/(main)/home/page.tsx index a83291d..3103988 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, + fetchTeamMemberTimesheets, fetchTimesheets, } from "@/app/api/timesheets"; import { authOptions } from "@/config/authConfig"; @@ -29,9 +30,10 @@ const Home: React.FC = async () => { fetchLeaveTypes(); fetchProjectWithTasks(); fetchHolidays(); + fetchTeamMemberTimesheets(username); return ( - + ); diff --git a/src/app/api/timesheets/actions.ts b/src/app/api/timesheets/actions.ts index cd32d06..49ebb4f 100644 --- a/src/app/api/timesheets/actions.ts +++ b/src/app/api/timesheets/actions.ts @@ -63,3 +63,14 @@ export const saveLeave = async (data: RecordLeaveInput, username: string) => { return savedRecords; }; + +export const saveMemberEntry = async (data: { + staffId: number; + entry: TimeEntry; +}) => { + return serverFetchJson(`${BASE_API_URL}/timesheets/saveMemberEntry`, { + method: "POST", + body: JSON.stringify(data), + headers: { "Content-Type": "application/json" }, + }); +}; diff --git a/src/app/api/timesheets/index.ts b/src/app/api/timesheets/index.ts index d9b1862..d6d558f 100644 --- a/src/app/api/timesheets/index.ts +++ b/src/app/api/timesheets/index.ts @@ -8,6 +8,14 @@ export interface LeaveType { name: string; } +export type TeamTimeSheets = { + [memberId: number]: { + timeEntries: RecordTimesheetInput; + staffId: string; + name: string; + }; +}; + export const fetchTimesheets = cache(async (username: string) => { return serverFetchJson(`${BASE_API_URL}/timesheets`, { next: { tags: [`timesheets_${username}`] }, @@ -28,3 +36,12 @@ export const fetchLeaveTypes = cache(async () => { next: { tags: ["leaveTypes"] }, }); }); + +export const fetchTeamMemberTimesheets = cache(async (username: string) => { + return serverFetchJson( + `${BASE_API_URL}/timesheets/teamTimesheets`, + { + next: { tags: [`team_timesheets_${username}`] }, + }, + ); +}); diff --git a/src/components/StyledFullCalendar/StyledFullCalendar.tsx b/src/components/StyledFullCalendar/StyledFullCalendar.tsx new file mode 100644 index 0000000..42d557d --- /dev/null +++ b/src/components/StyledFullCalendar/StyledFullCalendar.tsx @@ -0,0 +1,61 @@ +import FullCalendar from "@fullcalendar/react"; +import { Box, useTheme } from "@mui/material"; +import React from "react"; + +type Props = React.ComponentProps; + +const StyledFullCalendar: React.FC = (props) => { + const theme = useTheme(); + + return ( + + + + ); +}; + +export default StyledFullCalendar; diff --git a/src/components/StyledFullCalendar/index.ts b/src/components/StyledFullCalendar/index.ts new file mode 100644 index 0000000..ac76937 --- /dev/null +++ b/src/components/StyledFullCalendar/index.ts @@ -0,0 +1 @@ +export { default } from "./StyledFullCalendar"; diff --git a/src/components/TimesheetAmendment/TimesheetAmendment.tsx b/src/components/TimesheetAmendment/TimesheetAmendment.tsx new file mode 100644 index 0000000..237dc00 --- /dev/null +++ b/src/components/TimesheetAmendment/TimesheetAmendment.tsx @@ -0,0 +1,191 @@ +import React, { useCallback, useMemo, useState } from "react"; + +import { HolidaysResult } from "@/app/api/holidays"; +import { TeamTimeSheets } from "@/app/api/timesheets"; +import dayGridPlugin from "@fullcalendar/daygrid"; +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 { + 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 TimesheetEditModal, { + Props as TimesheetEditModalProps, +} from "../TimesheetTable/TimesheetEditModal"; + +export interface Props { + teamTimesheets: TeamTimeSheets; + companyHolidays: HolidaysResult[]; + allProjects: ProjectWithTasks[]; +} + +type MemberOption = TeamTimeSheets[0] & { id: string }; + +interface EventClickArg { + event: { + start: Date | null; + extendedProps: { + calendar?: string; + entry?: TimeEntry; + memberId?: string; + }; + }; +} + +const TimesheetAmendment: React.FC = ({ + teamTimesheets, + companyHolidays, + allProjects, +}) => { + 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]); + + // member select + const allMembers = useMemo(() => { + return transform( + teamTimesheets, + (acc, memberTimesheet, id) => { + return acc.push({ + ...memberTimesheet, + id, + }); + }, + [], + ); + }, [teamTimesheets]); + const [selectedStaff, setSelectedStaff] = useState( + allMembers[0], + ); + + // edit modal related + const [editModalProps, setEditModalProps] = useState< + Partial + >({}); + const [editModalOpen, setEditModalOpen] = useState(false); + + const openEditModal = useCallback((defaultValues?: TimeEntry) => { + setEditModalProps({ + defaultValues, + }); + setEditModalOpen(true); + }, []); + + const closeEditModal = useCallback(() => { + setEditModalOpen(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 timeEntries = useMemo( + () => + Object.keys(selectedStaff.timeEntries).flatMap((date, index) => { + return selectedStaff.timeEntries[date].map((entry) => ({ + id: `${date}-${index}-entry-${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) => { + if ( + event.extendedProps.calendar === "timeEntry" && + event.extendedProps.entry + ) { + openEditModal(event.extendedProps.entry); + } + }, + [openEditModal], + ); + + const handleSave = useCallback( + async (timeEntry: TimeEntry) => { + // TODO: should be fine, but can handle parse error + const intStaffId = parseInt(selectedStaff.id); + await saveMemberEntry({ staffId: intStaffId, entry: timeEntry }); + setEditModalOpen(false); + }, + [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; diff --git a/src/components/TimesheetAmendment/TimesheetAmendmentModal.tsx b/src/components/TimesheetAmendment/TimesheetAmendmentModal.tsx new file mode 100644 index 0000000..7dc2d8c --- /dev/null +++ b/src/components/TimesheetAmendment/TimesheetAmendmentModal.tsx @@ -0,0 +1,77 @@ +import useIsMobile from "@/app/utils/useIsMobile"; +import React from "react"; +import FullscreenModal from "../FullscreenModal"; +import { + Box, + Card, + CardContent, + Dialog, + DialogContent, + DialogTitle, + Modal, + SxProps, + Typography, +} from "@mui/material"; +import { useTranslation } from "react-i18next"; +import TimesheetAmendment, { + Props as TimesheetAmendmentProps, +} from "./TimesheetAmendment"; + +const modalSx: SxProps = { + position: "absolute", + top: "50%", + left: "50%", + transform: "translate(-50%, -50%)", + width: { xs: "calc(100% - 2rem)", sm: "90%" }, + maxHeight: "90%", + maxWidth: 1400, +}; + +interface Props extends TimesheetAmendmentProps { + open: boolean; + onClose: () => void; +} + +export const TimesheetAmendmentModal: React.FC = ({ + open, + onClose, + teamTimesheets, + companyHolidays, + allProjects, +}) => { + const { t } = useTranslation("home"); + const isMobile = useIsMobile(); + + const title = t("Timesheet Amendment"); + const content = ( + + ); + + return isMobile ? ( + + + + {title} + + {content} + + + ) : ( + + + + + {title} + + + {content} + + + + + ); +}; diff --git a/src/components/TimesheetAmendment/index.ts b/src/components/TimesheetAmendment/index.ts new file mode 100644 index 0000000..c4eb43f --- /dev/null +++ b/src/components/TimesheetAmendment/index.ts @@ -0,0 +1 @@ +export { default } from "./TimesheetAmendment"; diff --git a/src/components/TimesheetTable/EntryInputTable.tsx b/src/components/TimesheetTable/EntryInputTable.tsx index 3b95125..effd88b 100644 --- a/src/components/TimesheetTable/EntryInputTable.tsx +++ b/src/components/TimesheetTable/EntryInputTable.tsx @@ -215,7 +215,7 @@ const EntryInputTable: React.FC = ({ width: 300, editable: true, valueFormatter(params) { - const project = assignedProjects.find((p) => p.id === params.value); + const project = allProjects.find((p) => p.id === params.value); return project ? `${project.code} - ${project.name}` : t("None"); }, renderEditCell(params: GridRenderEditCellParams) { @@ -295,7 +295,7 @@ const EntryInputTable: React.FC = ({ : undefined; const task = projectId - ? assignedProjects + ? allProjects .find((p) => p.id === projectId) ?.tasks.find((t) => t.id === params.value) : undefined; diff --git a/src/components/TimesheetTable/MobileTimesheetEntry.tsx b/src/components/TimesheetTable/MobileTimesheetEntry.tsx index d1eaf56..174f22b 100644 --- a/src/components/TimesheetTable/MobileTimesheetEntry.tsx +++ b/src/components/TimesheetTable/MobileTimesheetEntry.tsx @@ -84,7 +84,7 @@ const MobileTimesheetEntry: React.FC = ({ }, []); const onSaveEntry = useCallback( - (entry: TimeEntry) => { + async (entry: TimeEntry) => { const existingEntry = currentEntries.find((e) => e.id === entry.id); if (existingEntry) { setValue( diff --git a/src/components/TimesheetTable/ProjectSelect.tsx b/src/components/TimesheetTable/ProjectSelect.tsx index 3c1ab51..31ad939 100644 --- a/src/components/TimesheetTable/ProjectSelect.tsx +++ b/src/components/TimesheetTable/ProjectSelect.tsx @@ -102,76 +102,4 @@ const AutocompleteProjectSelect: React.FC = ({ ); }; -// const ProjectSelect: React.FC = ({ -// allProjects, -// assignedProjects, -// value, -// onProjectSelect, -// }) => { -// const { t } = useTranslation("home"); - -// const nonAssignedProjects = useMemo(() => { -// return differenceBy(allProjects, assignedProjects, "id"); -// }, [allProjects, assignedProjects]); - -// const onChange = useCallback( -// (event: SelectChangeEvent) => { -// const newValue = event.target.value; -// onProjectSelect(newValue); -// }, -// [onProjectSelect], -// ); - -// return ( -// -// ); -// }; - export default AutocompleteProjectSelect; diff --git a/src/components/TimesheetTable/TimesheetEditModal.tsx b/src/components/TimesheetTable/TimesheetEditModal.tsx index b89427b..7e54eef 100644 --- a/src/components/TimesheetTable/TimesheetEditModal.tsx +++ b/src/components/TimesheetTable/TimesheetEditModal.tsx @@ -23,11 +23,12 @@ import uniqBy from "lodash/uniqBy"; import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; export interface Props extends Omit { - onSave: (leaveEntry: TimeEntry) => void; + onSave: (timeEntry: TimeEntry) => Promise; onDelete?: () => void; defaultValues?: Partial; allProjects: ProjectWithTasks[]; assignedProjects: AssignedProject[]; + modalSx?: SxProps; } const modalSx: SxProps = { @@ -50,6 +51,7 @@ const TimesheetEditModal: React.FC = ({ defaultValues, allProjects, assignedProjects, + modalSx: mSx, }) => { const { t } = useTranslation("home"); @@ -110,7 +112,7 @@ const TimesheetEditModal: React.FC = ({ return ( - + {t("Project Code and Name")} = ({ leaveTypes, allProjects, @@ -38,13 +49,31 @@ const UserWorkspacePage: React.FC = ({ defaultLeaveRecords, defaultTimesheets, holidays, + teamTimesheets, }) => { + const [anchorEl, setAnchorEl] = useState(null); + const [isTimeheetModalVisible, setTimeheetModalVisible] = useState(false); const [isLeaveModalVisible, setLeaveModalVisible] = useState(false); const [isPastEventModalVisible, setPastEventModalVisible] = useState(false); + const [isTimesheetAmendmentVisible, setisTimesheetAmendmentVisible] = + useState(false); const { t } = useTranslation("home"); + const showTimesheetAmendment = Object.keys(teamTimesheets).length > 0; + + const handleOpenActionMenu = useCallback< + React.MouseEventHandler + >((event) => { + setAnchorEl(event.currentTarget); + }, []); + + const handleCloseActionMenu = useCallback(() => { + setAnchorEl(null); + }, []); + const handleAddTimesheetButtonClick = useCallback(() => { + setAnchorEl(null); setTimeheetModalVisible(true); }, []); @@ -53,6 +82,7 @@ const UserWorkspacePage: React.FC = ({ }, []); const handleAddLeaveButtonClick = useCallback(() => { + setAnchorEl(null); setLeaveModalVisible(true); }, []); @@ -61,6 +91,7 @@ const UserWorkspacePage: React.FC = ({ }, []); const handlePastEventClick = useCallback(() => { + setAnchorEl(null); setPastEventModalVisible(true); }, []); @@ -68,6 +99,15 @@ const UserWorkspacePage: React.FC = ({ setPastEventModalVisible(false); }, []); + const handleAmendmentClick = useCallback(() => { + setAnchorEl(null); + setisTimesheetAmendmentVisible(true); + }, []); + + const handleAmendmentClose = useCallback(() => { + setisTimesheetAmendmentVisible(false); + }, []); + return ( <> = ({ {t("User Workspace")} - - - - - - - + + + + + {t("Enter Time")} + + + + {t("Record Leave")} + + + + {t("View Past Entries")} + + {showTimesheetAmendment && ( + + + {t("Timesheet Amendment")} + + )} + = ({ {t("You have no assigned projects!")} )} + {showTimesheetAmendment && ( + + )} ); }; diff --git a/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx b/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx index d86df7c..9a91d7f 100644 --- a/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx +++ b/src/components/UserWorkspacePage/UserWorkspaceWrapper.tsx @@ -6,6 +6,7 @@ import UserWorkspacePage from "./UserWorkspacePage"; import { fetchLeaveTypes, fetchLeaves, + fetchTeamMemberTimesheets, fetchTimesheets, } from "@/app/api/timesheets"; import { fetchHolidays } from "@/app/api/holidays"; @@ -16,6 +17,7 @@ interface Props { const UserWorkspaceWrapper: React.FC = async ({ username }) => { const [ + teamTimesheets, assignedProjects, allProjects, timesheets, @@ -23,6 +25,7 @@ const UserWorkspaceWrapper: React.FC = async ({ username }) => { leaveTypes, holidays, ] = await Promise.all([ + fetchTeamMemberTimesheets(username), fetchAssignedProjects(username), fetchProjectWithTasks(), fetchTimesheets(username), @@ -33,6 +36,7 @@ const UserWorkspaceWrapper: React.FC = async ({ username }) => { return (