@@ -0,0 +1,205 @@ | |||||
import { | |||||
RecordLeaveInput, | |||||
RecordTimesheetInput, | |||||
} from "@/app/api/timesheets/actions"; | |||||
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; | |||||
import { ArrowBack, Check } from "@mui/icons-material"; | |||||
import { | |||||
Box, | |||||
Button, | |||||
Card, | |||||
CardActionArea, | |||||
CardContent, | |||||
Stack, | |||||
Typography, | |||||
} from "@mui/material"; | |||||
import dayjs from "dayjs"; | |||||
import React, { useCallback, useState } from "react"; | |||||
import { useTranslation } from "react-i18next"; | |||||
import { | |||||
LEAVE_DAILY_MAX_HOURS, | |||||
TIMESHEET_DAILY_MAX_HOURS, | |||||
} from "@/app/api/timesheets/utils"; | |||||
interface Props<EntryComponentProps = object> { | |||||
days: string[]; | |||||
leaveEntries: RecordLeaveInput; | |||||
timesheetEntries: RecordTimesheetInput; | |||||
EntryComponent: React.FunctionComponent< | |||||
EntryComponentProps & { date: string } | |||||
>; | |||||
entryComponentProps: EntryComponentProps; | |||||
} | |||||
function DateHoursList<EntryTableProps>({ | |||||
days, | |||||
leaveEntries, | |||||
timesheetEntries, | |||||
EntryComponent, | |||||
entryComponentProps, | |||||
}: Props<EntryTableProps>) { | |||||
const { | |||||
t, | |||||
i18n: { language }, | |||||
} = useTranslation("home"); | |||||
const [selectedDate, setSelectedDate] = useState(""); | |||||
const isDateSelected = selectedDate !== ""; | |||||
const makeSelectDate = useCallback( | |||||
(date: string) => () => { | |||||
setSelectedDate(date); | |||||
}, | |||||
[], | |||||
); | |||||
const onDateDone = useCallback<React.MouseEventHandler<HTMLButtonElement>>( | |||||
(e) => { | |||||
setSelectedDate(""); | |||||
e.preventDefault(); | |||||
}, | |||||
[], | |||||
); | |||||
return ( | |||||
<> | |||||
{isDateSelected ? ( | |||||
<EntryComponent date={selectedDate} {...entryComponentProps} /> | |||||
) : ( | |||||
<Box overflow="scroll" flex={1}> | |||||
{days.map((day, index) => { | |||||
const dayJsObj = dayjs(day); | |||||
const leaves = leaveEntries[day]; | |||||
const leaveHours = | |||||
leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; | |||||
const timesheet = timesheetEntries[day]; | |||||
const timesheetHours = | |||||
timesheet?.reduce( | |||||
(acc, entry) => | |||||
acc + (entry.inputHours || 0) + (entry.otHours || 0), | |||||
0, | |||||
) || 0; | |||||
const dailyTotal = leaveHours + timesheetHours; | |||||
const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS; | |||||
const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS; | |||||
return ( | |||||
<Card | |||||
key={`${day}-${index}`} | |||||
sx={{ marginBlockEnd: 2, marginInline: 2 }} | |||||
> | |||||
<CardActionArea onClick={makeSelectDate(day)}> | |||||
<CardContent sx={{ padding: 3 }}> | |||||
<Typography | |||||
variant="overline" | |||||
component="div" | |||||
sx={{ | |||||
color: dayJsObj.day() === 0 ? "error.main" : undefined, | |||||
}} | |||||
> | |||||
{shortDateFormatter(language).format(dayJsObj.toDate())} | |||||
</Typography> | |||||
<Stack spacing={1}> | |||||
<Box | |||||
sx={{ | |||||
display: "flex", | |||||
justifyContent: "space-between", | |||||
alignItems: "baseline", | |||||
}} | |||||
> | |||||
<Typography variant="body2"> | |||||
{t("Timesheet Hours")} | |||||
</Typography> | |||||
<Typography> | |||||
{manhourFormatter.format(timesheetHours)} | |||||
</Typography> | |||||
</Box> | |||||
<Box | |||||
sx={{ | |||||
display: "flex", | |||||
justifyContent: "space-between", | |||||
flexWrap: "wrap", | |||||
alignItems: "baseline", | |||||
color: leaveExceeded ? "error.main" : undefined, | |||||
}} | |||||
> | |||||
<Typography variant="body2"> | |||||
{t("Leave Hours")} | |||||
</Typography> | |||||
<Typography> | |||||
{manhourFormatter.format(leaveHours)} | |||||
</Typography> | |||||
{leaveExceeded && ( | |||||
<Typography | |||||
component="div" | |||||
width="100%" | |||||
variant="caption" | |||||
> | |||||
{t("Leave hours cannot be more than {{hours}}", { | |||||
hours: LEAVE_DAILY_MAX_HOURS, | |||||
})} | |||||
</Typography> | |||||
)} | |||||
</Box> | |||||
<Box | |||||
sx={{ | |||||
display: "flex", | |||||
justifyContent: "space-between", | |||||
flexWrap: "wrap", | |||||
alignItems: "baseline", | |||||
color: dailyTotalExceeded ? "error.main" : undefined, | |||||
}} | |||||
> | |||||
<Typography variant="body2"> | |||||
{t("Daily Total Hours")} | |||||
</Typography> | |||||
<Typography> | |||||
{manhourFormatter.format(timesheetHours + leaveHours)} | |||||
</Typography> | |||||
{dailyTotalExceeded && ( | |||||
<Typography | |||||
component="div" | |||||
width="100%" | |||||
variant="caption" | |||||
> | |||||
{t( | |||||
"The daily total hours cannot be more than {{hours}}", | |||||
{ | |||||
hours: TIMESHEET_DAILY_MAX_HOURS, | |||||
}, | |||||
)} | |||||
</Typography> | |||||
)} | |||||
</Box> | |||||
</Stack> | |||||
</CardContent> | |||||
</CardActionArea> | |||||
</Card> | |||||
); | |||||
})} | |||||
</Box> | |||||
)} | |||||
<Box padding={2} display="flex" justifyContent="flex-end"> | |||||
{isDateSelected ? ( | |||||
<Button | |||||
variant="outlined" | |||||
startIcon={<ArrowBack />} | |||||
onClick={onDateDone} | |||||
> | |||||
{t("Done")} | |||||
</Button> | |||||
) : ( | |||||
<Button variant="contained" startIcon={<Check />} type="submit"> | |||||
{t("Save")} | |||||
</Button> | |||||
)} | |||||
</Box> | |||||
</> | |||||
); | |||||
} | |||||
export default DateHoursList; |
@@ -0,0 +1,196 @@ | |||||
import { | |||||
RecordLeaveInput, | |||||
RecordTimesheetInput, | |||||
} from "@/app/api/timesheets/actions"; | |||||
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; | |||||
import { Info, KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material"; | |||||
import { | |||||
Box, | |||||
Collapse, | |||||
IconButton, | |||||
Table, | |||||
TableBody, | |||||
TableCell, | |||||
TableContainer, | |||||
TableHead, | |||||
TableRow, | |||||
Tooltip, | |||||
} from "@mui/material"; | |||||
import dayjs from "dayjs"; | |||||
import React, { useState } from "react"; | |||||
import { useTranslation } from "react-i18next"; | |||||
import { | |||||
LEAVE_DAILY_MAX_HOURS, | |||||
TIMESHEET_DAILY_MAX_HOURS, | |||||
} from "@/app/api/timesheets/utils"; | |||||
interface Props<EntryTableProps = object> { | |||||
days: string[]; | |||||
leaveEntries: RecordLeaveInput; | |||||
timesheetEntries: RecordTimesheetInput; | |||||
EntryTableComponent: React.FunctionComponent< | |||||
EntryTableProps & { day: string } | |||||
>; | |||||
entryTableProps: EntryTableProps; | |||||
} | |||||
function DateHoursTable<EntryTableProps>({ | |||||
days, | |||||
EntryTableComponent, | |||||
entryTableProps, | |||||
leaveEntries, | |||||
timesheetEntries, | |||||
}: Props<EntryTableProps>) { | |||||
const { t } = useTranslation("home"); | |||||
return ( | |||||
<TableContainer sx={{ maxHeight: 400 }}> | |||||
<Table stickyHeader> | |||||
<TableHead> | |||||
<TableRow> | |||||
<TableCell /> | |||||
<TableCell>{t("Date")}</TableCell> | |||||
<TableCell>{t("Timesheet Hours")}</TableCell> | |||||
<TableCell>{t("Leave Hours")}</TableCell> | |||||
<TableCell>{t("Daily Total Hours")}</TableCell> | |||||
</TableRow> | |||||
</TableHead> | |||||
<TableBody> | |||||
{days.map((day, index) => { | |||||
return ( | |||||
<DayRow | |||||
key={`${day}${index}`} | |||||
day={day} | |||||
leaveEntries={leaveEntries} | |||||
timesheetEntries={timesheetEntries} | |||||
EntryTableComponent={EntryTableComponent} | |||||
entryTableProps={entryTableProps} | |||||
/> | |||||
); | |||||
})} | |||||
</TableBody> | |||||
</Table> | |||||
</TableContainer> | |||||
); | |||||
} | |||||
function DayRow<EntryTableProps>({ | |||||
day, | |||||
leaveEntries, | |||||
timesheetEntries, | |||||
entryTableProps, | |||||
EntryTableComponent, | |||||
}: { | |||||
day: string; | |||||
leaveEntries: RecordLeaveInput; | |||||
timesheetEntries: RecordTimesheetInput; | |||||
EntryTableComponent: React.FunctionComponent< | |||||
EntryTableProps & { day: string } | |||||
>; | |||||
entryTableProps: EntryTableProps; | |||||
}) { | |||||
const { | |||||
t, | |||||
i18n: { language }, | |||||
} = useTranslation("home"); | |||||
const dayJsObj = dayjs(day); | |||||
const [open, setOpen] = useState(false); | |||||
const leaves = leaveEntries[day]; | |||||
const leaveHours = | |||||
leaves?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; | |||||
const timesheet = timesheetEntries[day]; | |||||
const timesheetHours = | |||||
timesheet?.reduce( | |||||
(acc, entry) => acc + (entry.inputHours || 0) + (entry.otHours || 0), | |||||
0, | |||||
) || 0; | |||||
const dailyTotal = leaveHours + timesheetHours; | |||||
const leaveExceeded = leaveHours > LEAVE_DAILY_MAX_HOURS; | |||||
const dailyTotalExceeded = dailyTotal > TIMESHEET_DAILY_MAX_HOURS; | |||||
return ( | |||||
<> | |||||
<TableRow> | |||||
<TableCell align="center" width={70}> | |||||
<IconButton | |||||
disableRipple | |||||
aria-label="expand row" | |||||
size="small" | |||||
onClick={() => setOpen(!open)} | |||||
> | |||||
{open ? <KeyboardArrowUp /> : <KeyboardArrowDown />} | |||||
</IconButton> | |||||
</TableCell> | |||||
<TableCell | |||||
sx={{ color: dayJsObj.day() === 0 ? "error.main" : undefined }} | |||||
> | |||||
{shortDateFormatter(language).format(dayJsObj.toDate())} | |||||
</TableCell> | |||||
{/* Timesheet */} | |||||
<TableCell>{manhourFormatter.format(timesheetHours)}</TableCell> | |||||
{/* Leave total */} | |||||
<TableCell | |||||
sx={{ | |||||
color: leaveExceeded ? "error.main" : undefined, | |||||
}} | |||||
> | |||||
<Box display="flex" gap={1} alignItems="center"> | |||||
{manhourFormatter.format(leaveHours)} | |||||
{leaveExceeded && ( | |||||
<Tooltip | |||||
title={t("Leave hours cannot be more than {{hours}}", { | |||||
hours: LEAVE_DAILY_MAX_HOURS, | |||||
})} | |||||
> | |||||
<Info fontSize="small" /> | |||||
</Tooltip> | |||||
)} | |||||
</Box> | |||||
</TableCell> | |||||
{/* Daily total */} | |||||
<TableCell | |||||
sx={{ | |||||
color: dailyTotalExceeded ? "error.main" : undefined, | |||||
}} | |||||
> | |||||
<Box display="flex" gap={1} alignItems="center"> | |||||
{manhourFormatter.format(dailyTotal)} | |||||
{dailyTotalExceeded && ( | |||||
<Tooltip | |||||
title={t( | |||||
"The daily total hours cannot be more than {{hours}}", | |||||
{ | |||||
hours: TIMESHEET_DAILY_MAX_HOURS, | |||||
}, | |||||
)} | |||||
> | |||||
<Info fontSize="small" /> | |||||
</Tooltip> | |||||
)} | |||||
</Box> | |||||
</TableCell> | |||||
</TableRow> | |||||
<TableRow> | |||||
<TableCell | |||||
sx={{ | |||||
p: 0, | |||||
border: "none", | |||||
outline: open ? "1px solid" : undefined, | |||||
outlineColor: "primary.main", | |||||
}} | |||||
colSpan={5} | |||||
> | |||||
<Collapse in={open} timeout="auto" unmountOnExit> | |||||
<Box>{<EntryTableComponent day={day} {...entryTableProps} />}</Box> | |||||
</Collapse> | |||||
</TableCell> | |||||
</TableRow> | |||||
</> | |||||
); | |||||
} | |||||
export default DateHoursTable; |
@@ -0,0 +1 @@ | |||||
export { default } from "./DateHoursTable"; |
@@ -0,0 +1,46 @@ | |||||
import { Close } from "@mui/icons-material"; | |||||
import { | |||||
Box, | |||||
IconButton, | |||||
Modal, | |||||
ModalProps, | |||||
Paper, | |||||
Slide, | |||||
} from "@mui/material"; | |||||
interface Props extends ModalProps { | |||||
closeModal: () => void; | |||||
} | |||||
const FullscreenModal: React.FC<Props> = ({ | |||||
children, | |||||
closeModal, | |||||
...props | |||||
}) => { | |||||
return ( | |||||
<Modal {...props}> | |||||
<Slide in={props.open} direction="up"> | |||||
<Paper | |||||
sx={{ | |||||
width: "100%", | |||||
height: "100%", | |||||
borderRadius: 0, | |||||
display: "flex", | |||||
flexDirection: "column", | |||||
}} | |||||
> | |||||
<Box color="text.primary" flex="none"> | |||||
<IconButton onClick={closeModal} color="inherit" size="large"> | |||||
<Close /> | |||||
</IconButton> | |||||
</Box> | |||||
<Box flex={1} overflow="hidden"> | |||||
{children} | |||||
</Box> | |||||
</Paper> | |||||
</Slide> | |||||
</Modal> | |||||
); | |||||
}; | |||||
export default FullscreenModal; |
@@ -0,0 +1 @@ | |||||
export { default } from "./FullscreenModal"; |
@@ -9,15 +9,23 @@ 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"; | ||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | ||||
import { RecordLeaveInput, saveLeave } from "@/app/api/timesheets/actions"; | |||||
import { | |||||
RecordLeaveInput, | |||||
RecordTimesheetInput, | |||||
saveLeave, | |||||
} from "@/app/api/timesheets/actions"; | |||||
import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | ||||
import LeaveTable from "../LeaveTable"; | import LeaveTable from "../LeaveTable"; | ||||
import { LeaveType } from "@/app/api/timesheets"; | import { LeaveType } from "@/app/api/timesheets"; | ||||
import FullscreenModal from "../FullscreenModal"; | |||||
import MobileLeaveTable from "../LeaveTable/MobileLeaveTable"; | |||||
interface Props { | interface Props { | ||||
isOpen: boolean; | isOpen: boolean; | ||||
@@ -25,6 +33,7 @@ interface Props { | |||||
username: string; | username: string; | ||||
defaultLeaveRecords?: RecordLeaveInput; | defaultLeaveRecords?: RecordLeaveInput; | ||||
leaveTypes: LeaveType[]; | leaveTypes: LeaveType[]; | ||||
timesheetRecords: RecordTimesheetInput; | |||||
} | } | ||||
const modalSx: SxProps = { | const modalSx: SxProps = { | ||||
@@ -34,7 +43,7 @@ const modalSx: SxProps = { | |||||
transform: "translate(-50%, -50%)", | transform: "translate(-50%, -50%)", | ||||
width: { xs: "calc(100% - 2rem)", sm: "90%" }, | width: { xs: "calc(100% - 2rem)", sm: "90%" }, | ||||
maxHeight: "90%", | maxHeight: "90%", | ||||
maxWidth: 1200, | |||||
maxWidth: 1400, | |||||
}; | }; | ||||
const LeaveModal: React.FC<Props> = ({ | const LeaveModal: React.FC<Props> = ({ | ||||
@@ -42,6 +51,7 @@ const LeaveModal: React.FC<Props> = ({ | |||||
onClose, | onClose, | ||||
username, | username, | ||||
defaultLeaveRecords, | defaultLeaveRecords, | ||||
timesheetRecords, | |||||
leaveTypes, | leaveTypes, | ||||
}) => { | }) => { | ||||
const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
@@ -90,47 +100,80 @@ const LeaveModal: React.FC<Props> = ({ | |||||
const onModalClose = useCallback<NonNullable<ModalProps["onClose"]>>( | const onModalClose = useCallback<NonNullable<ModalProps["onClose"]>>( | ||||
(_, reason) => { | (_, reason) => { | ||||
if (reason !== "backdropClick") { | if (reason !== "backdropClick") { | ||||
onClose(); | |||||
onCancel(); | |||||
} | } | ||||
}, | }, | ||||
[onClose], | |||||
[onCancel], | |||||
); | ); | ||||
const theme = useTheme(); | |||||
const matches = useMediaQuery(theme.breakpoints.up("sm")); | |||||
return ( | return ( | ||||
<Modal open={isOpen} onClose={onModalClose}> | |||||
<Card sx={modalSx}> | |||||
<FormProvider {...formProps}> | |||||
<CardContent | |||||
<FormProvider {...formProps}> | |||||
{matches ? ( | |||||
// Desktop version | |||||
<Modal open={isOpen} onClose={onModalClose}> | |||||
<Card sx={modalSx}> | |||||
<CardContent | |||||
component="form" | |||||
onSubmit={formProps.handleSubmit(onSubmit)} | |||||
> | |||||
<Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
{t("Record Leave")} | |||||
</Typography> | |||||
<Box | |||||
sx={{ | |||||
marginInline: -3, | |||||
marginBlock: 4, | |||||
}} | |||||
> | |||||
<LeaveTable | |||||
leaveTypes={leaveTypes} | |||||
timesheetRecords={timesheetRecords} | |||||
/> | |||||
</Box> | |||||
<CardActions sx={{ justifyContent: "flex-end" }}> | |||||
<Button | |||||
variant="outlined" | |||||
startIcon={<Close />} | |||||
onClick={onCancel} | |||||
> | |||||
{t("Cancel")} | |||||
</Button> | |||||
<Button variant="contained" startIcon={<Check />} type="submit"> | |||||
{t("Save")} | |||||
</Button> | |||||
</CardActions> | |||||
</CardContent> | |||||
</Card> | |||||
</Modal> | |||||
) : ( | |||||
// Mobile version | |||||
<FullscreenModal | |||||
open={isOpen} | |||||
onClose={onModalClose} | |||||
closeModal={onCancel} | |||||
> | |||||
<Box | |||||
display="flex" | |||||
flexDirection="column" | |||||
gap={2} | |||||
height="100%" | |||||
component="form" | component="form" | ||||
onSubmit={formProps.handleSubmit(onSubmit)} | onSubmit={formProps.handleSubmit(onSubmit)} | ||||
> | > | ||||
<Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
<Typography variant="h6" padding={2} flex="none"> | |||||
{t("Record Leave")} | {t("Record Leave")} | ||||
</Typography> | </Typography> | ||||
<Box | |||||
sx={{ | |||||
marginInline: -3, | |||||
marginBlock: 4, | |||||
}} | |||||
> | |||||
<LeaveTable leaveTypes={leaveTypes} /> | |||||
</Box> | |||||
<CardActions sx={{ justifyContent: "flex-end" }}> | |||||
<Button | |||||
variant="outlined" | |||||
startIcon={<Close />} | |||||
onClick={onCancel} | |||||
> | |||||
{t("Cancel")} | |||||
</Button> | |||||
<Button variant="contained" startIcon={<Check />} type="submit"> | |||||
{t("Save")} | |||||
</Button> | |||||
</CardActions> | |||||
</CardContent> | |||||
</FormProvider> | |||||
</Card> | |||||
</Modal> | |||||
<MobileLeaveTable | |||||
leaveTypes={leaveTypes} | |||||
timesheetRecords={timesheetRecords} | |||||
/> | |||||
</Box> | |||||
</FullscreenModal> | |||||
)} | |||||
</FormProvider> | |||||
); | ); | ||||
}; | }; | ||||
@@ -0,0 +1,132 @@ | |||||
import { LeaveType } from "@/app/api/timesheets"; | |||||
import { LeaveEntry } from "@/app/api/timesheets/actions"; | |||||
import { Check, Delete } from "@mui/icons-material"; | |||||
import { | |||||
Box, | |||||
Button, | |||||
FormControl, | |||||
InputLabel, | |||||
MenuItem, | |||||
Modal, | |||||
ModalProps, | |||||
Paper, | |||||
Select, | |||||
SxProps, | |||||
TextField, | |||||
} from "@mui/material"; | |||||
import React, { useCallback, useEffect } from "react"; | |||||
import { Controller, useForm } from "react-hook-form"; | |||||
import { useTranslation } from "react-i18next"; | |||||
export interface Props extends Omit<ModalProps, "children"> { | |||||
onSave: (leaveEntry: LeaveEntry) => void; | |||||
onDelete?: () => void; | |||||
leaveTypes: LeaveType[]; | |||||
defaultValues?: Partial<LeaveEntry>; | |||||
} | |||||
const modalSx: SxProps = { | |||||
position: "absolute", | |||||
top: "50%", | |||||
left: "50%", | |||||
transform: "translate(-50%, -50%)", | |||||
width: "90%", | |||||
maxHeight: "90%", | |||||
padding: 3, | |||||
display: "flex", | |||||
flexDirection: "column", | |||||
gap: 2, | |||||
}; | |||||
const LeaveEditModal: React.FC<Props> = ({ | |||||
onSave, | |||||
onDelete, | |||||
open, | |||||
onClose, | |||||
leaveTypes, | |||||
defaultValues, | |||||
}) => { | |||||
const { t } = useTranslation("home"); | |||||
const { register, control, reset, getValues, trigger, formState } = | |||||
useForm<LeaveEntry>(); | |||||
useEffect(() => { | |||||
reset(defaultValues ?? { leaveTypeId: leaveTypes[0].id, id: Date.now() }); | |||||
}, [defaultValues, leaveTypes, reset]); | |||||
const saveHandler = useCallback(async () => { | |||||
const valid = await trigger(); | |||||
if (valid) { | |||||
onSave(getValues()); | |||||
} | |||||
}, [getValues, onSave, trigger]); | |||||
const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | |||||
(...args) => { | |||||
onClose?.(...args); | |||||
reset(); | |||||
}, | |||||
[onClose, reset], | |||||
); | |||||
return ( | |||||
<Modal open={open} onClose={closeHandler}> | |||||
<Paper sx={modalSx}> | |||||
<FormControl fullWidth> | |||||
<InputLabel>{t("Leave Type")}</InputLabel> | |||||
<Controller | |||||
defaultValue={leaveTypes[0].id} | |||||
control={control} | |||||
name="leaveTypeId" | |||||
render={({ field }) => ( | |||||
<Select label={t("Leave Type")} {...field}> | |||||
{leaveTypes.map((type, index) => ( | |||||
<MenuItem key={`${type.id}-${index}`} value={type.id}> | |||||
{type.name} | |||||
</MenuItem> | |||||
))} | |||||
</Select> | |||||
)} | |||||
/> | |||||
</FormControl> | |||||
<TextField | |||||
type="number" | |||||
label={t("Hours")} | |||||
fullWidth | |||||
{...register("inputHours", { | |||||
valueAsNumber: true, | |||||
validate: (value) => value > 0, | |||||
})} | |||||
error={Boolean(formState.errors.inputHours)} | |||||
/> | |||||
<TextField | |||||
label={t("Remark")} | |||||
fullWidth | |||||
multiline | |||||
rows={2} | |||||
{...register("remark")} | |||||
/> | |||||
<Box display="flex" justifyContent="flex-end" gap={1}> | |||||
{onDelete && ( | |||||
<Button | |||||
variant="outlined" | |||||
startIcon={<Delete />} | |||||
color="error" | |||||
onClick={onDelete} | |||||
> | |||||
{t("Delete")} | |||||
</Button> | |||||
)} | |||||
<Button | |||||
variant="contained" | |||||
startIcon={<Check />} | |||||
onClick={saveHandler} | |||||
> | |||||
{t("Save")} | |||||
</Button> | |||||
</Box> | |||||
</Paper> | |||||
</Modal> | |||||
); | |||||
}; | |||||
export default LeaveEditModal; |
@@ -169,8 +169,8 @@ const EntryInputTable: React.FC<Props> = ({ day, leaveTypes }) => { | |||||
}, | }, | ||||
{ | { | ||||
field: "inputHours", | field: "inputHours", | ||||
headerName: t("Hours"), | |||||
width: 100, | |||||
headerName: t("Leave Hours"), | |||||
width: 150, | |||||
editable: true, | editable: true, | ||||
type: "number", | type: "number", | ||||
valueFormatter(params) { | valueFormatter(params) { | ||||
@@ -1,136 +1,31 @@ | |||||
import { RecordLeaveInput, LeaveEntry } from "@/app/api/timesheets/actions"; | |||||
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; | |||||
import { KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material"; | |||||
import { | import { | ||||
Box, | |||||
Collapse, | |||||
IconButton, | |||||
Table, | |||||
TableBody, | |||||
TableCell, | |||||
TableContainer, | |||||
TableHead, | |||||
TableRow, | |||||
Typography, | |||||
} from "@mui/material"; | |||||
import dayjs from "dayjs"; | |||||
import React, { useState } from "react"; | |||||
RecordLeaveInput, | |||||
RecordTimesheetInput, | |||||
} from "@/app/api/timesheets/actions"; | |||||
import React from "react"; | |||||
import { useFormContext } from "react-hook-form"; | import { useFormContext } from "react-hook-form"; | ||||
import { useTranslation } from "react-i18next"; | |||||
import LeaveEntryTable from "./LeaveEntryTable"; | import LeaveEntryTable from "./LeaveEntryTable"; | ||||
import { LeaveType } from "@/app/api/timesheets"; | import { LeaveType } from "@/app/api/timesheets"; | ||||
import { LEAVE_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils"; | |||||
import DateHoursTable from "../DateHoursTable"; | |||||
interface Props { | interface Props { | ||||
leaveTypes: LeaveType[]; | leaveTypes: LeaveType[]; | ||||
timesheetRecords: RecordTimesheetInput; | |||||
} | } | ||||
const LeaveTable: React.FC<Props> = ({ leaveTypes }) => { | |||||
const { t } = useTranslation("home"); | |||||
const LeaveTable: React.FC<Props> = ({ leaveTypes, timesheetRecords }) => { | |||||
const { watch } = useFormContext<RecordLeaveInput>(); | const { watch } = useFormContext<RecordLeaveInput>(); | ||||
const currentInput = watch(); | const currentInput = watch(); | ||||
const days = Object.keys(currentInput); | const days = Object.keys(currentInput); | ||||
return ( | return ( | ||||
<TableContainer sx={{ maxHeight: 400 }}> | |||||
<Table stickyHeader> | |||||
<TableHead> | |||||
<TableRow> | |||||
<TableCell /> | |||||
<TableCell>{t("Date")}</TableCell> | |||||
<TableCell>{t("Daily Total Hours")}</TableCell> | |||||
</TableRow> | |||||
</TableHead> | |||||
<TableBody> | |||||
{days.map((day, index) => { | |||||
const entries = currentInput[day]; | |||||
return ( | |||||
<DayRow | |||||
key={`${day}${index}`} | |||||
day={day} | |||||
entries={entries} | |||||
leaveTypes={leaveTypes} | |||||
/> | |||||
); | |||||
})} | |||||
</TableBody> | |||||
</Table> | |||||
</TableContainer> | |||||
); | |||||
}; | |||||
const DayRow: React.FC<{ | |||||
day: string; | |||||
entries: LeaveEntry[]; | |||||
leaveTypes: LeaveType[]; | |||||
}> = ({ day, entries, leaveTypes }) => { | |||||
const { | |||||
t, | |||||
i18n: { language }, | |||||
} = useTranslation("home"); | |||||
const dayJsObj = dayjs(day); | |||||
const [open, setOpen] = useState(false); | |||||
const totalHours = entries.reduce((acc, entry) => acc + entry.inputHours, 0); | |||||
return ( | |||||
<> | |||||
<TableRow> | |||||
<TableCell align="center" width={70}> | |||||
<IconButton | |||||
disableRipple | |||||
aria-label="expand row" | |||||
size="small" | |||||
onClick={() => setOpen(!open)} | |||||
> | |||||
{open ? <KeyboardArrowUp /> : <KeyboardArrowDown />} | |||||
</IconButton> | |||||
</TableCell> | |||||
<TableCell | |||||
sx={{ color: dayJsObj.day() === 0 ? "error.main" : undefined }} | |||||
> | |||||
{shortDateFormatter(language).format(dayJsObj.toDate())} | |||||
</TableCell> | |||||
<TableCell | |||||
sx={{ | |||||
color: | |||||
totalHours > LEAVE_DAILY_MAX_HOURS ? "error.main" : undefined, | |||||
}} | |||||
> | |||||
{manhourFormatter.format(totalHours)} | |||||
{totalHours > LEAVE_DAILY_MAX_HOURS && ( | |||||
<Typography | |||||
color="error.main" | |||||
variant="body2" | |||||
component="span" | |||||
sx={{ marginInlineStart: 1 }} | |||||
> | |||||
{t("(the daily total hours cannot be more than {{hours}})", { | |||||
hours: LEAVE_DAILY_MAX_HOURS, | |||||
})} | |||||
</Typography> | |||||
)} | |||||
</TableCell> | |||||
</TableRow> | |||||
<TableRow> | |||||
<TableCell | |||||
sx={{ | |||||
p: 0, | |||||
border: "none", | |||||
outline: open ? "1px solid" : undefined, | |||||
outlineColor: "primary.main", | |||||
}} | |||||
colSpan={3} | |||||
> | |||||
<Collapse in={open} timeout="auto" unmountOnExit> | |||||
<Box> | |||||
<LeaveEntryTable day={day} leaveTypes={leaveTypes} /> | |||||
</Box> | |||||
</Collapse> | |||||
</TableCell> | |||||
</TableRow> | |||||
</> | |||||
<DateHoursTable | |||||
days={days} | |||||
leaveEntries={currentInput} | |||||
timesheetEntries={timesheetRecords} | |||||
EntryTableComponent={LeaveEntryTable} | |||||
entryTableProps={{ leaveTypes }} | |||||
/> | |||||
); | ); | ||||
}; | }; | ||||
@@ -0,0 +1,179 @@ | |||||
import { LeaveType } from "@/app/api/timesheets"; | |||||
import { LeaveEntry, RecordLeaveInput } from "@/app/api/timesheets/actions"; | |||||
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; | |||||
import { Add, Edit } from "@mui/icons-material"; | |||||
import { | |||||
Box, | |||||
Button, | |||||
Card, | |||||
CardContent, | |||||
IconButton, | |||||
Typography, | |||||
} from "@mui/material"; | |||||
import dayjs from "dayjs"; | |||||
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"; | |||||
interface Props { | |||||
date: string; | |||||
leaveTypes: LeaveType[]; | |||||
} | |||||
const MobileLeaveEntry: React.FC<Props> = ({ date, leaveTypes }) => { | |||||
const { | |||||
t, | |||||
i18n: { language }, | |||||
} = useTranslation("home"); | |||||
const dayJsObj = dayjs(date); | |||||
const leaveTypeMap = useMemo<{ [id: LeaveType["id"]]: LeaveType }>(() => { | |||||
return leaveTypes.reduce( | |||||
(acc, leaveType) => ({ ...acc, [leaveType.id]: leaveType }), | |||||
{}, | |||||
); | |||||
}, [leaveTypes]); | |||||
const { watch, setValue } = useFormContext<RecordLeaveInput>(); | |||||
const currentEntries = watch(date); | |||||
// Edit modal | |||||
const [editModalProps, setEditModalProps] = useState< | |||||
Partial<LeaveEditModalProps> | |||||
>({}); | |||||
const [editModalOpen, setEditModalOpen] = useState(false); | |||||
const openEditModal = useCallback( | |||||
(defaultValues?: LeaveEntry) => () => { | |||||
setEditModalProps({ | |||||
defaultValues, | |||||
onDelete: defaultValues | |||||
? () => { | |||||
setValue( | |||||
date, | |||||
currentEntries.filter((entry) => entry.id !== defaultValues.id), | |||||
); | |||||
setEditModalOpen(false); | |||||
} | |||||
: undefined, | |||||
}); | |||||
setEditModalOpen(true); | |||||
}, | |||||
[currentEntries, date, setValue], | |||||
); | |||||
const closeEditModal = useCallback(() => { | |||||
setEditModalOpen(false); | |||||
}, []); | |||||
const onSaveEntry = useCallback( | |||||
(entry: LeaveEntry) => { | |||||
const existingEntry = currentEntries.find((e) => e.id === entry.id); | |||||
if (existingEntry) { | |||||
setValue( | |||||
date, | |||||
currentEntries.map((e) => ({ | |||||
...(e.id === existingEntry.id ? entry : e), | |||||
})), | |||||
); | |||||
} else { | |||||
setValue(date, [...currentEntries, entry]); | |||||
} | |||||
setEditModalOpen(false); | |||||
}, | |||||
[currentEntries, date, setValue], | |||||
); | |||||
return ( | |||||
<Box | |||||
marginInline={2} | |||||
flex={1} | |||||
display="flex" | |||||
flexDirection="column" | |||||
gap={2} | |||||
> | |||||
<Typography | |||||
variant="overline" | |||||
color={dayJsObj.day() === 0 ? "error.main" : undefined} | |||||
> | |||||
{shortDateFormatter(language).format(dayJsObj.toDate())} | |||||
</Typography> | |||||
{currentEntries.length ? ( | |||||
currentEntries.map((entry, index) => { | |||||
return ( | |||||
<Card key={`${entry.id}-${index}`} sx={{ marginInline: 1 }}> | |||||
<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> | |||||
); | |||||
}) | |||||
) : ( | |||||
<Typography variant="body2" display="block"> | |||||
{t("Add some leave entries!")} | |||||
</Typography> | |||||
)} | |||||
<Box> | |||||
<Button startIcon={<Add />} onClick={openEditModal()}> | |||||
{t("Record leave")} | |||||
</Button> | |||||
</Box> | |||||
<LeaveEditModal | |||||
leaveTypes={leaveTypes} | |||||
open={editModalOpen} | |||||
onClose={closeEditModal} | |||||
onSave={onSaveEntry} | |||||
{...editModalProps} | |||||
/> | |||||
</Box> | |||||
); | |||||
}; | |||||
export default MobileLeaveEntry; |
@@ -0,0 +1,35 @@ | |||||
import { | |||||
RecordLeaveInput, | |||||
RecordTimesheetInput, | |||||
} from "@/app/api/timesheets/actions"; | |||||
import React from "react"; | |||||
import { useFormContext } from "react-hook-form"; | |||||
import { LeaveType } from "@/app/api/timesheets"; | |||||
import MobileLeaveEntry from "./MobileLeaveEntry"; | |||||
import DateHoursList from "../DateHoursTable/DateHoursList"; | |||||
interface Props { | |||||
leaveTypes: LeaveType[]; | |||||
timesheetRecords: RecordTimesheetInput; | |||||
} | |||||
const MobileLeaveTable: React.FC<Props> = ({ | |||||
timesheetRecords, | |||||
leaveTypes, | |||||
}) => { | |||||
const { watch } = useFormContext<RecordLeaveInput>(); | |||||
const currentInput = watch(); | |||||
const days = Object.keys(currentInput); | |||||
return ( | |||||
<DateHoursList | |||||
days={days} | |||||
leaveEntries={currentInput} | |||||
timesheetEntries={timesheetRecords} | |||||
EntryComponent={MobileLeaveEntry} | |||||
entryComponentProps={{ leaveTypes }} | |||||
/> | |||||
); | |||||
}; | |||||
export default MobileLeaveTable; |
@@ -9,18 +9,23 @@ 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"; | ||||
import { Check, Close } from "@mui/icons-material"; | import { Check, Close } from "@mui/icons-material"; | ||||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | ||||
import { | import { | ||||
RecordLeaveInput, | |||||
RecordTimesheetInput, | RecordTimesheetInput, | ||||
saveTimesheet, | saveTimesheet, | ||||
} from "@/app/api/timesheets/actions"; | } from "@/app/api/timesheets/actions"; | ||||
import dayjs from "dayjs"; | import dayjs from "dayjs"; | ||||
import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | 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 MobileTimesheetTable from "../TimesheetTable/MobileTimesheetTable"; | |||||
interface Props { | interface Props { | ||||
isOpen: boolean; | isOpen: boolean; | ||||
@@ -29,6 +34,7 @@ interface Props { | |||||
assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
username: string; | username: string; | ||||
defaultTimesheets?: RecordTimesheetInput; | defaultTimesheets?: RecordTimesheetInput; | ||||
leaveRecords: RecordLeaveInput; | |||||
} | } | ||||
const modalSx: SxProps = { | const modalSx: SxProps = { | ||||
@@ -38,7 +44,7 @@ const modalSx: SxProps = { | |||||
transform: "translate(-50%, -50%)", | transform: "translate(-50%, -50%)", | ||||
width: { xs: "calc(100% - 2rem)", sm: "90%" }, | width: { xs: "calc(100% - 2rem)", sm: "90%" }, | ||||
maxHeight: "90%", | maxHeight: "90%", | ||||
maxWidth: 1200, | |||||
maxWidth: 1400, | |||||
}; | }; | ||||
const TimesheetModal: React.FC<Props> = ({ | const TimesheetModal: React.FC<Props> = ({ | ||||
@@ -48,6 +54,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
assignedProjects, | assignedProjects, | ||||
username, | username, | ||||
defaultTimesheets, | defaultTimesheets, | ||||
leaveRecords, | |||||
}) => { | }) => { | ||||
const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
@@ -101,44 +108,76 @@ const TimesheetModal: React.FC<Props> = ({ | |||||
[onClose], | [onClose], | ||||
); | ); | ||||
const theme = useTheme(); | |||||
const matches = useMediaQuery(theme.breakpoints.up("sm")); | |||||
return ( | return ( | ||||
<Modal open={isOpen} onClose={onModalClose}> | |||||
<Card sx={modalSx}> | |||||
<FormProvider {...formProps}> | |||||
<CardContent | |||||
<FormProvider {...formProps}> | |||||
{matches ? ( | |||||
// Desktop version | |||||
<Modal open={isOpen} onClose={onModalClose}> | |||||
<Card sx={modalSx}> | |||||
<CardContent | |||||
component="form" | |||||
onSubmit={formProps.handleSubmit(onSubmit)} | |||||
> | |||||
<Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
{t("Timesheet Input")} | |||||
</Typography> | |||||
<Box | |||||
sx={{ | |||||
marginInline: -3, | |||||
marginBlock: 4, | |||||
}} | |||||
> | |||||
<TimesheetTable | |||||
assignedProjects={assignedProjects} | |||||
allProjects={allProjects} | |||||
leaveRecords={leaveRecords} | |||||
/> | |||||
</Box> | |||||
<CardActions sx={{ justifyContent: "flex-end" }}> | |||||
<Button | |||||
variant="outlined" | |||||
startIcon={<Close />} | |||||
onClick={onCancel} | |||||
> | |||||
{t("Cancel")} | |||||
</Button> | |||||
<Button variant="contained" startIcon={<Check />} type="submit"> | |||||
{t("Save")} | |||||
</Button> | |||||
</CardActions> | |||||
</CardContent> | |||||
</Card> | |||||
</Modal> | |||||
) : ( | |||||
// Mobile version | |||||
<FullscreenModal | |||||
open={isOpen} | |||||
onClose={onModalClose} | |||||
closeModal={onCancel} | |||||
> | |||||
<Box | |||||
display="flex" | |||||
flexDirection="column" | |||||
gap={2} | |||||
height="100%" | |||||
component="form" | component="form" | ||||
onSubmit={formProps.handleSubmit(onSubmit)} | onSubmit={formProps.handleSubmit(onSubmit)} | ||||
> | > | ||||
<Typography variant="overline" display="block" marginBlockEnd={1}> | |||||
<Typography variant="h6" padding={2} flex="none"> | |||||
{t("Timesheet Input")} | {t("Timesheet Input")} | ||||
</Typography> | </Typography> | ||||
<Box | |||||
sx={{ | |||||
marginInline: -3, | |||||
marginBlock: 4, | |||||
}} | |||||
> | |||||
<TimesheetTable | |||||
assignedProjects={assignedProjects} | |||||
allProjects={allProjects} | |||||
/> | |||||
</Box> | |||||
<CardActions sx={{ justifyContent: "flex-end" }}> | |||||
<Button | |||||
variant="outlined" | |||||
startIcon={<Close />} | |||||
onClick={onCancel} | |||||
> | |||||
{t("Cancel")} | |||||
</Button> | |||||
<Button variant="contained" startIcon={<Check />} type="submit"> | |||||
{t("Save")} | |||||
</Button> | |||||
</CardActions> | |||||
</CardContent> | |||||
</FormProvider> | |||||
</Card> | |||||
</Modal> | |||||
<MobileTimesheetTable | |||||
assignedProjects={assignedProjects} | |||||
allProjects={allProjects} | |||||
leaveRecords={leaveRecords} | |||||
/> | |||||
</Box> | |||||
</FullscreenModal> | |||||
)} | |||||
</FormProvider> | |||||
); | ); | ||||
}; | }; | ||||
@@ -0,0 +1,40 @@ | |||||
import { TimeEntry, RecordTimesheetInput } from "@/app/api/timesheets/actions"; | |||||
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; | |||||
import { Add, Edit } from "@mui/icons-material"; | |||||
import { | |||||
Box, | |||||
Button, | |||||
Card, | |||||
CardContent, | |||||
IconButton, | |||||
Typography, | |||||
} from "@mui/material"; | |||||
import dayjs from "dayjs"; | |||||
import React from "react"; | |||||
import { useFormContext } from "react-hook-form"; | |||||
import { useTranslation } from "react-i18next"; | |||||
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||||
interface Props { | |||||
date: string; | |||||
allProjects: ProjectWithTasks[]; | |||||
assignedProjects: AssignedProject[]; | |||||
} | |||||
const MobileTimesheetEntry: React.FC<Props> = ({ | |||||
date, | |||||
allProjects, | |||||
assignedProjects, | |||||
}) => { | |||||
const { | |||||
t, | |||||
i18n: { language }, | |||||
} = useTranslation("home"); | |||||
const dayJsObj = dayjs(date); | |||||
const { watch, setValue } = useFormContext<RecordTimesheetInput>(); | |||||
const currentEntries = watch(date); | |||||
return null; | |||||
}; | |||||
export default MobileTimesheetEntry; |
@@ -0,0 +1,37 @@ | |||||
import { | |||||
RecordLeaveInput, | |||||
RecordTimesheetInput, | |||||
} from "@/app/api/timesheets/actions"; | |||||
import React from "react"; | |||||
import { useFormContext } from "react-hook-form"; | |||||
import DateHoursList from "../DateHoursTable/DateHoursList"; | |||||
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||||
import MobileTimesheetEntry from "./MobileTimesheetEntry"; | |||||
interface Props { | |||||
allProjects: ProjectWithTasks[]; | |||||
assignedProjects: AssignedProject[]; | |||||
leaveRecords: RecordLeaveInput; | |||||
} | |||||
const MobileTimesheetTable: React.FC<Props> = ({ | |||||
allProjects, | |||||
assignedProjects, | |||||
leaveRecords, | |||||
}) => { | |||||
const { watch } = useFormContext<RecordTimesheetInput>(); | |||||
const currentInput = watch(); | |||||
const days = Object.keys(currentInput); | |||||
return ( | |||||
<DateHoursList | |||||
days={days} | |||||
leaveEntries={leaveRecords} | |||||
timesheetEntries={currentInput} | |||||
EntryComponent={MobileTimesheetEntry} | |||||
entryComponentProps={{ allProjects, assignedProjects }} | |||||
/> | |||||
); | |||||
}; | |||||
export default MobileTimesheetTable; |
@@ -1,146 +1,36 @@ | |||||
import { RecordTimesheetInput, TimeEntry } from "@/app/api/timesheets/actions"; | |||||
import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; | |||||
import { KeyboardArrowDown, KeyboardArrowUp } from "@mui/icons-material"; | |||||
import { | import { | ||||
Box, | |||||
Collapse, | |||||
IconButton, | |||||
Table, | |||||
TableBody, | |||||
TableCell, | |||||
TableContainer, | |||||
TableHead, | |||||
TableRow, | |||||
Typography, | |||||
} from "@mui/material"; | |||||
import dayjs from "dayjs"; | |||||
import React, { useState } from "react"; | |||||
RecordLeaveInput, | |||||
RecordTimesheetInput, | |||||
} from "@/app/api/timesheets/actions"; | |||||
import React from "react"; | |||||
import { useFormContext } from "react-hook-form"; | import { useFormContext } from "react-hook-form"; | ||||
import { useTranslation } from "react-i18next"; | |||||
import EntryInputTable from "./EntryInputTable"; | import EntryInputTable from "./EntryInputTable"; | ||||
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | ||||
import { TIMESHEET_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils"; | |||||
import DateHoursTable from "../DateHoursTable"; | |||||
interface Props { | interface Props { | ||||
allProjects: ProjectWithTasks[]; | allProjects: ProjectWithTasks[]; | ||||
assignedProjects: AssignedProject[]; | assignedProjects: AssignedProject[]; | ||||
leaveRecords: RecordLeaveInput; | |||||
} | } | ||||
const TimesheetTable: React.FC<Props> = ({ allProjects, assignedProjects }) => { | |||||
const { t } = useTranslation("home"); | |||||
const TimesheetTable: React.FC<Props> = ({ | |||||
allProjects, | |||||
assignedProjects, | |||||
leaveRecords, | |||||
}) => { | |||||
const { watch } = useFormContext<RecordTimesheetInput>(); | const { watch } = useFormContext<RecordTimesheetInput>(); | ||||
const currentInput = watch(); | const currentInput = watch(); | ||||
const days = Object.keys(currentInput); | const days = Object.keys(currentInput); | ||||
return ( | return ( | ||||
<TableContainer sx={{ maxHeight: 400 }}> | |||||
<Table stickyHeader> | |||||
<TableHead> | |||||
<TableRow> | |||||
<TableCell /> | |||||
<TableCell>{t("Date")}</TableCell> | |||||
<TableCell>{t("Daily Total Hours")}</TableCell> | |||||
</TableRow> | |||||
</TableHead> | |||||
<TableBody> | |||||
{days.map((day, index) => { | |||||
const entries = currentInput[day]; | |||||
return ( | |||||
<DayRow | |||||
key={`${day}${index}`} | |||||
day={day} | |||||
entries={entries} | |||||
allProjects={allProjects} | |||||
assignedProjects={assignedProjects} | |||||
/> | |||||
); | |||||
})} | |||||
</TableBody> | |||||
</Table> | |||||
</TableContainer> | |||||
); | |||||
}; | |||||
const DayRow: React.FC<{ | |||||
day: string; | |||||
entries: TimeEntry[]; | |||||
allProjects: ProjectWithTasks[]; | |||||
assignedProjects: AssignedProject[]; | |||||
}> = ({ day, entries, allProjects, assignedProjects }) => { | |||||
const { | |||||
t, | |||||
i18n: { language }, | |||||
} = useTranslation("home"); | |||||
const dayJsObj = dayjs(day); | |||||
const [open, setOpen] = useState(false); | |||||
const totalHours = entries.reduce( | |||||
(acc, entry) => acc + (entry.inputHours || 0) + (entry.otHours || 0), | |||||
0, | |||||
); | |||||
return ( | |||||
<> | |||||
<TableRow> | |||||
<TableCell align="center" width={70}> | |||||
<IconButton | |||||
disableRipple | |||||
aria-label="expand row" | |||||
size="small" | |||||
onClick={() => setOpen(!open)} | |||||
> | |||||
{open ? <KeyboardArrowUp /> : <KeyboardArrowDown />} | |||||
</IconButton> | |||||
</TableCell> | |||||
<TableCell | |||||
sx={{ color: dayJsObj.day() === 0 ? "error.main" : undefined }} | |||||
> | |||||
{shortDateFormatter(language).format(dayJsObj.toDate())} | |||||
</TableCell> | |||||
<TableCell | |||||
sx={{ | |||||
color: | |||||
totalHours > TIMESHEET_DAILY_MAX_HOURS ? "error.main" : undefined, | |||||
}} | |||||
> | |||||
{manhourFormatter.format(totalHours)} | |||||
{totalHours > TIMESHEET_DAILY_MAX_HOURS && ( | |||||
<Typography | |||||
color="error.main" | |||||
variant="body2" | |||||
component="span" | |||||
sx={{ marginInlineStart: 1 }} | |||||
> | |||||
{t("(the daily total hours cannot be more than {{hours}})", { | |||||
hours: TIMESHEET_DAILY_MAX_HOURS, | |||||
})} | |||||
</Typography> | |||||
)} | |||||
</TableCell> | |||||
</TableRow> | |||||
<TableRow> | |||||
<TableCell | |||||
sx={{ | |||||
p: 0, | |||||
border: "none", | |||||
outline: open ? "1px solid" : undefined, | |||||
outlineColor: "primary.main", | |||||
}} | |||||
colSpan={3} | |||||
> | |||||
<Collapse in={open} timeout="auto" unmountOnExit> | |||||
<Box> | |||||
<EntryInputTable | |||||
day={day} | |||||
assignedProjects={assignedProjects} | |||||
allProjects={allProjects} | |||||
/> | |||||
</Box> | |||||
</Collapse> | |||||
</TableCell> | |||||
</TableRow> | |||||
</> | |||||
<DateHoursTable | |||||
days={days} | |||||
leaveEntries={leaveRecords} | |||||
timesheetEntries={currentInput} | |||||
EntryTableComponent={EntryInputTable} | |||||
entryTableProps={{ assignedProjects, allProjects }} | |||||
/> | |||||
); | ); | ||||
}; | }; | ||||
@@ -88,12 +88,14 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
assignedProjects={assignedProjects} | assignedProjects={assignedProjects} | ||||
username={username} | username={username} | ||||
defaultTimesheets={defaultTimesheets} | defaultTimesheets={defaultTimesheets} | ||||
leaveRecords={defaultLeaveRecords} | |||||
/> | /> | ||||
<LeaveModal | <LeaveModal | ||||
leaveTypes={leaveTypes} | leaveTypes={leaveTypes} | ||||
isOpen={isLeaveModalVisible} | isOpen={isLeaveModalVisible} | ||||
onClose={handleCloseLeaveModal} | onClose={handleCloseLeaveModal} | ||||
defaultLeaveRecords={defaultLeaveRecords} | defaultLeaveRecords={defaultLeaveRecords} | ||||
timesheetRecords={defaultTimesheets} | |||||
username={username} | username={username} | ||||
/> | /> | ||||
{assignedProjects.length > 0 ? ( | {assignedProjects.length > 0 ? ( | ||||