No puede seleccionar más de 25 temas Los temas deben comenzar con una letra o número, pueden incluir guiones ('-') y pueden tener hasta 35 caracteres de largo.
 
 

421 líneas
13 KiB

  1. import React, { useCallback, useEffect, useMemo, useState } from "react";
  2. import { HolidaysResult } from "@/app/api/holidays";
  3. import { LeaveType, TeamLeaves, TeamTimeSheets } from "@/app/api/timesheets";
  4. import dayGridPlugin from "@fullcalendar/daygrid";
  5. import interactionPlugin from "@fullcalendar/interaction";
  6. import { Autocomplete, Stack, TextField, useTheme } from "@mui/material";
  7. import { useTranslation } from "react-i18next";
  8. import transform from "lodash/transform";
  9. import {
  10. getHolidayForDate,
  11. getPublicHolidaysForNYears,
  12. } from "@/app/utils/holidayUtils";
  13. import {
  14. INPUT_DATE_FORMAT,
  15. convertDateArrayToString,
  16. } from "@/app/utils/formatUtil";
  17. import StyledFullCalendar from "../StyledFullCalendar";
  18. import { ProjectWithTasks } from "@/app/api/projects";
  19. import {
  20. LeaveEntry,
  21. TimeEntry,
  22. deleteMemberEntry,
  23. deleteMemberLeave,
  24. saveMemberEntry,
  25. saveMemberLeave,
  26. } from "@/app/api/timesheets/actions";
  27. import TimesheetEditModal, {
  28. Props as TimesheetEditModalProps,
  29. } from "../TimesheetTable/TimesheetEditModal";
  30. import { Props as LeaveEditModalProps } from "../LeaveTable/LeaveEditModal";
  31. import LeaveEditModal from "../LeaveTable/LeaveEditModal";
  32. import dayjs from "dayjs";
  33. import { checkTotalHours } from "@/app/api/timesheets/utils";
  34. import unionBy from "lodash/unionBy";
  35. export interface Props {
  36. leaveTypes: LeaveType[];
  37. teamLeaves: TeamLeaves;
  38. teamTimesheets: TeamTimeSheets;
  39. companyHolidays: HolidaysResult[];
  40. allProjects: ProjectWithTasks[];
  41. }
  42. type MemberOption = TeamTimeSheets[0] & TeamLeaves[0] & { id: string };
  43. interface EventClickArg {
  44. event: {
  45. start: Date | null;
  46. startStr: string;
  47. extendedProps: {
  48. calendar?: string;
  49. entry?: TimeEntry | LeaveEntry;
  50. memberId?: string;
  51. };
  52. };
  53. }
  54. const TimesheetAmendment: React.FC<Props> = ({
  55. teamTimesheets,
  56. teamLeaves,
  57. companyHolidays,
  58. allProjects,
  59. leaveTypes,
  60. }) => {
  61. const { t } = useTranslation(["home", "common"]);
  62. const theme = useTheme();
  63. const projectMap = useMemo(() => {
  64. return allProjects.reduce<{
  65. [id: ProjectWithTasks["id"]]: ProjectWithTasks;
  66. }>((acc, project) => {
  67. return { ...acc, [project.id]: project };
  68. }, {});
  69. }, [allProjects]);
  70. const leaveMap = useMemo(() => {
  71. return leaveTypes.reduce<{ [id: LeaveType["id"]]: string }>(
  72. (acc, leaveType) => ({ ...acc, [leaveType.id]: leaveType.name }),
  73. {},
  74. );
  75. }, [leaveTypes]);
  76. // Use a local state to manage updates after a mutation
  77. const [localTeamTimesheets, setLocalTeamTimesheets] =
  78. useState(teamTimesheets);
  79. const [localTeamLeaves, setLocalTeamLeaves] = useState(teamLeaves);
  80. // member select
  81. const allMembers = useMemo(() => {
  82. return transform<TeamTimeSheets[0], MemberOption[]>(
  83. localTeamTimesheets,
  84. (acc, memberTimesheet, id) => {
  85. const leaves = localTeamLeaves[parseInt(id)];
  86. return acc.push({
  87. ...leaves,
  88. ...memberTimesheet,
  89. id,
  90. });
  91. },
  92. [],
  93. );
  94. }, [localTeamLeaves, localTeamTimesheets]);
  95. const [selectedStaff, setSelectedStaff] = useState<MemberOption>(
  96. allMembers[0],
  97. );
  98. useEffect(() => {
  99. setSelectedStaff(
  100. (currentStaff) =>
  101. allMembers.find((member) => member.id === currentStaff.id) ||
  102. allMembers[0],
  103. );
  104. }, [allMembers]);
  105. // edit modal related
  106. const [editModalProps, setEditModalProps] = useState<
  107. Partial<TimesheetEditModalProps>
  108. >({});
  109. const [editModalOpen, setEditModalOpen] = useState(false);
  110. const openEditModal = useCallback(
  111. (defaultValues?: TimeEntry, recordDate?: string, isHoliday?: boolean) => {
  112. setEditModalProps({
  113. defaultValues: defaultValues ? { ...defaultValues } : undefined,
  114. recordDate,
  115. isHoliday,
  116. onDelete: defaultValues
  117. ? async () => {
  118. const intStaffId = parseInt(selectedStaff.id);
  119. const newMemberTimesheets = await deleteMemberEntry({
  120. staffId: intStaffId,
  121. entryId: defaultValues.id,
  122. });
  123. setLocalTeamTimesheets((timesheets) => ({
  124. ...timesheets,
  125. [intStaffId]: {
  126. ...timesheets[intStaffId],
  127. timeEntries: newMemberTimesheets,
  128. },
  129. }));
  130. setEditModalOpen(false);
  131. }
  132. : undefined,
  133. });
  134. setEditModalOpen(true);
  135. },
  136. [selectedStaff.id],
  137. );
  138. const closeEditModal = useCallback(() => {
  139. setEditModalOpen(false);
  140. }, []);
  141. // leave edit modal related
  142. const [leaveEditModalProps, setLeaveEditModalProps] = useState<
  143. Partial<LeaveEditModalProps>
  144. >({});
  145. const [leaveEditModalOpen, setLeaveEditModalOpen] = useState(false);
  146. const openLeaveEditModal = useCallback(
  147. (defaultValues?: LeaveEntry, recordDate?: string, isHoliday?: boolean) => {
  148. setLeaveEditModalProps({
  149. defaultValues: defaultValues ? { ...defaultValues } : undefined,
  150. recordDate,
  151. isHoliday,
  152. onDelete: defaultValues
  153. ? async () => {
  154. const intStaffId = parseInt(selectedStaff.id);
  155. const newMemberLeaves = await deleteMemberLeave({
  156. staffId: intStaffId,
  157. entryId: defaultValues.id,
  158. });
  159. setLocalTeamLeaves((leaves) => ({
  160. ...leaves,
  161. [intStaffId]: {
  162. ...leaves[intStaffId],
  163. leaveEntries: newMemberLeaves,
  164. },
  165. }));
  166. setLeaveEditModalOpen(false);
  167. }
  168. : undefined,
  169. });
  170. setLeaveEditModalOpen(true);
  171. },
  172. [selectedStaff.id],
  173. );
  174. const closeLeaveEditModal = useCallback(() => {
  175. setLeaveEditModalOpen(false);
  176. }, []);
  177. // calendar related
  178. const holidays = useMemo(() => {
  179. return [
  180. ...getPublicHolidaysForNYears(2),
  181. ...companyHolidays.map((h) => ({
  182. title: h.name,
  183. date: convertDateArrayToString(h.date, INPUT_DATE_FORMAT),
  184. extendedProps: {
  185. calender: "holiday",
  186. },
  187. })),
  188. ].map((e) => ({
  189. ...e,
  190. backgroundColor: theme.palette.error.main,
  191. borderColor: theme.palette.error.main,
  192. }));
  193. }, [companyHolidays, theme.palette.error.main]);
  194. const leaveEntries = useMemo(
  195. () =>
  196. Object.keys(selectedStaff.leaveEntries).flatMap((date, index) => {
  197. return selectedStaff.leaveEntries[date].map((entry) => ({
  198. id: `${date}-${index}-leave-${entry.id}`,
  199. date,
  200. title: `${t("{{count}} hour", {
  201. ns: "common",
  202. count: entry.inputHours || 0,
  203. })} (${leaveMap[entry.leaveTypeId]})`,
  204. backgroundColor: theme.palette.warning.light,
  205. borderColor: theme.palette.warning.light,
  206. textColor: theme.palette.text.primary,
  207. extendedProps: {
  208. calendar: "leaveEntry",
  209. entry,
  210. memberId: selectedStaff.id,
  211. },
  212. }));
  213. }),
  214. [leaveMap, selectedStaff, t, theme],
  215. );
  216. const timeEntries = useMemo(
  217. () =>
  218. Object.keys(selectedStaff.timeEntries).flatMap((date, index) => {
  219. return selectedStaff.timeEntries[date].map((entry) => ({
  220. id: `${date}-${index}-time-${entry.id}`,
  221. date,
  222. title: `${t("{{count}} hour", {
  223. ns: "common",
  224. count: (entry.inputHours || 0) + (entry.otHours || 0),
  225. })} (${
  226. entry.projectId
  227. ? projectMap[entry.projectId].code
  228. : t("Non-billable task")
  229. })`,
  230. backgroundColor: theme.palette.info.main,
  231. borderColor: theme.palette.info.main,
  232. extendedProps: {
  233. calendar: "timeEntry",
  234. entry,
  235. memberId: selectedStaff.id,
  236. },
  237. }));
  238. }),
  239. [projectMap, selectedStaff, t, theme],
  240. );
  241. const handleEventClick = useCallback(
  242. ({ event }: EventClickArg) => {
  243. const dayJsObj = dayjs(event.startStr);
  244. const holiday = getHolidayForDate(event.startStr, companyHolidays);
  245. const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6;
  246. if (
  247. event.extendedProps.calendar === "timeEntry" &&
  248. event.extendedProps.entry
  249. ) {
  250. openEditModal(
  251. event.extendedProps.entry as TimeEntry,
  252. event.startStr,
  253. Boolean(isHoliday),
  254. );
  255. } else if (
  256. event.extendedProps.calendar === "leaveEntry" &&
  257. event.extendedProps.entry
  258. ) {
  259. openLeaveEditModal(
  260. event.extendedProps.entry as LeaveEntry,
  261. event.startStr,
  262. Boolean(isHoliday),
  263. );
  264. }
  265. },
  266. [companyHolidays, openEditModal, openLeaveEditModal],
  267. );
  268. const handleDateClick = useCallback(
  269. (e: { dateStr: string }) => {
  270. const dayJsObj = dayjs(e.dateStr);
  271. const holiday = getHolidayForDate(e.dateStr, companyHolidays);
  272. const isHoliday = holiday || dayJsObj.day() === 0 || dayJsObj.day() === 6;
  273. openEditModal(undefined, e.dateStr, Boolean(isHoliday));
  274. },
  275. [companyHolidays, openEditModal],
  276. );
  277. const checkTotalHoursForDate = useCallback(
  278. (newEntry: TimeEntry | LeaveEntry, date?: string) => {
  279. if (!date) {
  280. throw Error("Invalid date");
  281. }
  282. const intStaffId = parseInt(selectedStaff.id);
  283. const leaves = localTeamLeaves[intStaffId].leaveEntries[date] || [];
  284. const timesheets =
  285. localTeamTimesheets[intStaffId].timeEntries[date] || [];
  286. let totalHourError;
  287. if ((newEntry as LeaveEntry).leaveTypeId) {
  288. // newEntry is a leave entry
  289. const leavesWithNewEntry = unionBy(
  290. [newEntry as LeaveEntry],
  291. leaves,
  292. "id",
  293. );
  294. totalHourError = checkTotalHours(timesheets, leavesWithNewEntry);
  295. } else {
  296. // newEntry is a timesheet entry
  297. const timesheetsWithNewEntry = unionBy(
  298. [newEntry as TimeEntry],
  299. timesheets,
  300. "id",
  301. );
  302. totalHourError = checkTotalHours(timesheetsWithNewEntry, leaves);
  303. }
  304. if (totalHourError) throw Error(totalHourError);
  305. },
  306. [localTeamLeaves, localTeamTimesheets, selectedStaff.id],
  307. );
  308. const handleSave = useCallback(
  309. async (timeEntry: TimeEntry, recordDate?: string) => {
  310. // TODO: should be fine, but can handle parse error
  311. const intStaffId = parseInt(selectedStaff.id);
  312. checkTotalHoursForDate(timeEntry, recordDate);
  313. const newMemberTimesheets = await saveMemberEntry({
  314. staffId: intStaffId,
  315. entry: timeEntry,
  316. recordDate,
  317. });
  318. setLocalTeamTimesheets((timesheets) => ({
  319. ...timesheets,
  320. [intStaffId]: {
  321. ...timesheets[intStaffId],
  322. timeEntries: newMemberTimesheets,
  323. },
  324. }));
  325. setEditModalOpen(false);
  326. },
  327. [checkTotalHoursForDate, selectedStaff.id],
  328. );
  329. const handleSaveLeave = useCallback(
  330. async (leaveEntry: LeaveEntry, recordDate?: string) => {
  331. const intStaffId = parseInt(selectedStaff.id);
  332. checkTotalHoursForDate(leaveEntry, recordDate);
  333. const newMemberLeaves = await saveMemberLeave({
  334. staffId: intStaffId,
  335. recordDate,
  336. entry: leaveEntry,
  337. });
  338. setLocalTeamLeaves((leaves) => ({
  339. ...leaves,
  340. [intStaffId]: {
  341. ...leaves[intStaffId],
  342. leaveEntries: newMemberLeaves,
  343. },
  344. }));
  345. setLeaveEditModalOpen(false);
  346. },
  347. [checkTotalHoursForDate, selectedStaff.id],
  348. );
  349. return (
  350. <Stack spacing={2}>
  351. <Autocomplete
  352. sx={{ maxWidth: 400 }}
  353. noOptionsText={t("No team members")}
  354. value={selectedStaff}
  355. onChange={(_, value) => {
  356. if (value) setSelectedStaff(value);
  357. }}
  358. options={allMembers}
  359. isOptionEqualToValue={(option, value) => option.id === value.id}
  360. getOptionLabel={(option) => `${option.staffId} - ${option.name}`}
  361. renderInput={(params) => <TextField {...params} />}
  362. />
  363. <StyledFullCalendar
  364. plugins={[dayGridPlugin, interactionPlugin]}
  365. initialView="dayGridMonth"
  366. buttonText={{ today: t("Today") }}
  367. events={[...holidays, ...timeEntries, ...leaveEntries]}
  368. eventClick={handleEventClick}
  369. dateClick={handleDateClick}
  370. />
  371. <TimesheetEditModal
  372. modalSx={{ maxWidth: 400 }}
  373. allProjects={allProjects}
  374. assignedProjects={[]}
  375. open={editModalOpen}
  376. onClose={closeEditModal}
  377. onSave={handleSave}
  378. {...editModalProps}
  379. />
  380. <LeaveEditModal
  381. modalSx={{ maxWidth: 400 }}
  382. leaveTypes={leaveTypes}
  383. open={leaveEditModalOpen}
  384. onClose={closeLeaveEditModal}
  385. onSave={handleSaveLeave}
  386. {...leaveEditModalProps}
  387. />
  388. </Stack>
  389. );
  390. };
  391. export default TimesheetAmendment;