@@ -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; |