| @@ -4,6 +4,7 @@ import UserWorkspacePage from "@/components/UserWorkspacePage"; | |||||
| import { | import { | ||||
| fetchLeaveTypes, | fetchLeaveTypes, | ||||
| fetchLeaves, | fetchLeaves, | ||||
| fetchTeamMemberTimesheets, | |||||
| fetchTimesheets, | fetchTimesheets, | ||||
| } from "@/app/api/timesheets"; | } from "@/app/api/timesheets"; | ||||
| import { authOptions } from "@/config/authConfig"; | import { authOptions } from "@/config/authConfig"; | ||||
| @@ -29,9 +30,10 @@ const Home: React.FC = async () => { | |||||
| fetchLeaveTypes(); | fetchLeaveTypes(); | ||||
| fetchProjectWithTasks(); | fetchProjectWithTasks(); | ||||
| fetchHolidays(); | fetchHolidays(); | ||||
| fetchTeamMemberTimesheets(username); | |||||
| return ( | return ( | ||||
| <I18nProvider namespaces={["home"]}> | |||||
| <I18nProvider namespaces={["home", "common"]}> | |||||
| <UserWorkspacePage username={username} /> | <UserWorkspacePage username={username} /> | ||||
| </I18nProvider> | </I18nProvider> | ||||
| ); | ); | ||||
| @@ -63,3 +63,14 @@ export const saveLeave = async (data: RecordLeaveInput, username: string) => { | |||||
| return savedRecords; | return savedRecords; | ||||
| }; | }; | ||||
| export const saveMemberEntry = async (data: { | |||||
| staffId: number; | |||||
| entry: TimeEntry; | |||||
| }) => { | |||||
| return serverFetchJson<TimeEntry>(`${BASE_API_URL}/timesheets/saveMemberEntry`, { | |||||
| method: "POST", | |||||
| body: JSON.stringify(data), | |||||
| headers: { "Content-Type": "application/json" }, | |||||
| }); | |||||
| }; | |||||
| @@ -8,6 +8,14 @@ export interface LeaveType { | |||||
| name: string; | name: string; | ||||
| } | } | ||||
| export type TeamTimeSheets = { | |||||
| [memberId: number]: { | |||||
| timeEntries: RecordTimesheetInput; | |||||
| staffId: string; | |||||
| name: string; | |||||
| }; | |||||
| }; | |||||
| export const fetchTimesheets = cache(async (username: string) => { | export const fetchTimesheets = cache(async (username: string) => { | ||||
| return serverFetchJson<RecordTimesheetInput>(`${BASE_API_URL}/timesheets`, { | return serverFetchJson<RecordTimesheetInput>(`${BASE_API_URL}/timesheets`, { | ||||
| next: { tags: [`timesheets_${username}`] }, | next: { tags: [`timesheets_${username}`] }, | ||||
| @@ -28,3 +36,12 @@ export const fetchLeaveTypes = cache(async () => { | |||||
| next: { tags: ["leaveTypes"] }, | next: { tags: ["leaveTypes"] }, | ||||
| }); | }); | ||||
| }); | }); | ||||
| export const fetchTeamMemberTimesheets = cache(async (username: string) => { | |||||
| return serverFetchJson<TeamTimeSheets>( | |||||
| `${BASE_API_URL}/timesheets/teamTimesheets`, | |||||
| { | |||||
| next: { tags: [`team_timesheets_${username}`] }, | |||||
| }, | |||||
| ); | |||||
| }); | |||||
| @@ -0,0 +1,61 @@ | |||||
| import FullCalendar from "@fullcalendar/react"; | |||||
| import { Box, useTheme } from "@mui/material"; | |||||
| import React from "react"; | |||||
| type Props = React.ComponentProps<typeof FullCalendar>; | |||||
| const StyledFullCalendar: React.FC<Props> = (props) => { | |||||
| const theme = useTheme(); | |||||
| return ( | |||||
| <Box | |||||
| sx={{ | |||||
| ".fc": { | |||||
| fontFamily: theme.typography.fontFamily, | |||||
| ".fc-toolbar-title": theme.typography.h6, | |||||
| ".fc-button": { | |||||
| borderRadius: "12px", | |||||
| border: "none", | |||||
| fontWeight: theme.typography.button.fontWeight, | |||||
| fontSize: theme.typography.button.fontSize, | |||||
| }, | |||||
| ".fc-button-primary": { | |||||
| backgroundColor: "primary.main", | |||||
| "&:disabled": { | |||||
| backgroundColor: "action.disabledBackground", | |||||
| color: "action.disabled", | |||||
| opacity: 1, | |||||
| }, | |||||
| "&:active:not(:disabled)": { | |||||
| backgroundColor: "primary.dark", | |||||
| }, | |||||
| }, | |||||
| ".fc-prev-button": { marginInlineEnd: 1 }, | |||||
| ".fc-prev-button, .fc-next-button": { | |||||
| background: "transparent", | |||||
| border: "none", | |||||
| color: "neutral.700", | |||||
| padding: 0, | |||||
| borderRadius: "50% !important", | |||||
| display: "inline-flex", | |||||
| alignItems: "center", | |||||
| justifyContent: "center", | |||||
| width: 40, | |||||
| height: 40, | |||||
| "&.fc-button:active": { | |||||
| color: "neutral.800", | |||||
| backgroundColor: "action.hover", | |||||
| }, | |||||
| "&:focus": { | |||||
| boxShadow: "none !important", | |||||
| }, | |||||
| }, | |||||
| }, | |||||
| }} | |||||
| > | |||||
| <FullCalendar {...props} /> | |||||
| </Box> | |||||
| ); | |||||
| }; | |||||
| export default StyledFullCalendar; | |||||
| @@ -0,0 +1 @@ | |||||
| export { default } from "./StyledFullCalendar"; | |||||
| @@ -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<Props> = ({ | |||||
| 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[0], MemberOption[]>( | |||||
| teamTimesheets, | |||||
| (acc, memberTimesheet, id) => { | |||||
| return acc.push({ | |||||
| ...memberTimesheet, | |||||
| id, | |||||
| }); | |||||
| }, | |||||
| [], | |||||
| ); | |||||
| }, [teamTimesheets]); | |||||
| const [selectedStaff, setSelectedStaff] = useState<MemberOption>( | |||||
| allMembers[0], | |||||
| ); | |||||
| // edit modal related | |||||
| const [editModalProps, setEditModalProps] = useState< | |||||
| Partial<TimesheetEditModalProps> | |||||
| >({}); | |||||
| 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 ( | |||||
| <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]} | |||||
| initialView="dayGridMonth" | |||||
| buttonText={{ today: t("Today") }} | |||||
| events={[...holidays, ...timeEntries]} | |||||
| eventClick={handleEventClick} | |||||
| /> | |||||
| <TimesheetEditModal | |||||
| modalSx={{ maxWidth: 400 }} | |||||
| allProjects={allProjects} | |||||
| assignedProjects={[]} | |||||
| open={editModalOpen} | |||||
| onClose={closeEditModal} | |||||
| onSave={handleSave} | |||||
| {...editModalProps} | |||||
| /> | |||||
| </Stack> | |||||
| ); | |||||
| }; | |||||
| export default TimesheetAmendment; | |||||
| @@ -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<Props> = ({ | |||||
| open, | |||||
| onClose, | |||||
| teamTimesheets, | |||||
| companyHolidays, | |||||
| allProjects, | |||||
| }) => { | |||||
| const { t } = useTranslation("home"); | |||||
| const isMobile = useIsMobile(); | |||||
| const title = t("Timesheet Amendment"); | |||||
| const content = ( | |||||
| <TimesheetAmendment | |||||
| companyHolidays={companyHolidays} | |||||
| teamTimesheets={teamTimesheets} | |||||
| allProjects={allProjects} | |||||
| /> | |||||
| ); | |||||
| return isMobile ? ( | |||||
| <FullscreenModal open={open} onClose={onClose} closeModal={onClose}> | |||||
| <Box display="flex" flexDirection="column" gap={2} height="100%"> | |||||
| <Typography variant="h6" flex="none" padding={2}> | |||||
| {title} | |||||
| </Typography> | |||||
| <Box paddingInline={2}>{content}</Box> | |||||
| </Box> | |||||
| </FullscreenModal> | |||||
| ) : ( | |||||
| <Modal open={open} onClose={onClose}> | |||||
| <Card sx={modalSx}> | |||||
| <CardContent> | |||||
| <Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
| {title} | |||||
| </Typography> | |||||
| <Box maxHeight={900} overflow="scroll"> | |||||
| {content} | |||||
| </Box> | |||||
| </CardContent> | |||||
| </Card> | |||||
| </Modal> | |||||
| ); | |||||
| }; | |||||
| @@ -0,0 +1 @@ | |||||
| export { default } from "./TimesheetAmendment"; | |||||
| @@ -215,7 +215,7 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| width: 300, | width: 300, | ||||
| editable: true, | editable: true, | ||||
| valueFormatter(params) { | 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"); | return project ? `${project.code} - ${project.name}` : t("None"); | ||||
| }, | }, | ||||
| renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) { | renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) { | ||||
| @@ -295,7 +295,7 @@ const EntryInputTable: React.FC<Props> = ({ | |||||
| : undefined; | : undefined; | ||||
| const task = projectId | const task = projectId | ||||
| ? assignedProjects | |||||
| ? allProjects | |||||
| .find((p) => p.id === projectId) | .find((p) => p.id === projectId) | ||||
| ?.tasks.find((t) => t.id === params.value) | ?.tasks.find((t) => t.id === params.value) | ||||
| : undefined; | : undefined; | ||||
| @@ -84,7 +84,7 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
| }, []); | }, []); | ||||
| const onSaveEntry = useCallback( | const onSaveEntry = useCallback( | ||||
| (entry: TimeEntry) => { | |||||
| async (entry: TimeEntry) => { | |||||
| const existingEntry = currentEntries.find((e) => e.id === entry.id); | const existingEntry = currentEntries.find((e) => e.id === entry.id); | ||||
| if (existingEntry) { | if (existingEntry) { | ||||
| setValue( | setValue( | ||||
| @@ -102,76 +102,4 @@ const AutocompleteProjectSelect: React.FC<Props> = ({ | |||||
| ); | ); | ||||
| }; | }; | ||||
| // const ProjectSelect: React.FC<Props> = ({ | |||||
| // allProjects, | |||||
| // assignedProjects, | |||||
| // value, | |||||
| // onProjectSelect, | |||||
| // }) => { | |||||
| // const { t } = useTranslation("home"); | |||||
| // const nonAssignedProjects = useMemo(() => { | |||||
| // return differenceBy(allProjects, assignedProjects, "id"); | |||||
| // }, [allProjects, assignedProjects]); | |||||
| // const onChange = useCallback( | |||||
| // (event: SelectChangeEvent<number>) => { | |||||
| // const newValue = event.target.value; | |||||
| // onProjectSelect(newValue); | |||||
| // }, | |||||
| // [onProjectSelect], | |||||
| // ); | |||||
| // return ( | |||||
| // <Select | |||||
| // displayEmpty | |||||
| // value={value || ""} | |||||
| // onChange={onChange} | |||||
| // sx={{ width: "100%" }} | |||||
| // MenuProps={{ | |||||
| // slotProps: { | |||||
| // paper: { | |||||
| // sx: { maxHeight: 400 }, | |||||
| // }, | |||||
| // }, | |||||
| // anchorOrigin: { | |||||
| // vertical: "bottom", | |||||
| // horizontal: "left", | |||||
| // }, | |||||
| // transformOrigin: { | |||||
| // vertical: "top", | |||||
| // horizontal: "left", | |||||
| // }, | |||||
| // }} | |||||
| // > | |||||
| // <ListSubheader>{t("Non-billable")}</ListSubheader> | |||||
| // <MenuItem value={""}>{t("None")}</MenuItem> | |||||
| // {assignedProjects.length > 0 && [ | |||||
| // <ListSubheader key="assignedProjectsSubHeader"> | |||||
| // {t("Assigned Projects")} | |||||
| // </ListSubheader>, | |||||
| // ...assignedProjects.map((project) => ( | |||||
| // <MenuItem | |||||
| // key={project.id} | |||||
| // value={project.id} | |||||
| // sx={{ whiteSpace: "wrap" }} | |||||
| // >{`${project.code} - ${project.name}`}</MenuItem> | |||||
| // )), | |||||
| // ]} | |||||
| // {nonAssignedProjects.length > 0 && [ | |||||
| // <ListSubheader key="nonAssignedProjectsSubHeader"> | |||||
| // {t("Non-assigned Projects")} | |||||
| // </ListSubheader>, | |||||
| // ...nonAssignedProjects.map((project) => ( | |||||
| // <MenuItem | |||||
| // key={project.id} | |||||
| // value={project.id} | |||||
| // sx={{ whiteSpace: "wrap" }} | |||||
| // >{`${project.code} - ${project.name}`}</MenuItem> | |||||
| // )), | |||||
| // ]} | |||||
| // </Select> | |||||
| // ); | |||||
| // }; | |||||
| export default AutocompleteProjectSelect; | export default AutocompleteProjectSelect; | ||||
| @@ -23,11 +23,12 @@ import uniqBy from "lodash/uniqBy"; | |||||
| import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; | import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; | ||||
| export interface Props extends Omit<ModalProps, "children"> { | export interface Props extends Omit<ModalProps, "children"> { | ||||
| onSave: (leaveEntry: TimeEntry) => void; | |||||
| onSave: (timeEntry: TimeEntry) => Promise<void>; | |||||
| onDelete?: () => void; | onDelete?: () => void; | ||||
| defaultValues?: Partial<TimeEntry>; | defaultValues?: Partial<TimeEntry>; | ||||
| allProjects: ProjectWithTasks[]; | allProjects: ProjectWithTasks[]; | ||||
| assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
| modalSx?: SxProps; | |||||
| } | } | ||||
| const modalSx: SxProps = { | const modalSx: SxProps = { | ||||
| @@ -50,6 +51,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||||
| defaultValues, | defaultValues, | ||||
| allProjects, | allProjects, | ||||
| assignedProjects, | assignedProjects, | ||||
| modalSx: mSx, | |||||
| }) => { | }) => { | ||||
| const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
| @@ -110,7 +112,7 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||||
| return ( | return ( | ||||
| <Modal open={open} onClose={closeHandler}> | <Modal open={open} onClose={closeHandler}> | ||||
| <Paper sx={modalSx}> | |||||
| <Paper sx={{ ...modalSx, ...mSx }}> | |||||
| <FormControl fullWidth> | <FormControl fullWidth> | ||||
| <InputLabel shrink>{t("Project Code and Name")}</InputLabel> | <InputLabel shrink>{t("Project Code and Name")}</InputLabel> | ||||
| <Controller | <Controller | ||||
| @@ -1,12 +1,16 @@ | |||||
| "use client"; | "use client"; | ||||
| import { useCallback, useState } from "react"; | |||||
| import React, { useCallback, useState } from "react"; | |||||
| import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
| import Button from "@mui/material/Button"; | import Button from "@mui/material/Button"; | ||||
| import Stack from "@mui/material/Stack"; | import Stack from "@mui/material/Stack"; | ||||
| import { Add } from "@mui/icons-material"; | |||||
| import { Box, Typography } from "@mui/material"; | |||||
| import ButtonGroup from "@mui/material/ButtonGroup"; | |||||
| import { | |||||
| CalendarMonth, | |||||
| EditCalendar, | |||||
| Luggage, | |||||
| MoreTime, | |||||
| } from "@mui/icons-material"; | |||||
| import { Menu, MenuItem, SxProps, Typography } from "@mui/material"; | |||||
| import AssignedProjects from "./AssignedProjects"; | import AssignedProjects from "./AssignedProjects"; | ||||
| import TimesheetModal from "../TimesheetModal"; | import TimesheetModal from "../TimesheetModal"; | ||||
| import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | ||||
| @@ -15,10 +19,11 @@ import { | |||||
| RecordTimesheetInput, | RecordTimesheetInput, | ||||
| } from "@/app/api/timesheets/actions"; | } from "@/app/api/timesheets/actions"; | ||||
| import LeaveModal from "../LeaveModal"; | import LeaveModal from "../LeaveModal"; | ||||
| import { LeaveType } from "@/app/api/timesheets"; | |||||
| import { LeaveType, TeamTimeSheets } from "@/app/api/timesheets"; | |||||
| import { CalendarIcon } from "@mui/x-date-pickers"; | import { CalendarIcon } from "@mui/x-date-pickers"; | ||||
| import PastEntryCalendarModal from "../PastEntryCalendar/PastEntryCalendarModal"; | import PastEntryCalendarModal from "../PastEntryCalendar/PastEntryCalendarModal"; | ||||
| import { HolidaysResult } from "@/app/api/holidays"; | import { HolidaysResult } from "@/app/api/holidays"; | ||||
| import { TimesheetAmendmentModal } from "../TimesheetAmendment/TimesheetAmendmentModal"; | |||||
| export interface Props { | export interface Props { | ||||
| leaveTypes: LeaveType[]; | leaveTypes: LeaveType[]; | ||||
| @@ -28,8 +33,14 @@ export interface Props { | |||||
| defaultLeaveRecords: RecordLeaveInput; | defaultLeaveRecords: RecordLeaveInput; | ||||
| defaultTimesheets: RecordTimesheetInput; | defaultTimesheets: RecordTimesheetInput; | ||||
| holidays: HolidaysResult[]; | holidays: HolidaysResult[]; | ||||
| teamTimesheets: TeamTimeSheets; | |||||
| } | } | ||||
| const menuItemSx: SxProps = { | |||||
| gap: 1, | |||||
| color: "neutral.700", | |||||
| }; | |||||
| const UserWorkspacePage: React.FC<Props> = ({ | const UserWorkspacePage: React.FC<Props> = ({ | ||||
| leaveTypes, | leaveTypes, | ||||
| allProjects, | allProjects, | ||||
| @@ -38,13 +49,31 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| defaultLeaveRecords, | defaultLeaveRecords, | ||||
| defaultTimesheets, | defaultTimesheets, | ||||
| holidays, | holidays, | ||||
| teamTimesheets, | |||||
| }) => { | }) => { | ||||
| const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); | |||||
| const [isTimeheetModalVisible, setTimeheetModalVisible] = useState(false); | const [isTimeheetModalVisible, setTimeheetModalVisible] = useState(false); | ||||
| const [isLeaveModalVisible, setLeaveModalVisible] = useState(false); | const [isLeaveModalVisible, setLeaveModalVisible] = useState(false); | ||||
| const [isPastEventModalVisible, setPastEventModalVisible] = useState(false); | const [isPastEventModalVisible, setPastEventModalVisible] = useState(false); | ||||
| const [isTimesheetAmendmentVisible, setisTimesheetAmendmentVisible] = | |||||
| useState(false); | |||||
| const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
| const showTimesheetAmendment = Object.keys(teamTimesheets).length > 0; | |||||
| const handleOpenActionMenu = useCallback< | |||||
| React.MouseEventHandler<HTMLButtonElement> | |||||
| >((event) => { | |||||
| setAnchorEl(event.currentTarget); | |||||
| }, []); | |||||
| const handleCloseActionMenu = useCallback(() => { | |||||
| setAnchorEl(null); | |||||
| }, []); | |||||
| const handleAddTimesheetButtonClick = useCallback(() => { | const handleAddTimesheetButtonClick = useCallback(() => { | ||||
| setAnchorEl(null); | |||||
| setTimeheetModalVisible(true); | setTimeheetModalVisible(true); | ||||
| }, []); | }, []); | ||||
| @@ -53,6 +82,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| }, []); | }, []); | ||||
| const handleAddLeaveButtonClick = useCallback(() => { | const handleAddLeaveButtonClick = useCallback(() => { | ||||
| setAnchorEl(null); | |||||
| setLeaveModalVisible(true); | setLeaveModalVisible(true); | ||||
| }, []); | }, []); | ||||
| @@ -61,6 +91,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| }, []); | }, []); | ||||
| const handlePastEventClick = useCallback(() => { | const handlePastEventClick = useCallback(() => { | ||||
| setAnchorEl(null); | |||||
| setPastEventModalVisible(true); | setPastEventModalVisible(true); | ||||
| }, []); | }, []); | ||||
| @@ -68,6 +99,15 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| setPastEventModalVisible(false); | setPastEventModalVisible(false); | ||||
| }, []); | }, []); | ||||
| const handleAmendmentClick = useCallback(() => { | |||||
| setAnchorEl(null); | |||||
| setisTimesheetAmendmentVisible(true); | |||||
| }, []); | |||||
| const handleAmendmentClose = useCallback(() => { | |||||
| setisTimesheetAmendmentVisible(false); | |||||
| }, []); | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <Stack | <Stack | ||||
| @@ -79,24 +119,46 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| <Typography variant="h4" marginInlineEnd={2}> | <Typography variant="h4" marginInlineEnd={2}> | ||||
| {t("User Workspace")} | {t("User Workspace")} | ||||
| </Typography> | </Typography> | ||||
| <Box display="flex" flexWrap="wrap" gap={2}> | |||||
| <Button | |||||
| startIcon={<CalendarIcon />} | |||||
| variant="outlined" | |||||
| onClick={handlePastEventClick} | |||||
| > | |||||
| {t("View Past Entries")} | |||||
| </Button> | |||||
| <ButtonGroup variant="contained"> | |||||
| <Button startIcon={<Add />} onClick={handleAddTimesheetButtonClick}> | |||||
| {t("Enter Time")} | |||||
| </Button> | |||||
| <Button startIcon={<Add />} onClick={handleAddLeaveButtonClick}> | |||||
| {t("Record Leave")} | |||||
| </Button> | |||||
| </ButtonGroup> | |||||
| </Box> | |||||
| <Button | |||||
| startIcon={<CalendarIcon />} | |||||
| variant="contained" | |||||
| onClick={handleOpenActionMenu} | |||||
| > | |||||
| {t("Timesheet Actions")} | |||||
| </Button> | |||||
| </Stack> | </Stack> | ||||
| <Menu | |||||
| anchorEl={anchorEl} | |||||
| open={Boolean(anchorEl)} | |||||
| onClose={handleCloseActionMenu} | |||||
| anchorOrigin={{ | |||||
| vertical: "bottom", | |||||
| horizontal: "right", | |||||
| }} | |||||
| transformOrigin={{ | |||||
| vertical: "top", | |||||
| horizontal: "right", | |||||
| }} | |||||
| > | |||||
| <MenuItem onClick={handleAddTimesheetButtonClick} sx={menuItemSx}> | |||||
| <MoreTime /> | |||||
| {t("Enter Time")} | |||||
| </MenuItem> | |||||
| <MenuItem onClick={handleAddLeaveButtonClick} sx={menuItemSx}> | |||||
| <Luggage /> | |||||
| {t("Record Leave")} | |||||
| </MenuItem> | |||||
| <MenuItem onClick={handlePastEventClick} sx={menuItemSx}> | |||||
| <CalendarMonth /> | |||||
| {t("View Past Entries")} | |||||
| </MenuItem> | |||||
| {showTimesheetAmendment && ( | |||||
| <MenuItem onClick={handleAmendmentClick} sx={menuItemSx}> | |||||
| <EditCalendar /> | |||||
| {t("Timesheet Amendment")} | |||||
| </MenuItem> | |||||
| )} | |||||
| </Menu> | |||||
| <PastEntryCalendarModal | <PastEntryCalendarModal | ||||
| open={isPastEventModalVisible} | open={isPastEventModalVisible} | ||||
| handleClose={handlePastEventClose} | handleClose={handlePastEventClose} | ||||
| @@ -131,6 +193,15 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| {t("You have no assigned projects!")} | {t("You have no assigned projects!")} | ||||
| </Typography> | </Typography> | ||||
| )} | )} | ||||
| {showTimesheetAmendment && ( | |||||
| <TimesheetAmendmentModal | |||||
| allProjects={allProjects} | |||||
| companyHolidays={holidays} | |||||
| teamTimesheets={teamTimesheets} | |||||
| open={isTimesheetAmendmentVisible} | |||||
| onClose={handleAmendmentClose} | |||||
| /> | |||||
| )} | |||||
| </> | </> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -6,6 +6,7 @@ import UserWorkspacePage from "./UserWorkspacePage"; | |||||
| import { | import { | ||||
| fetchLeaveTypes, | fetchLeaveTypes, | ||||
| fetchLeaves, | fetchLeaves, | ||||
| fetchTeamMemberTimesheets, | |||||
| fetchTimesheets, | fetchTimesheets, | ||||
| } from "@/app/api/timesheets"; | } from "@/app/api/timesheets"; | ||||
| import { fetchHolidays } from "@/app/api/holidays"; | import { fetchHolidays } from "@/app/api/holidays"; | ||||
| @@ -16,6 +17,7 @@ interface Props { | |||||
| const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => { | const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => { | ||||
| const [ | const [ | ||||
| teamTimesheets, | |||||
| assignedProjects, | assignedProjects, | ||||
| allProjects, | allProjects, | ||||
| timesheets, | timesheets, | ||||
| @@ -23,6 +25,7 @@ const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => { | |||||
| leaveTypes, | leaveTypes, | ||||
| holidays, | holidays, | ||||
| ] = await Promise.all([ | ] = await Promise.all([ | ||||
| fetchTeamMemberTimesheets(username), | |||||
| fetchAssignedProjects(username), | fetchAssignedProjects(username), | ||||
| fetchProjectWithTasks(), | fetchProjectWithTasks(), | ||||
| fetchTimesheets(username), | fetchTimesheets(username), | ||||
| @@ -33,6 +36,7 @@ const UserWorkspaceWrapper: React.FC<Props> = async ({ username }) => { | |||||
| return ( | return ( | ||||
| <UserWorkspacePage | <UserWorkspacePage | ||||
| teamTimesheets={teamTimesheets} | |||||
| allProjects={allProjects} | allProjects={allProjects} | ||||
| assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
| username={username} | username={username} | ||||
| @@ -1,6 +1,9 @@ | |||||
| { | { | ||||
| "Grade {{grade}}": "Grade {{grade}}", | "Grade {{grade}}": "Grade {{grade}}", | ||||
| "{{count}} hour_one": "{{count}} hour", | |||||
| "{{count}} hour_other": "{{count}} hours", | |||||
| "All": "All", | "All": "All", | ||||
| "Petty Cash": "Petty Cash", | "Petty Cash": "Petty Cash", | ||||