@@ -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} | ||||