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