| @@ -0,0 +1,196 @@ | |||
| import { | |||
| RecordLeaveInput, | |||
| RecordTimesheetInput, | |||
| } from "@/app/api/timesheets/actions"; | |||
| import { | |||
| Box, | |||
| Card, | |||
| CardActionArea, | |||
| CardContent, | |||
| Stack, | |||
| Typography, | |||
| } from "@mui/material"; | |||
| import union from "lodash/union"; | |||
| import { useCallback, useMemo } from "react"; | |||
| import dayjs, { Dayjs } from "dayjs"; | |||
| import { getHolidayForDate } from "@/app/utils/holidayUtils"; | |||
| import { HolidaysResult } from "@/app/api/holidays"; | |||
| import { manhourFormatter, shortDateFormatter } from "@/app/utils/formatUtil"; | |||
| import { useTranslation } from "react-i18next"; | |||
| import pickBy from "lodash/pickBy"; | |||
| interface Props { | |||
| currentMonth: Dayjs; | |||
| timesheet: RecordTimesheetInput; | |||
| leaves: RecordLeaveInput; | |||
| companyHolidays: HolidaysResult[]; | |||
| onDateSelect: (date: string) => void; | |||
| } | |||
| const MonthlySummary: React.FC<Props> = ({ | |||
| timesheet, | |||
| leaves, | |||
| currentMonth, | |||
| companyHolidays, | |||
| onDateSelect, | |||
| }) => { | |||
| const { | |||
| t, | |||
| i18n: { language }, | |||
| } = useTranslation("home"); | |||
| const timesheetForCurrentMonth = useMemo(() => { | |||
| return pickBy(timesheet, (_, date) => { | |||
| return currentMonth.isSame(dayjs(date), "month"); | |||
| }); | |||
| }, [currentMonth, timesheet]); | |||
| const leavesForCurrentMonth = useMemo(() => { | |||
| return pickBy(leaves, (_, date) => { | |||
| return currentMonth.isSame(dayjs(date), "month"); | |||
| }); | |||
| }, [currentMonth, leaves]); | |||
| const days = useMemo(() => { | |||
| return union( | |||
| Object.keys(timesheetForCurrentMonth), | |||
| Object.keys(leavesForCurrentMonth), | |||
| ); | |||
| }, [timesheetForCurrentMonth, leavesForCurrentMonth]).sort(); | |||
| const makeSelectDate = useCallback( | |||
| (date: string) => () => { | |||
| onDateSelect(date); | |||
| }, | |||
| [onDateSelect], | |||
| ); | |||
| return ( | |||
| <Stack | |||
| gap={2} | |||
| marginBlockEnd={2} | |||
| minWidth={{ sm: 375 }} | |||
| maxHeight={{ sm: 500 }} | |||
| > | |||
| <Typography variant="overline">{t("Monthly Summary")}</Typography> | |||
| <Box sx={{ overflowY: "scroll" }} flex={1}> | |||
| {days.map((day, index) => { | |||
| const dayJsObj = dayjs(day); | |||
| const holiday = getHolidayForDate(day, companyHolidays); | |||
| const isHoliday = | |||
| holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6; | |||
| const ls = leavesForCurrentMonth[day]; | |||
| const leaveHours = | |||
| ls?.reduce((acc, entry) => acc + entry.inputHours, 0) || 0; | |||
| const ts = timesheetForCurrentMonth[day]; | |||
| const timesheetNormalHours = | |||
| ts?.reduce((acc, entry) => acc + (entry.inputHours || 0), 0) || 0; | |||
| const timesheetOtHours = | |||
| ts?.reduce((acc, entry) => acc + (entry.otHours || 0), 0) || 0; | |||
| const timesheetHours = timesheetNormalHours + timesheetOtHours; | |||
| 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: isHoliday ? "error.main" : undefined, | |||
| }} | |||
| > | |||
| {shortDateFormatter(language).format(dayJsObj.toDate())} | |||
| {holiday && ( | |||
| <Typography | |||
| marginInlineStart={1} | |||
| variant="caption" | |||
| >{`(${holiday.title})`}</Typography> | |||
| )} | |||
| </Typography> | |||
| <Stack spacing={1}> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| flexWrap: "wrap", | |||
| 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", | |||
| }} | |||
| > | |||
| <Typography variant="body2"> | |||
| {t("Leave Hours")} | |||
| </Typography> | |||
| <Typography> | |||
| {manhourFormatter.format(leaveHours)} | |||
| </Typography> | |||
| </Box> | |||
| <Box | |||
| sx={{ | |||
| display: "flex", | |||
| justifyContent: "space-between", | |||
| flexWrap: "wrap", | |||
| alignItems: "baseline", | |||
| }} | |||
| > | |||
| <Typography variant="body2"> | |||
| {t("Daily Total Hours")} | |||
| </Typography> | |||
| <Typography> | |||
| {manhourFormatter.format(timesheetHours + leaveHours)} | |||
| </Typography> | |||
| </Box> | |||
| </Stack> | |||
| </CardContent> | |||
| </CardActionArea> | |||
| </Card> | |||
| ); | |||
| })} | |||
| </Box> | |||
| <Typography variant="overline"> | |||
| {`${t("Total Monthly Work Hours")}: ${manhourFormatter.format( | |||
| Object.values(timesheetForCurrentMonth) | |||
| .flatMap((entries) => entries) | |||
| .map((entry) => (entry.inputHours ?? 0) + (entry.otHours ?? 0)) | |||
| .reduce((acc, cur) => { | |||
| return acc + cur; | |||
| }, 0), | |||
| )}`} | |||
| </Typography> | |||
| <Typography variant="overline"> | |||
| {`${t("Total Monthly Leave Hours")}: ${manhourFormatter.format( | |||
| Object.values(leavesForCurrentMonth) | |||
| .flatMap((entries) => entries) | |||
| .map((entry) => entry.inputHours) | |||
| .reduce((acc, cur) => { | |||
| return acc + cur; | |||
| }, 0), | |||
| )}`} | |||
| </Typography> | |||
| </Stack> | |||
| ); | |||
| }; | |||
| export default MonthlySummary; | |||
| @@ -26,6 +26,7 @@ export interface Props { | |||
| timesheet: RecordTimesheetInput; | |||
| leaves: RecordLeaveInput; | |||
| onDateSelect: (date: string) => void; | |||
| onMonthChange: (day: Dayjs) => void; | |||
| } | |||
| const getColor = ( | |||
| @@ -72,6 +73,7 @@ const PastEntryCalendar: React.FC<Props> = ({ | |||
| timesheet, | |||
| leaves, | |||
| onDateSelect, | |||
| onMonthChange, | |||
| }) => { | |||
| const { | |||
| i18n: { language }, | |||
| @@ -88,6 +90,7 @@ const PastEntryCalendar: React.FC<Props> = ({ | |||
| > | |||
| <DateCalendar | |||
| onChange={onChange} | |||
| onMonthChange={onMonthChange} | |||
| disableFuture | |||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | |||
| slots={{ day: EntryDay as any }} | |||
| @@ -20,12 +20,17 @@ import { ProjectWithTasks } from "@/app/api/projects"; | |||
| import { LeaveType } from "@/app/api/timesheets"; | |||
| import useIsMobile from "@/app/utils/useIsMobile"; | |||
| import FullscreenModal from "../FullscreenModal"; | |||
| import MonthlySummary from "./MonthlySummary"; | |||
| import { HolidaysResult } from "@/app/api/holidays"; | |||
| import dayjs from "dayjs"; | |||
| interface Props extends Omit<PastEntryCalendarProps, "onDateSelect"> { | |||
| interface Props | |||
| extends Omit<PastEntryCalendarProps, "onDateSelect" | "onMonthChange"> { | |||
| open: boolean; | |||
| handleClose: () => void; | |||
| leaveTypes: LeaveType[]; | |||
| allProjects: ProjectWithTasks[]; | |||
| companyHolidays: HolidaysResult[]; | |||
| } | |||
| const Indicator = styled(Box)(() => ({ | |||
| @@ -45,6 +50,7 @@ const PastEntryCalendarModal: React.FC<Props> = ({ | |||
| const { t } = useTranslation("home"); | |||
| const [selectedDate, setSelectedDate] = useState(""); | |||
| const [currentMonth, setMonthChange] = useState(dayjs()); | |||
| const clearDate = useCallback(() => { | |||
| setSelectedDate(""); | |||
| @@ -54,40 +60,52 @@ const PastEntryCalendarModal: React.FC<Props> = ({ | |||
| handleClose(); | |||
| }, [handleClose]); | |||
| const content = selectedDate ? ( | |||
| <> | |||
| <PastEntryList | |||
| date={selectedDate} | |||
| timesheet={timesheet} | |||
| leaves={leaves} | |||
| allProjects={allProjects} | |||
| leaveTypes={leaveTypes} | |||
| /> | |||
| </> | |||
| ) : ( | |||
| <> | |||
| <Stack marginBlockEnd={2}> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "info.light" }} /> | |||
| <Typography variant="caption">{t("Has timesheet entry")}</Typography> | |||
| </Box> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "warning.light" }} /> | |||
| <Typography variant="caption">{t("Has leave entry")}</Typography> | |||
| </Box> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "success.light" }} /> | |||
| <Typography variant="caption"> | |||
| {t("Has both timesheet and leave entry")} | |||
| </Typography> | |||
| </Box> | |||
| </Stack> | |||
| <PastEntryCalendar | |||
| timesheet={timesheet} | |||
| leaves={leaves} | |||
| onDateSelect={setSelectedDate} | |||
| /> | |||
| </> | |||
| const content = ( | |||
| <Box sx={{ display: "flex", flexDirection: { xs: "column", sm: "row" } }}> | |||
| <Box> | |||
| <Stack marginBlockEnd={2}> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "info.light" }} /> | |||
| <Typography variant="caption"> | |||
| {t("Has timesheet entry")} | |||
| </Typography> | |||
| </Box> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "warning.light" }} /> | |||
| <Typography variant="caption">{t("Has leave entry")}</Typography> | |||
| </Box> | |||
| <Box display="flex" alignItems="center" gap={1}> | |||
| <Indicator sx={{ backgroundColor: "success.light" }} /> | |||
| <Typography variant="caption"> | |||
| {t("Has both timesheet and leave entry")} | |||
| </Typography> | |||
| </Box> | |||
| </Stack> | |||
| <PastEntryCalendar | |||
| timesheet={timesheet} | |||
| leaves={leaves} | |||
| onDateSelect={setSelectedDate} | |||
| onMonthChange={setMonthChange} | |||
| /> | |||
| </Box> | |||
| {selectedDate ? ( | |||
| <PastEntryList | |||
| date={selectedDate} | |||
| timesheet={timesheet} | |||
| leaves={leaves} | |||
| allProjects={allProjects} | |||
| leaveTypes={leaveTypes} | |||
| /> | |||
| ) : ( | |||
| <MonthlySummary | |||
| currentMonth={currentMonth} | |||
| timesheet={timesheet} | |||
| leaves={leaves} | |||
| companyHolidays={[]} | |||
| onDateSelect={setSelectedDate} | |||
| /> | |||
| )} | |||
| </Box> | |||
| ); | |||
| const isMobile = useIsMobile(); | |||
| @@ -115,14 +133,14 @@ const PastEntryCalendarModal: React.FC<Props> = ({ | |||
| startIcon={<ArrowBack />} | |||
| onClick={clearDate} | |||
| > | |||
| {t("Back")} | |||
| {t("Back to Monthly Summary")} | |||
| </Button> | |||
| )} | |||
| </Box> | |||
| </Box> | |||
| </FullscreenModal> | |||
| ) : ( | |||
| <Dialog onClose={onClose} open={open}> | |||
| <Dialog onClose={onClose} open={open} maxWidth="md"> | |||
| <DialogTitle>{t("Past Entries")}</DialogTitle> | |||
| <DialogContent>{content}</DialogContent> | |||
| {selectedDate && ( | |||
| @@ -132,7 +150,7 @@ const PastEntryCalendarModal: React.FC<Props> = ({ | |||
| startIcon={<ArrowBack />} | |||
| onClick={clearDate} | |||
| > | |||
| {t("Back")} | |||
| {t("Back to Monthly Summary")} | |||
| </Button> | |||
| </DialogActions> | |||
| )} | |||
| @@ -57,7 +57,12 @@ const PastEntryList: React.FC<Props> = ({ | |||
| const dayJsObj = dayjs(date); | |||
| return ( | |||
| <Stack gap={2} marginBlockEnd={2} minWidth={{ sm: 375 }}> | |||
| <Stack | |||
| gap={2} | |||
| marginBlockEnd={2} | |||
| minWidth={{ sm: 375 }} | |||
| maxHeight={{ sm: 500 }} | |||
| > | |||
| <Typography | |||
| variant="overline" | |||
| color={dayJsObj.day() === 0 ? "error.main" : undefined} | |||
| @@ -94,17 +99,25 @@ const PastEntryList: React.FC<Props> = ({ | |||
| leaveTypeMap={leaveTypeMap} | |||
| /> | |||
| ))} | |||
| <Typography | |||
| variant="overline" | |||
| > | |||
| {t("Total Work Hours")}: {manhourFormatter.format(timeEntries.map(entry => (entry.inputHours ?? 0) + (entry.otHours ?? 0)).reduce((acc, cur) => { return acc + cur }, 0))} | |||
| </Typography> | |||
| <Typography | |||
| variant="overline" | |||
| > | |||
| {t("Total Leave Hours")}: {manhourFormatter.format(leaveEntries.map(entry => entry.inputHours).reduce((acc, cur) => { return acc + cur }, 0))} | |||
| </Typography> | |||
| </Box> | |||
| <Typography variant="overline"> | |||
| {`${t("Total Work Hours")}: ${manhourFormatter.format( | |||
| timeEntries | |||
| .map((entry) => (entry.inputHours ?? 0) + (entry.otHours ?? 0)) | |||
| .reduce((acc, cur) => { | |||
| return acc + cur; | |||
| }, 0), | |||
| )}`} | |||
| </Typography> | |||
| <Typography variant="overline"> | |||
| {`${t("Total Leave Hours")}: ${manhourFormatter.format( | |||
| leaveEntries | |||
| .map((entry) => entry.inputHours) | |||
| .reduce((acc, cur) => { | |||
| return acc + cur; | |||
| }, 0), | |||
| )}`} | |||
| </Typography> | |||
| </Stack> | |||
| ); | |||
| }; | |||
| @@ -102,7 +102,7 @@ const TimeLeaveInputTable: React.FC<Props> = ({ | |||
| }, {}); | |||
| }, [assignedProjects]); | |||
| const { getValues, setValue, clearErrors } = | |||
| const { getValues, setValue, clearErrors, setError } = | |||
| useFormContext<RecordTimeLeaveInput>(); | |||
| const currentEntries = getValues(day); | |||
| @@ -486,8 +486,13 @@ const TimeLeaveInputTable: React.FC<Props> = ({ | |||
| .filter((e): e is TimeLeaveEntry => Boolean(e)); | |||
| setValue(day, newEntries); | |||
| clearErrors(day); | |||
| }, [getValues, entries, setValue, day, clearErrors]); | |||
| if (entries.some((e) => e._isNew)) { | |||
| setError(day, { message: "There are some unsaved entries." }); | |||
| } else { | |||
| clearErrors(day); | |||
| } | |||
| }, [getValues, entries, setValue, day, clearErrors, setError]); | |||
| const hasOutOfPlannedStages = entries.some( | |||
| (entry) => entry._isPlanned !== undefined && !entry._isPlanned, | |||
| @@ -154,6 +154,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||
| leaves={defaultLeaveRecords} | |||
| allProjects={allProjects} | |||
| leaveTypes={leaveTypes} | |||
| companyHolidays={holidays} | |||
| /> | |||
| <TimeLeaveModal | |||
| fastEntryEnabled={fastEntryEnabled} | |||