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