@@ -9,8 +9,6 @@ import { | |||||
ModalProps, | ModalProps, | ||||
SxProps, | SxProps, | ||||
Typography, | Typography, | ||||
useMediaQuery, | |||||
useTheme, | |||||
} from "@mui/material"; | } from "@mui/material"; | ||||
import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
import { Check, Close } from "@mui/icons-material"; | import { Check, Close } from "@mui/icons-material"; | ||||
@@ -26,6 +24,7 @@ import LeaveTable from "../LeaveTable"; | |||||
import { LeaveType } from "@/app/api/timesheets"; | import { LeaveType } from "@/app/api/timesheets"; | ||||
import FullscreenModal from "../FullscreenModal"; | import FullscreenModal from "../FullscreenModal"; | ||||
import MobileLeaveTable from "../LeaveTable/MobileLeaveTable"; | import MobileLeaveTable from "../LeaveTable/MobileLeaveTable"; | ||||
import useIsMobile from "../utils/useIsMobile"; | |||||
interface Props { | interface Props { | ||||
isOpen: boolean; | isOpen: boolean; | ||||
@@ -106,12 +105,11 @@ const LeaveModal: React.FC<Props> = ({ | |||||
[onCancel], | [onCancel], | ||||
); | ); | ||||
const theme = useTheme(); | |||||
const matches = useMediaQuery(theme.breakpoints.up("sm")); | |||||
const matches = useIsMobile(); | |||||
return ( | return ( | ||||
<FormProvider {...formProps}> | <FormProvider {...formProps}> | ||||
{matches ? ( | |||||
{!matches ? ( | |||||
// Desktop version | // Desktop version | ||||
<Modal open={isOpen} onClose={onModalClose}> | <Modal open={isOpen} onClose={onModalClose}> | ||||
<Card sx={modalSx}> | <Card sx={modalSx}> | ||||
@@ -47,7 +47,11 @@ const LeaveEditModal: React.FC<Props> = ({ | |||||
}) => { | }) => { | ||||
const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
const { register, control, reset, getValues, trigger, formState } = | const { register, control, reset, getValues, trigger, formState } = | ||||
useForm<LeaveEntry>(); | |||||
useForm<LeaveEntry>({ | |||||
defaultValues: { | |||||
leaveTypeId: leaveTypes[0].id, | |||||
}, | |||||
}); | |||||
useEffect(() => { | useEffect(() => { | ||||
reset(defaultValues ?? { leaveTypeId: leaveTypes[0].id, id: Date.now() }); | reset(defaultValues ?? { leaveTypeId: leaveTypes[0].id, id: Date.now() }); | ||||
@@ -57,14 +61,14 @@ const LeaveEditModal: React.FC<Props> = ({ | |||||
const valid = await trigger(); | const valid = await trigger(); | ||||
if (valid) { | if (valid) { | ||||
onSave(getValues()); | onSave(getValues()); | ||||
reset(); | |||||
reset({ id: Date.now() }); | |||||
} | } | ||||
}, [getValues, onSave, reset, trigger]); | }, [getValues, onSave, reset, trigger]); | ||||
const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | ||||
(...args) => { | (...args) => { | ||||
onClose?.(...args); | onClose?.(...args); | ||||
reset(); | |||||
reset({ id: Date.now() }); | |||||
}, | }, | ||||
[onClose, reset], | [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 { useFormContext } from "react-hook-form"; | ||||
import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
import LeaveEditModal, { Props as LeaveEditModalProps } from "./LeaveEditModal"; | import LeaveEditModal, { Props as LeaveEditModalProps } from "./LeaveEditModal"; | ||||
import LeaveEntryCard from "./LeaveEntryCard"; | |||||
interface Props { | interface Props { | ||||
date: string; | date: string; | ||||
@@ -105,60 +106,12 @@ const MobileLeaveEntry: React.FC<Props> = ({ date, leaveTypes }) => { | |||||
{currentEntries.length ? ( | {currentEntries.length ? ( | ||||
currentEntries.map((entry, index) => { | currentEntries.map((entry, index) => { | ||||
return ( | return ( | ||||
<Card | |||||
<LeaveEntryCard | |||||
key={`${entry.id}-${index}`} | 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 { useCallback, useState } from "react"; | ||||
import { useTranslation } from "react-i18next"; | import { useTranslation } from "react-i18next"; | ||||
import { ArrowBack } from "@mui/icons-material"; | 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"> { | interface Props extends Omit<PastEntryCalendarProps, "onDateSelect"> { | ||||
open: boolean; | open: boolean; | ||||
handleClose: () => void; | handleClose: () => void; | ||||
leaveTypes: LeaveType[]; | |||||
allProjects: ProjectWithTasks[]; | |||||
} | } | ||||
const Indicator = styled(Box)(() => ({ | const Indicator = styled(Box)(() => ({ | ||||
@@ -32,6 +39,8 @@ const PastEntryCalendarModal: React.FC<Props> = ({ | |||||
open, | open, | ||||
timesheet, | timesheet, | ||||
leaves, | leaves, | ||||
leaveTypes, | |||||
allProjects, | |||||
}) => { | }) => { | ||||
const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
@@ -45,42 +54,75 @@ const PastEntryCalendarModal: React.FC<Props> = ({ | |||||
handleClose(); | handleClose(); | ||||
}, [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}> | <Dialog onClose={onClose} open={open}> | ||||
<DialogTitle>{t("Past Entries")}</DialogTitle> | <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 && ( | {selectedDate && ( | ||||
<DialogActions> | <DialogActions> | ||||
<Button | <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, | ModalProps, | ||||
SxProps, | SxProps, | ||||
Typography, | Typography, | ||||
useMediaQuery, | |||||
useTheme, | |||||
} from "@mui/material"; | } from "@mui/material"; | ||||
import TimesheetTable from "../TimesheetTable"; | import TimesheetTable from "../TimesheetTable"; | ||||
import { useTranslation } from "react-i18next"; | 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 { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | ||||
import FullscreenModal from "../FullscreenModal"; | import FullscreenModal from "../FullscreenModal"; | ||||
import MobileTimesheetTable from "../TimesheetTable/MobileTimesheetTable"; | import MobileTimesheetTable from "../TimesheetTable/MobileTimesheetTable"; | ||||
import useIsMobile from "../utils/useIsMobile"; | |||||
interface Props { | interface Props { | ||||
isOpen: boolean; | isOpen: boolean; | ||||
@@ -108,12 +107,11 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
[onClose], | [onClose], | ||||
); | ); | ||||
const theme = useTheme(); | |||||
const matches = useMediaQuery(theme.breakpoints.up("sm")); | |||||
const matches = useIsMobile(); | |||||
return ( | return ( | ||||
<FormProvider {...formProps}> | <FormProvider {...formProps}> | ||||
{matches ? ( | |||||
{!matches ? ( | |||||
// Desktop version | // Desktop version | ||||
<Modal open={isOpen} onClose={onModalClose}> | <Modal open={isOpen} onClose={onModalClose}> | ||||
<Card sx={modalSx}> | <Card sx={modalSx}> | ||||
@@ -136,7 +136,7 @@ const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
)} | )} | ||||
<Box> | <Box> | ||||
<Button startIcon={<Add />} onClick={openEditModal()}> | <Button startIcon={<Add />} onClick={openEditModal()}> | ||||
{t("Record leave")} | |||||
{t("Record time")} | |||||
</Button> | </Button> | ||||
</Box> | </Box> | ||||
<TimesheetEditModal | <TimesheetEditModal | ||||
@@ -91,14 +91,14 @@ const TimesheetEditModal: React.FC<Props> = ({ | |||||
const valid = await trigger(); | const valid = await trigger(); | ||||
if (valid) { | if (valid) { | ||||
onSave(getValues()); | onSave(getValues()); | ||||
reset(); | |||||
reset({ id: Date.now() }); | |||||
} | } | ||||
}, [getValues, onSave, reset, trigger]); | }, [getValues, onSave, reset, trigger]); | ||||
const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | ||||
(...args) => { | (...args) => { | ||||
onClose?.(...args); | onClose?.(...args); | ||||
reset(); | |||||
reset({ id: Date.now() }); | |||||
}, | }, | ||||
[onClose, reset], | [onClose, reset], | ||||
); | ); | ||||
@@ -2,14 +2,17 @@ | |||||
import React from "react"; | import React from "react"; | ||||
import TransferList, { TransferListProps } from "./TransferList"; | import TransferList, { TransferListProps } from "./TransferList"; | ||||
import { useMediaQuery, useTheme } from "@mui/material"; | |||||
import MultiSelectList from "./MultiSelectList"; | import MultiSelectList from "./MultiSelectList"; | ||||
import useIsMobile from "../utils/useIsMobile"; | |||||
const TransferListWrapper: React.FC<TransferListProps> = (props) => { | 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; | export default TransferListWrapper; |
@@ -99,6 +99,8 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
handleClose={handlePastEventClose} | handleClose={handlePastEventClose} | ||||
timesheet={defaultTimesheets} | timesheet={defaultTimesheets} | ||||
leaves={defaultLeaveRecords} | leaves={defaultLeaveRecords} | ||||
allProjects={allProjects} | |||||
leaveTypes={leaveTypes} | |||||
/> | /> | ||||
<TimesheetModal | <TimesheetModal | ||||
isOpen={isTimeheetModalVisible} | 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; |