| @@ -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 ? ( | |||