@@ -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, | |||
SxProps, | |||
Typography, | |||
useMediaQuery, | |||
useTheme, | |||
} from "@mui/material"; | |||
import { useTranslation } from "react-i18next"; | |||
import { Check, Close } from "@mui/icons-material"; | |||
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 { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
import LeaveTable from "../LeaveTable"; | |||
import { LeaveType } from "@/app/api/timesheets"; | |||
import FullscreenModal from "../FullscreenModal"; | |||
import MobileLeaveTable from "../LeaveTable/MobileLeaveTable"; | |||
interface Props { | |||
isOpen: boolean; | |||
@@ -25,6 +33,7 @@ interface Props { | |||
username: string; | |||
defaultLeaveRecords?: RecordLeaveInput; | |||
leaveTypes: LeaveType[]; | |||
timesheetRecords: RecordTimesheetInput; | |||
} | |||
const modalSx: SxProps = { | |||
@@ -34,7 +43,7 @@ const modalSx: SxProps = { | |||
transform: "translate(-50%, -50%)", | |||
width: { xs: "calc(100% - 2rem)", sm: "90%" }, | |||
maxHeight: "90%", | |||
maxWidth: 1200, | |||
maxWidth: 1400, | |||
}; | |||
const LeaveModal: React.FC<Props> = ({ | |||
@@ -42,6 +51,7 @@ const LeaveModal: React.FC<Props> = ({ | |||
onClose, | |||
username, | |||
defaultLeaveRecords, | |||
timesheetRecords, | |||
leaveTypes, | |||
}) => { | |||
const { t } = useTranslation("home"); | |||
@@ -90,47 +100,80 @@ const LeaveModal: React.FC<Props> = ({ | |||
const onModalClose = useCallback<NonNullable<ModalProps["onClose"]>>( | |||
(_, reason) => { | |||
if (reason !== "backdropClick") { | |||
onClose(); | |||
onCancel(); | |||
} | |||
}, | |||
[onClose], | |||
[onCancel], | |||
); | |||
const theme = useTheme(); | |||
const matches = useMediaQuery(theme.breakpoints.up("sm")); | |||
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" | |||
onSubmit={formProps.handleSubmit(onSubmit)} | |||
> | |||
<Typography variant="overline" display="block" marginBlockEnd={1}> | |||
<Typography variant="h6" padding={2} flex="none"> | |||
{t("Record Leave")} | |||
</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", | |||
headerName: t("Hours"), | |||
width: 100, | |||
headerName: t("Leave Hours"), | |||
width: 150, | |||
editable: true, | |||
type: "number", | |||
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 { | |||
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 { useTranslation } from "react-i18next"; | |||
import LeaveEntryTable from "./LeaveEntryTable"; | |||
import { LeaveType } from "@/app/api/timesheets"; | |||
import { LEAVE_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils"; | |||
import DateHoursTable from "../DateHoursTable"; | |||
interface Props { | |||
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 currentInput = watch(); | |||
const days = Object.keys(currentInput); | |||
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, | |||
SxProps, | |||
Typography, | |||
useMediaQuery, | |||
useTheme, | |||
} from "@mui/material"; | |||
import TimesheetTable from "../TimesheetTable"; | |||
import { useTranslation } from "react-i18next"; | |||
import { Check, Close } from "@mui/icons-material"; | |||
import { FormProvider, SubmitHandler, useForm } from "react-hook-form"; | |||
import { | |||
RecordLeaveInput, | |||
RecordTimesheetInput, | |||
saveTimesheet, | |||
} from "@/app/api/timesheets/actions"; | |||
import dayjs from "dayjs"; | |||
import { INPUT_DATE_FORMAT } from "@/app/utils/formatUtil"; | |||
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||
import FullscreenModal from "../FullscreenModal"; | |||
import MobileTimesheetTable from "../TimesheetTable/MobileTimesheetTable"; | |||
interface Props { | |||
isOpen: boolean; | |||
@@ -29,6 +34,7 @@ interface Props { | |||
assignedProjects: AssignedProject[]; | |||
username: string; | |||
defaultTimesheets?: RecordTimesheetInput; | |||
leaveRecords: RecordLeaveInput; | |||
} | |||
const modalSx: SxProps = { | |||
@@ -38,7 +44,7 @@ const modalSx: SxProps = { | |||
transform: "translate(-50%, -50%)", | |||
width: { xs: "calc(100% - 2rem)", sm: "90%" }, | |||
maxHeight: "90%", | |||
maxWidth: 1200, | |||
maxWidth: 1400, | |||
}; | |||
const TimesheetModal: React.FC<Props> = ({ | |||
@@ -48,6 +54,7 @@ const TimesheetModal: React.FC<Props> = ({ | |||
assignedProjects, | |||
username, | |||
defaultTimesheets, | |||
leaveRecords, | |||
}) => { | |||
const { t } = useTranslation("home"); | |||
@@ -101,44 +108,76 @@ const TimesheetModal: React.FC<Props> = ({ | |||
[onClose], | |||
); | |||
const theme = useTheme(); | |||
const matches = useMediaQuery(theme.breakpoints.up("sm")); | |||
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" | |||
onSubmit={formProps.handleSubmit(onSubmit)} | |||
> | |||
<Typography variant="overline" display="block" marginBlockEnd={1}> | |||
<Typography variant="h6" padding={2} flex="none"> | |||
{t("Timesheet Input")} | |||
</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 { | |||
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 { useTranslation } from "react-i18next"; | |||
import EntryInputTable from "./EntryInputTable"; | |||
import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; | |||
import { TIMESHEET_DAILY_MAX_HOURS } from "@/app/api/timesheets/utils"; | |||
import DateHoursTable from "../DateHoursTable"; | |||
interface Props { | |||
allProjects: ProjectWithTasks[]; | |||
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 currentInput = watch(); | |||
const days = Object.keys(currentInput); | |||
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} | |||
username={username} | |||
defaultTimesheets={defaultTimesheets} | |||
leaveRecords={defaultLeaveRecords} | |||
/> | |||
<LeaveModal | |||
leaveTypes={leaveTypes} | |||
isOpen={isLeaveModalVisible} | |||
onClose={handleCloseLeaveModal} | |||
defaultLeaveRecords={defaultLeaveRecords} | |||
timesheetRecords={defaultTimesheets} | |||
username={username} | |||
/> | |||
{assignedProjects.length > 0 ? ( | |||