| @@ -9,8 +9,6 @@ import { | |||
| ModalProps, | |||
| SxProps, | |||
| Typography, | |||
| useMediaQuery, | |||
| useTheme, | |||
| } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { Check, Close } from "@mui/icons-material"; | |||
| @@ -26,6 +24,7 @@ import LeaveTable from "../LeaveTable"; | |||
| import { LeaveType } from "@/app/api/timesheets"; | |||
| import FullscreenModal from "../FullscreenModal"; | |||
| import MobileLeaveTable from "../LeaveTable/MobileLeaveTable"; | |||
| import useIsMobile from "../utils/useIsMobile"; | |||
| interface Props { | |||
| isOpen: boolean; | |||
| @@ -106,12 +105,11 @@ const LeaveModal: React.FC<Props> = ({ | |||
| [onCancel], | |||
| ); | |||
| const theme = useTheme(); | |||
| const matches = useMediaQuery(theme.breakpoints.up("sm")); | |||
| const matches = useIsMobile(); | |||
| return ( | |||
| <FormProvider {...formProps}> | |||
| {matches ? ( | |||
| {!matches ? ( | |||
| // Desktop version | |||
| <Modal open={isOpen} onClose={onModalClose}> | |||
| <Card sx={modalSx}> | |||
| @@ -47,7 +47,11 @@ const LeaveEditModal: React.FC<Props> = ({ | |||
| }) => { | |||
| const { t } = useTranslation("home"); | |||
| const { register, control, reset, getValues, trigger, formState } = | |||
| useForm<LeaveEntry>(); | |||
| useForm<LeaveEntry>({ | |||
| defaultValues: { | |||
| leaveTypeId: leaveTypes[0].id, | |||
| }, | |||
| }); | |||
| useEffect(() => { | |||
| reset(defaultValues ?? { leaveTypeId: leaveTypes[0].id, id: Date.now() }); | |||
| @@ -57,14 +61,14 @@ const LeaveEditModal: React.FC<Props> = ({ | |||
| const valid = await trigger(); | |||
| if (valid) { | |||
| onSave(getValues()); | |||
| reset(); | |||
| reset({ id: Date.now() }); | |||
| } | |||
| }, [getValues, onSave, reset, trigger]); | |||
| const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | |||
| (...args) => { | |||
| onClose?.(...args); | |||
| reset(); | |||
| reset({ id: Date.now() }); | |||
| }, | |||
| [onClose, reset], | |||
| ); | |||
| @@ -0,0 +1,63 @@ | |||
| import { LeaveType } from "@/app/api/timesheets"; | |||
| import { LeaveEntry } from "@/app/api/timesheets/actions"; | |||
| import { manhourFormatter } from "@/app/utils/formatUtil"; | |||
| import { Edit } from "@mui/icons-material"; | |||
| import { Box, Card, CardContent, IconButton, Typography } from "@mui/material"; | |||
| import { useTranslation } from "react-i18next"; | |||
| interface Props { | |||
| entry: LeaveEntry; | |||
| onEdit?: () => void; | |||
| leaveTypeMap: { | |||
| [id: number]: LeaveType; | |||
| }; | |||
| } | |||
| const LeaveEntryCard: React.FC<Props> = ({ entry, onEdit, leaveTypeMap }) => { | |||
| const { t } = useTranslation("home"); | |||
| return ( | |||
| <Card sx={{ marginInline: 1, overflow: "visible" }}> | |||
| <CardContent | |||
| sx={{ | |||
| padding: 2, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| gap: 2, | |||
| "&:last-child": { | |||
| paddingBottom: 2, | |||
| }, | |||
| }} | |||
| > | |||
| <Box | |||
| display="flex" | |||
| justifyContent="space-between" | |||
| alignItems="flex-start" | |||
| > | |||
| <Box> | |||
| <Typography variant="body2" component="div" fontWeight="bold"> | |||
| {leaveTypeMap[entry.leaveTypeId].name} | |||
| </Typography> | |||
| <Typography component="p"> | |||
| {manhourFormatter.format(entry.inputHours)} | |||
| </Typography> | |||
| </Box> | |||
| {onEdit && ( | |||
| <IconButton size="small" color="primary" onClick={onEdit}> | |||
| <Edit /> | |||
| </IconButton> | |||
| )} | |||
| </Box> | |||
| {entry.remark && ( | |||
| <Box> | |||
| <Typography variant="body2" component="div" fontWeight="bold"> | |||
| {t("Remark")} | |||
| </Typography> | |||
| <Typography component="p">{entry.remark}</Typography> | |||
| </Box> | |||
| )} | |||
| </CardContent> | |||
| </Card> | |||
| ); | |||
| }; | |||
| export default LeaveEntryCard; | |||
| @@ -15,6 +15,7 @@ import React, { useCallback, useMemo, useState } from "react"; | |||
| import { useFormContext } from "react-hook-form"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import LeaveEditModal, { Props as LeaveEditModalProps } from "./LeaveEditModal"; | |||
| import LeaveEntryCard from "./LeaveEntryCard"; | |||
| interface Props { | |||
| date: string; | |||
| @@ -105,60 +106,12 @@ const MobileLeaveEntry: React.FC<Props> = ({ date, leaveTypes }) => { | |||
| {currentEntries.length ? ( | |||
| currentEntries.map((entry, index) => { | |||
| return ( | |||
| <Card | |||
| <LeaveEntryCard | |||
| key={`${entry.id}-${index}`} | |||
| sx={{ marginInline: 1, overflow: "visible" }} | |||
| > | |||
| <CardContent | |||
| sx={{ | |||
| padding: 2, | |||
| display: "flex", | |||
| flexDirection: "column", | |||
| gap: 2, | |||
| "&:last-child": { | |||
| paddingBottom: 2, | |||
| }, | |||
| }} | |||
| > | |||
| <Box | |||
| display="flex" | |||
| justifyContent="space-between" | |||
| alignItems="flex-start" | |||
| > | |||
| <Box> | |||
| <Typography | |||
| variant="body2" | |||
| component="div" | |||
| fontWeight="bold" | |||
| > | |||
| {leaveTypeMap[entry.leaveTypeId].name} | |||
| </Typography> | |||
| <Typography component="p"> | |||
| {manhourFormatter.format(entry.inputHours)} | |||
| </Typography> | |||
| </Box> | |||
| <IconButton | |||
| size="small" | |||
| color="primary" | |||
| onClick={openEditModal(entry)} | |||
| > | |||
| <Edit /> | |||
| </IconButton> | |||
| </Box> | |||
| {entry.remark && ( | |||
| <Box> | |||
| <Typography | |||
| variant="body2" | |||
| component="div" | |||
| fontWeight="bold" | |||
| > | |||
| {t("Remark")} | |||
| </Typography> | |||
| <Typography component="p">{entry.remark}</Typography> | |||
| </Box> | |||
| )} | |||
| </CardContent> | |||
| </Card> | |||
| entry={entry} | |||
| onEdit={openEditModal(entry)} | |||
| leaveTypeMap={leaveTypeMap} | |||
| /> | |||
| ); | |||
| }) | |||
| ) : ( | |||
| @@ -15,10 +15,17 @@ import PastEntryCalendar, { | |||
| import { useCallback, useState } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import { ArrowBack } from "@mui/icons-material"; | |||
| import PastEntryList from "./PastEntryList"; | |||
| import { ProjectWithTasks } from "@/app/api/projects"; | |||
| import { LeaveType } from "@/app/api/timesheets"; | |||
| import useIsMobile from "../utils/useIsMobile"; | |||
| import FullscreenModal from "../FullscreenModal"; | |||
| interface Props extends Omit<PastEntryCalendarProps, "onDateSelect"> { | |||
| open: boolean; | |||
| handleClose: () => void; | |||
| leaveTypes: LeaveType[]; | |||
| allProjects: ProjectWithTasks[]; | |||
| } | |||
| const Indicator = styled(Box)(() => ({ | |||
| @@ -32,6 +39,8 @@ const PastEntryCalendarModal: React.FC<Props> = ({ | |||
| open, | |||
| timesheet, | |||
| leaves, | |||
| leaveTypes, | |||
| allProjects, | |||
| }) => { | |||
| const { t } = useTranslation("home"); | |||
| @@ -45,42 +54,75 @@ const PastEntryCalendarModal: React.FC<Props> = ({ | |||
| handleClose(); | |||
| }, [handleClose]); | |||
| return ( | |||
| const content = selectedDate ? ( | |||
| <> | |||
| <PastEntryList | |||
| date={selectedDate} | |||
| timesheet={timesheet} | |||
| leaves={leaves} | |||
| allProjects={allProjects} | |||
| leaveTypes={leaveTypes} | |||
| /> | |||
| </> | |||
| ) : ( | |||
| <> | |||
| <Stack marginBlockEnd={2}> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "info.light" }} /> | |||
| <Typography variant="caption">{t("Has timesheet entry")}</Typography> | |||
| </Box> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "warning.light" }} /> | |||
| <Typography variant="caption">{t("Has leave entry")}</Typography> | |||
| </Box> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "success.light" }} /> | |||
| <Typography variant="caption"> | |||
| {t("Has both timesheet and leave entry")} | |||
| </Typography> | |||
| </Box> | |||
| </Stack> | |||
| <PastEntryCalendar | |||
| timesheet={timesheet} | |||
| leaves={leaves} | |||
| onDateSelect={setSelectedDate} | |||
| /> | |||
| </> | |||
| ); | |||
| const isMobile = useIsMobile(); | |||
| return isMobile ? ( | |||
| <FullscreenModal open={open} onClose={onClose} closeModal={onClose}> | |||
| <Box display="flex" flexDirection="column" gap={2} height="100%"> | |||
| <Typography variant="h6" flex="none" paddingInline={2}> | |||
| {t("Past Entries")} | |||
| </Typography> | |||
| <Box | |||
| flex={1} | |||
| paddingInline={2} | |||
| overflow="hidden" | |||
| display="flex" | |||
| flexDirection="column" | |||
| sx={{ overflow: "scroll" }} | |||
| > | |||
| {content} | |||
| </Box> | |||
| <Box padding={2} display="flex" justifyContent="flex-end"> | |||
| <Button | |||
| variant="outlined" | |||
| startIcon={<ArrowBack />} | |||
| onClick={clearDate} | |||
| > | |||
| {t("Back")} | |||
| </Button> | |||
| </Box> | |||
| </Box> | |||
| </FullscreenModal> | |||
| ) : ( | |||
| <Dialog onClose={onClose} open={open}> | |||
| <DialogTitle>{t("Past Entries")}</DialogTitle> | |||
| <DialogContent> | |||
| {selectedDate ? ( | |||
| <Box>{selectedDate}</Box> | |||
| ) : ( | |||
| <Box> | |||
| <Stack> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "info.light" }} /> | |||
| <Typography variant="caption"> | |||
| {t("Has timesheet entry")} | |||
| </Typography> | |||
| </Box> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "warning.light" }} /> | |||
| <Typography variant="caption"> | |||
| {t("Has leave entry")} | |||
| </Typography> | |||
| </Box> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "success.light" }} /> | |||
| <Typography variant="caption"> | |||
| {t("Has both timesheet and leave entry")} | |||
| </Typography> | |||
| </Box> | |||
| </Stack> | |||
| <PastEntryCalendar | |||
| timesheet={timesheet} | |||
| leaves={leaves} | |||
| onDateSelect={setSelectedDate} | |||
| /> | |||
| </Box> | |||
| )} | |||
| </DialogContent> | |||
| <DialogContent>{content}</DialogContent> | |||
| {selectedDate && ( | |||
| <DialogActions> | |||
| <Button | |||
| @@ -0,0 +1,101 @@ | |||
| import { | |||
| RecordTimesheetInput, | |||
| RecordLeaveInput, | |||
| } from "@/app/api/timesheets/actions"; | |||
| import { shortDateFormatter } from "@/app/utils/formatUtil"; | |||
| import { Box, Stack, Typography } from "@mui/material"; | |||
| import dayjs from "dayjs"; | |||
| import React, { useMemo } from "react"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import TimeEntryCard from "../TimesheetTable/TimeEntryCard"; | |||
| import LeaveEntryCard from "../LeaveTable/LeaveEntryCard"; | |||
| import { ProjectWithTasks } from "@/app/api/projects"; | |||
| import { LeaveType } from "@/app/api/timesheets"; | |||
| interface Props { | |||
| date: string; | |||
| timesheet: RecordTimesheetInput; | |||
| leaves: RecordLeaveInput; | |||
| leaveTypes: LeaveType[]; | |||
| allProjects: ProjectWithTasks[]; | |||
| } | |||
| const PastEntryList: React.FC<Props> = ({ | |||
| date, | |||
| timesheet, | |||
| leaves, | |||
| leaveTypes, | |||
| allProjects, | |||
| }) => { | |||
| const { | |||
| i18n: { language }, | |||
| } = useTranslation("home"); | |||
| const timeEntries = timesheet[date] || []; | |||
| const leaveEntries = leaves[date] || []; | |||
| const leaveTypeMap = useMemo<{ [id: LeaveType["id"]]: LeaveType }>(() => { | |||
| return leaveTypes.reduce( | |||
| (acc, leaveType) => ({ ...acc, [leaveType.id]: leaveType }), | |||
| {}, | |||
| ); | |||
| }, [leaveTypes]); | |||
| const projectMap = useMemo(() => { | |||
| return allProjects.reduce<{ | |||
| [id: ProjectWithTasks["id"]]: ProjectWithTasks; | |||
| }>((acc, project) => { | |||
| return { ...acc, [project.id]: project }; | |||
| }, {}); | |||
| }, [allProjects]); | |||
| if (!(timeEntries.length || leaveEntries.length)) { | |||
| return null; | |||
| } | |||
| const dayJsObj = dayjs(date); | |||
| return ( | |||
| <Stack gap={2} marginBlockEnd={2} minWidth={{ sm: 375 }}> | |||
| <Typography | |||
| variant="overline" | |||
| color={dayJsObj.day() === 0 ? "error.main" : undefined} | |||
| > | |||
| {shortDateFormatter(language).format(dayJsObj.toDate())} | |||
| </Typography> | |||
| <Box | |||
| flex={1} | |||
| display="flex" | |||
| flexDirection="column" | |||
| gap={2} | |||
| sx={{ overflowY: "scroll" }} | |||
| > | |||
| {timeEntries.map((entry, index) => { | |||
| const project = entry.projectId | |||
| ? projectMap[entry.projectId] | |||
| : undefined; | |||
| const task = project?.tasks.find((t) => t.id === entry.taskId); | |||
| return ( | |||
| <TimeEntryCard | |||
| key={`${entry.id}-${index}`} | |||
| project={project} | |||
| task={task} | |||
| entry={entry} | |||
| /> | |||
| ); | |||
| })} | |||
| {leaveEntries.map((entry, index) => ( | |||
| <LeaveEntryCard | |||
| key={`${entry.id}-${index}`} | |||
| entry={entry} | |||
| leaveTypeMap={leaveTypeMap} | |||
| /> | |||
| ))} | |||
| </Box> | |||
| </Stack> | |||
| ); | |||
| }; | |||
| export default PastEntryList; | |||
| @@ -9,8 +9,6 @@ import { | |||
| ModalProps, | |||
| SxProps, | |||
| Typography, | |||
| useMediaQuery, | |||
| useTheme, | |||
| } from "@mui/material"; | |||
| import TimesheetTable from "../TimesheetTable"; | |||
| import { useTranslation } from "react-i18next"; | |||
| @@ -26,6 +24,7 @@ import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
| import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||
| import FullscreenModal from "../FullscreenModal"; | |||
| import MobileTimesheetTable from "../TimesheetTable/MobileTimesheetTable"; | |||
| import useIsMobile from "../utils/useIsMobile"; | |||
| interface Props { | |||
| isOpen: boolean; | |||
| @@ -108,12 +107,11 @@ const TimesheetModal: React.FC<Props> = ({ | |||
| [onClose], | |||
| ); | |||
| const theme = useTheme(); | |||
| const matches = useMediaQuery(theme.breakpoints.up("sm")); | |||
| const matches = useIsMobile(); | |||
| return ( | |||
| <FormProvider {...formProps}> | |||
| {matches ? ( | |||
| {!matches ? ( | |||
| // Desktop version | |||
| <Modal open={isOpen} onClose={onModalClose}> | |||
| <Card sx={modalSx}> | |||
| @@ -136,7 +136,7 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||
| )} | |||
| <Box> | |||
| <Button startIcon={<Add />} onClick={openEditModal()}> | |||
| {t("Record leave")} | |||
| {t("Record time")} | |||
| </Button> | |||
| </Box> | |||
| <TimesheetEditModal | |||
| @@ -91,14 +91,14 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||
| const valid = await trigger(); | |||
| if (valid) { | |||
| onSave(getValues()); | |||
| reset(); | |||
| reset({ id: Date.now() }); | |||
| } | |||
| }, [getValues, onSave, reset, trigger]); | |||
| const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | |||
| (...args) => { | |||
| onClose?.(...args); | |||
| reset(); | |||
| reset({ id: Date.now() }); | |||
| }, | |||
| [onClose, reset], | |||
| ); | |||
| @@ -2,14 +2,17 @@ | |||
| import React from "react"; | |||
| import TransferList, { TransferListProps } from "./TransferList"; | |||
| import { useMediaQuery, useTheme } from "@mui/material"; | |||
| import MultiSelectList from "./MultiSelectList"; | |||
| import useIsMobile from "../utils/useIsMobile"; | |||
| const TransferListWrapper: React.FC<TransferListProps> = (props) => { | |||
| const theme = useTheme(); | |||
| const matches = useMediaQuery(theme.breakpoints.up("sm")); | |||
| const matches = useIsMobile(); | |||
| return matches ? <TransferList {...props} /> : <MultiSelectList {...props} />; | |||
| return !matches ? ( | |||
| <TransferList {...props} /> | |||
| ) : ( | |||
| <MultiSelectList {...props} /> | |||
| ); | |||
| }; | |||
| export default TransferListWrapper; | |||
| @@ -99,6 +99,8 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| handleClose={handlePastEventClose} | |||
| timesheet={defaultTimesheets} | |||
| leaves={defaultLeaveRecords} | |||
| allProjects={allProjects} | |||
| leaveTypes={leaveTypes} | |||
| /> | |||
| <TimesheetModal | |||
| isOpen={isTimeheetModalVisible} | |||
| @@ -0,0 +1,10 @@ | |||
| import { Breakpoint, useMediaQuery, useTheme } from "@mui/material"; | |||
| const useIsMobile = (breakpoint: Breakpoint = "sm") => { | |||
| const theme = useTheme(); | |||
| const matches = useMediaQuery(theme.breakpoints.up(breakpoint)); | |||
| return !matches; | |||
| }; | |||
| export default useIsMobile; | |||