| @@ -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; | timesheet: RecordTimesheetInput; | ||||
| leaves: RecordLeaveInput; | leaves: RecordLeaveInput; | ||||
| onDateSelect: (date: string) => void; | onDateSelect: (date: string) => void; | ||||
| onMonthChange: (day: Dayjs) => void; | |||||
| } | } | ||||
| const getColor = ( | const getColor = ( | ||||
| @@ -72,6 +73,7 @@ const PastEntryCalendar: React.FC<Props> = ({ | |||||
| timesheet, | timesheet, | ||||
| leaves, | leaves, | ||||
| onDateSelect, | onDateSelect, | ||||
| onMonthChange, | |||||
| }) => { | }) => { | ||||
| const { | const { | ||||
| i18n: { language }, | i18n: { language }, | ||||
| @@ -88,6 +90,7 @@ const PastEntryCalendar: React.FC<Props> = ({ | |||||
| > | > | ||||
| <DateCalendar | <DateCalendar | ||||
| onChange={onChange} | onChange={onChange} | ||||
| onMonthChange={onMonthChange} | |||||
| disableFuture | disableFuture | ||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||
| slots={{ day: EntryDay as any }} | slots={{ day: EntryDay as any }} | ||||
| @@ -20,12 +20,17 @@ import { ProjectWithTasks } from "@/app/api/projects"; | |||||
| import { LeaveType } from "@/app/api/timesheets"; | import { LeaveType } from "@/app/api/timesheets"; | ||||
| import useIsMobile from "@/app/utils/useIsMobile"; | import useIsMobile from "@/app/utils/useIsMobile"; | ||||
| import FullscreenModal from "../FullscreenModal"; | 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; | open: boolean; | ||||
| handleClose: () => void; | handleClose: () => void; | ||||
| leaveTypes: LeaveType[]; | leaveTypes: LeaveType[]; | ||||
| allProjects: ProjectWithTasks[]; | allProjects: ProjectWithTasks[]; | ||||
| companyHolidays: HolidaysResult[]; | |||||
| } | } | ||||
| const Indicator = styled(Box)(() => ({ | const Indicator = styled(Box)(() => ({ | ||||
| @@ -45,6 +50,7 @@ const PastEntryCalendarModal: React.FC<Props> = ({ | |||||
| const { t } = useTranslation("home"); | const { t } = useTranslation("home"); | ||||
| const [selectedDate, setSelectedDate] = useState(""); | const [selectedDate, setSelectedDate] = useState(""); | ||||
| const [currentMonth, setMonthChange] = useState(dayjs()); | |||||
| const clearDate = useCallback(() => { | const clearDate = useCallback(() => { | ||||
| setSelectedDate(""); | setSelectedDate(""); | ||||
| @@ -54,40 +60,52 @@ const PastEntryCalendarModal: React.FC<Props> = ({ | |||||
| handleClose(); | handleClose(); | ||||
| }, [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(); | const isMobile = useIsMobile(); | ||||
| @@ -115,14 +133,14 @@ const PastEntryCalendarModal: React.FC<Props> = ({ | |||||
| startIcon={<ArrowBack />} | startIcon={<ArrowBack />} | ||||
| onClick={clearDate} | onClick={clearDate} | ||||
| > | > | ||||
| {t("Back")} | |||||
| {t("Back to Monthly Summary")} | |||||
| </Button> | </Button> | ||||
| )} | )} | ||||
| </Box> | </Box> | ||||
| </Box> | </Box> | ||||
| </FullscreenModal> | </FullscreenModal> | ||||
| ) : ( | ) : ( | ||||
| <Dialog onClose={onClose} open={open}> | |||||
| <Dialog onClose={onClose} open={open} maxWidth="md"> | |||||
| <DialogTitle>{t("Past Entries")}</DialogTitle> | <DialogTitle>{t("Past Entries")}</DialogTitle> | ||||
| <DialogContent>{content}</DialogContent> | <DialogContent>{content}</DialogContent> | ||||
| {selectedDate && ( | {selectedDate && ( | ||||
| @@ -132,7 +150,7 @@ const PastEntryCalendarModal: React.FC<Props> = ({ | |||||
| startIcon={<ArrowBack />} | startIcon={<ArrowBack />} | ||||
| onClick={clearDate} | onClick={clearDate} | ||||
| > | > | ||||
| {t("Back")} | |||||
| {t("Back to Monthly Summary")} | |||||
| </Button> | </Button> | ||||
| </DialogActions> | </DialogActions> | ||||
| )} | )} | ||||
| @@ -57,7 +57,12 @@ const PastEntryList: React.FC<Props> = ({ | |||||
| const dayJsObj = dayjs(date); | const dayJsObj = dayjs(date); | ||||
| return ( | return ( | ||||
| <Stack gap={2} marginBlockEnd={2} minWidth={{ sm: 375 }}> | |||||
| <Stack | |||||
| gap={2} | |||||
| marginBlockEnd={2} | |||||
| minWidth={{ sm: 375 }} | |||||
| maxHeight={{ sm: 500 }} | |||||
| > | |||||
| <Typography | <Typography | ||||
| variant="overline" | variant="overline" | ||||
| color={dayJsObj.day() === 0 ? "error.main" : undefined} | color={dayJsObj.day() === 0 ? "error.main" : undefined} | ||||
| @@ -94,17 +99,25 @@ const PastEntryList: React.FC<Props> = ({ | |||||
| leaveTypeMap={leaveTypeMap} | 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> | </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> | </Stack> | ||||
| ); | ); | ||||
| }; | }; | ||||
| @@ -102,7 +102,7 @@ const TimeLeaveInputTable: React.FC<Props> = ({ | |||||
| }, {}); | }, {}); | ||||
| }, [assignedProjects]); | }, [assignedProjects]); | ||||
| const { getValues, setValue, clearErrors } = | |||||
| const { getValues, setValue, clearErrors, setError } = | |||||
| useFormContext<RecordTimeLeaveInput>(); | useFormContext<RecordTimeLeaveInput>(); | ||||
| const currentEntries = getValues(day); | const currentEntries = getValues(day); | ||||
| @@ -486,8 +486,13 @@ const TimeLeaveInputTable: React.FC<Props> = ({ | |||||
| .filter((e): e is TimeLeaveEntry => Boolean(e)); | .filter((e): e is TimeLeaveEntry => Boolean(e)); | ||||
| setValue(day, newEntries); | 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( | const hasOutOfPlannedStages = entries.some( | ||||
| (entry) => entry._isPlanned !== undefined && !entry._isPlanned, | (entry) => entry._isPlanned !== undefined && !entry._isPlanned, | ||||
| @@ -154,6 +154,7 @@ const UserWorkspacePage: React.FC<Props> = ({ | |||||
| leaves={defaultLeaveRecords} | leaves={defaultLeaveRecords} | ||||
| allProjects={allProjects} | allProjects={allProjects} | ||||
| leaveTypes={leaveTypes} | leaveTypes={leaveTypes} | ||||
| companyHolidays={holidays} | |||||
| /> | /> | ||||
| <TimeLeaveModal | <TimeLeaveModal | ||||
| fastEntryEnabled={fastEntryEnabled} | fastEntryEnabled={fastEntryEnabled} | ||||