Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 

536 righe
15 KiB

  1. import { Add, Check, Close, Delete } from "@mui/icons-material";
  2. import { Box, Button, Tooltip, Typography } from "@mui/material";
  3. import {
  4. FooterPropsOverrides,
  5. GridActionsCellItem,
  6. GridCellParams,
  7. GridColDef,
  8. GridEditInputCell,
  9. GridEventListener,
  10. GridRenderEditCellParams,
  11. GridRowId,
  12. GridRowModel,
  13. GridRowModes,
  14. GridRowModesModel,
  15. GridToolbarContainer,
  16. useGridApiRef,
  17. } from "@mui/x-data-grid";
  18. import { useTranslation } from "react-i18next";
  19. import StyledDataGrid from "../StyledDataGrid";
  20. import React, { useCallback, useEffect, useMemo, useState } from "react";
  21. import { useFormContext } from "react-hook-form";
  22. import { RecordTimesheetInput, TimeEntry } from "@/app/api/timesheets/actions";
  23. import { manhourFormatter } from "@/app/utils/formatUtil";
  24. import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
  25. import uniqBy from "lodash/uniqBy";
  26. import { TaskGroup } from "@/app/api/tasks";
  27. import dayjs from "dayjs";
  28. import isBetween from "dayjs/plugin/isBetween";
  29. import ProjectSelect from "./ProjectSelect";
  30. import TaskGroupSelect from "./TaskGroupSelect";
  31. import TaskSelect from "./TaskSelect";
  32. import {
  33. DAILY_NORMAL_MAX_HOURS,
  34. TimeEntryError,
  35. validateTimeEntry,
  36. } from "@/app/api/timesheets/utils";
  37. import { roundToNearestQuarter } from "@/app/utils/manhourUtils";
  38. import FastTimeEntryModal from "./FastTimeEntryModal";
  39. dayjs.extend(isBetween);
  40. interface Props {
  41. day: string;
  42. isHoliday: boolean;
  43. allProjects: ProjectWithTasks[];
  44. assignedProjects: AssignedProject[];
  45. fastEntryEnabled?: boolean;
  46. }
  47. export type TimeEntryRow = Partial<
  48. TimeEntry & {
  49. _isNew: boolean;
  50. _error: TimeEntryError;
  51. isPlanned?: boolean;
  52. }
  53. >;
  54. const EntryInputTable: React.FC<Props> = ({
  55. day,
  56. allProjects,
  57. assignedProjects,
  58. isHoliday,
  59. fastEntryEnabled,
  60. }) => {
  61. const { t } = useTranslation("home");
  62. const taskGroupsByProject = useMemo(() => {
  63. return allProjects.reduce<{
  64. [projectId: AssignedProject["id"]]: {
  65. value: TaskGroup["id"];
  66. label: string;
  67. }[];
  68. }>((acc, project) => {
  69. return {
  70. ...acc,
  71. [project.id]: uniqBy(
  72. project.tasks.map((t) => ({
  73. value: t.taskGroup.id,
  74. label: t.taskGroup.name,
  75. })),
  76. "value",
  77. ),
  78. };
  79. }, {});
  80. }, [allProjects]);
  81. // To check for start / end planned dates
  82. const milestonesByProject = useMemo(() => {
  83. return assignedProjects.reduce<{
  84. [projectId: AssignedProject["id"]]: AssignedProject["milestones"];
  85. }>((acc, project) => {
  86. return { ...acc, [project.id]: { ...project.milestones } };
  87. }, {});
  88. }, [assignedProjects]);
  89. const { getValues, setValue, clearErrors } =
  90. useFormContext<RecordTimesheetInput>();
  91. const currentEntries = getValues(day);
  92. const [entries, setEntries] = useState<TimeEntryRow[]>(currentEntries || []);
  93. const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
  94. const apiRef = useGridApiRef();
  95. const addRow = useCallback(() => {
  96. const id = Date.now();
  97. setEntries((e) => [...e, { id, _isNew: true }]);
  98. setRowModesModel((model) => ({
  99. ...model,
  100. [id]: { mode: GridRowModes.Edit, fieldToFocus: "projectId" },
  101. }));
  102. }, []);
  103. const validateRow = useCallback(
  104. (id: GridRowId) => {
  105. const row = apiRef.current.getRowWithUpdatedValues(
  106. id,
  107. "",
  108. ) as TimeEntryRow;
  109. const error = validateTimeEntry(row, isHoliday);
  110. // Test for warnings
  111. let isPlanned;
  112. if (
  113. row.projectId &&
  114. row.taskGroupId &&
  115. milestonesByProject[row.projectId]
  116. ) {
  117. const milestone =
  118. milestonesByProject[row.projectId][row.taskGroupId] || {};
  119. const { startDate, endDate } = milestone;
  120. // Check if the current day is between the start and end date inclusively
  121. isPlanned = dayjs(day).isBetween(startDate, endDate, "day", "[]");
  122. }
  123. apiRef.current.updateRows([{ id, _error: error, isPlanned }]);
  124. return !error;
  125. },
  126. [apiRef, day, isHoliday, milestonesByProject],
  127. );
  128. const handleCancel = useCallback(
  129. (id: GridRowId) => () => {
  130. setRowModesModel((model) => ({
  131. ...model,
  132. [id]: { mode: GridRowModes.View, ignoreModifications: true },
  133. }));
  134. const editedRow = entries.find((entry) => entry.id === id);
  135. if (editedRow?._isNew) {
  136. setEntries((es) => es.filter((e) => e.id !== id));
  137. }
  138. },
  139. [entries],
  140. );
  141. const handleDelete = useCallback(
  142. (id: GridRowId) => () => {
  143. setEntries((es) => es.filter((e) => e.id !== id));
  144. },
  145. [],
  146. );
  147. const handleSave = useCallback(
  148. (id: GridRowId) => () => {
  149. if (validateRow(id)) {
  150. setRowModesModel((model) => ({
  151. ...model,
  152. [id]: { mode: GridRowModes.View },
  153. }));
  154. }
  155. },
  156. [validateRow],
  157. );
  158. const handleEditStop = useCallback<GridEventListener<"rowEditStop">>(
  159. (params, event) => {
  160. if (!validateRow(params.id)) {
  161. event.defaultMuiPrevented = true;
  162. }
  163. },
  164. [validateRow],
  165. );
  166. const processRowUpdate = useCallback((newRow: GridRowModel) => {
  167. const updatedRow = { ...newRow, _isNew: false };
  168. setEntries((es) => es.map((e) => (e.id === newRow.id ? updatedRow : e)));
  169. return updatedRow;
  170. }, []);
  171. const columns = useMemo<GridColDef[]>(
  172. () => [
  173. {
  174. type: "actions",
  175. field: "actions",
  176. headerName: t("Actions"),
  177. getActions: ({ id }) => {
  178. if (rowModesModel[id]?.mode === GridRowModes.Edit) {
  179. return [
  180. <GridActionsCellItem
  181. key="accpet-action"
  182. icon={<Check />}
  183. label={t("Save")}
  184. onClick={handleSave(id)}
  185. />,
  186. <GridActionsCellItem
  187. key="cancel-action"
  188. icon={<Close />}
  189. label={t("Cancel")}
  190. onClick={handleCancel(id)}
  191. />,
  192. ];
  193. }
  194. return [
  195. <GridActionsCellItem
  196. key="delete-action"
  197. icon={<Delete />}
  198. label={t("Remove")}
  199. onClick={handleDelete(id)}
  200. />,
  201. ];
  202. },
  203. },
  204. {
  205. field: "projectId",
  206. headerName: t("Project Code and Name"),
  207. width: 300,
  208. editable: true,
  209. valueFormatter(params) {
  210. const project = allProjects.find((p) => p.id === params.value);
  211. return project ? `${project.code} - ${project.name}` : t("None");
  212. },
  213. renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) {
  214. return (
  215. <ProjectSelect
  216. multiple={false}
  217. allProjects={allProjects}
  218. assignedProjects={assignedProjects}
  219. value={params.value}
  220. onProjectSelect={(projectId) => {
  221. params.api.setEditCellValue({
  222. id: params.id,
  223. field: params.field,
  224. value: projectId,
  225. });
  226. params.api.setCellFocus(params.id, "taskGroupId");
  227. }}
  228. />
  229. );
  230. },
  231. },
  232. {
  233. field: "taskGroupId",
  234. headerName: t("Stage"),
  235. width: 200,
  236. editable: true,
  237. renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) {
  238. return (
  239. <TaskGroupSelect
  240. projectId={params.row.projectId}
  241. value={params.value}
  242. taskGroupsByProject={taskGroupsByProject}
  243. onTaskGroupSelect={(taskGroupId) => {
  244. params.api.setEditCellValue({
  245. id: params.id,
  246. field: params.field,
  247. value: taskGroupId,
  248. });
  249. params.api.setCellFocus(params.id, "taskId");
  250. }}
  251. />
  252. );
  253. },
  254. valueFormatter(params) {
  255. const taskGroups = params.id
  256. ? taskGroupsByProject[params.api.getRow(params.id).projectId] || []
  257. : [];
  258. const taskGroup = taskGroups.find((tg) => tg.value === params.value);
  259. return taskGroup ? taskGroup.label : t("None");
  260. },
  261. },
  262. {
  263. field: "taskId",
  264. headerName: t("Task"),
  265. width: 200,
  266. editable: true,
  267. renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) {
  268. return (
  269. <TaskSelect
  270. value={params.value}
  271. projectId={params.row.projectId}
  272. taskGroupId={params.row.taskGroupId}
  273. allProjects={allProjects}
  274. onTaskSelect={(taskId) => {
  275. params.api.setEditCellValue({
  276. id: params.id,
  277. field: params.field,
  278. value: taskId,
  279. });
  280. params.api.setCellFocus(params.id, "inputHours");
  281. }}
  282. />
  283. );
  284. },
  285. valueFormatter(params) {
  286. const projectId = params.id
  287. ? params.api.getRow(params.id).projectId
  288. : undefined;
  289. const task = projectId
  290. ? allProjects
  291. .find((p) => p.id === projectId)
  292. ?.tasks.find((t) => t.id === params.value)
  293. : undefined;
  294. return task ? task.name : t("None");
  295. },
  296. },
  297. {
  298. field: "inputHours",
  299. headerName: t("Hours"),
  300. width: 100,
  301. editable: true,
  302. type: "number",
  303. renderEditCell(params: GridRenderEditCellParams<TimeEntryRow>) {
  304. const errorMessage =
  305. params.row._error?.[params.field as keyof TimeEntry];
  306. const content = <GridEditInputCell {...params} />;
  307. return errorMessage ? (
  308. <Tooltip title={t(errorMessage, { DAILY_NORMAL_MAX_HOURS })}>
  309. <Box width="100%">{content}</Box>
  310. </Tooltip>
  311. ) : (
  312. content
  313. );
  314. },
  315. valueParser(value) {
  316. return value ? roundToNearestQuarter(value) : value;
  317. },
  318. valueFormatter(params) {
  319. return manhourFormatter.format(params.value || 0);
  320. },
  321. },
  322. {
  323. field: "otHours",
  324. headerName: t("Other Hours"),
  325. width: 150,
  326. editable: true,
  327. type: "number",
  328. renderEditCell(params: GridRenderEditCellParams<TimeEntryRow>) {
  329. const errorMessage =
  330. params.row._error?.[params.field as keyof TimeEntry];
  331. const content = <GridEditInputCell {...params} />;
  332. return errorMessage ? (
  333. <Tooltip title={t(errorMessage)}>
  334. <Box width="100%">{content}</Box>
  335. </Tooltip>
  336. ) : (
  337. content
  338. );
  339. },
  340. valueParser(value) {
  341. return value ? roundToNearestQuarter(value) : value;
  342. },
  343. valueFormatter(params) {
  344. return manhourFormatter.format(params.value || 0);
  345. },
  346. },
  347. {
  348. field: "remark",
  349. headerName: t("Remark"),
  350. sortable: false,
  351. flex: 1,
  352. editable: true,
  353. renderEditCell(params: GridRenderEditCellParams<TimeEntryRow>) {
  354. const errorMessage =
  355. params.row._error?.[params.field as keyof TimeEntry];
  356. const content = <GridEditInputCell {...params} />;
  357. return errorMessage ? (
  358. <Tooltip title={t(errorMessage)}>
  359. <Box width="100%">{content}</Box>
  360. </Tooltip>
  361. ) : (
  362. content
  363. );
  364. },
  365. },
  366. ],
  367. [
  368. t,
  369. rowModesModel,
  370. handleDelete,
  371. handleSave,
  372. handleCancel,
  373. assignedProjects,
  374. allProjects,
  375. taskGroupsByProject,
  376. ],
  377. );
  378. useEffect(() => {
  379. setValue(day, [
  380. ...entries
  381. .filter((e) => !e._isNew && !e._error && e.id)
  382. .map(({ isPlanned, _error, _isNew, ...entry }) => ({
  383. id: entry.id!,
  384. ...entry,
  385. })),
  386. ]);
  387. clearErrors(day);
  388. }, [getValues, entries, setValue, day, clearErrors]);
  389. const hasOutOfPlannedStages = entries.some(
  390. (entry) => entry.isPlanned !== undefined && !entry.isPlanned,
  391. );
  392. // Fast entry modal
  393. const [fastEntryModalOpen, setFastEntryModalOpen] = useState(false);
  394. const closeFastEntryModal = useCallback(() => {
  395. setFastEntryModalOpen(false);
  396. }, []);
  397. const openFastEntryModal = useCallback(() => {
  398. setFastEntryModalOpen(true);
  399. }, []);
  400. const onSaveFastEntry = useCallback(async (entries: TimeEntry[]) => {
  401. setEntries((e) => [...e, ...entries]);
  402. setFastEntryModalOpen(false);
  403. }, []);
  404. const footer = (
  405. <Box display="flex" gap={2} alignItems="center">
  406. <Button
  407. disableRipple
  408. variant="outlined"
  409. startIcon={<Add />}
  410. onClick={addRow}
  411. size="small"
  412. >
  413. {t("Record time")}
  414. </Button>
  415. {fastEntryEnabled && <Button
  416. disableRipple
  417. variant="outlined"
  418. startIcon={<Add />}
  419. onClick={openFastEntryModal}
  420. size="small"
  421. >
  422. {t("Fast time entry")}
  423. </Button>}
  424. {hasOutOfPlannedStages && (
  425. <Typography color="warning.main" variant="body2">
  426. {t("There are entries for stages out of planned dates!")}
  427. </Typography>
  428. )}
  429. </Box>
  430. );
  431. return (
  432. <>
  433. <StyledDataGrid
  434. apiRef={apiRef}
  435. autoHeight
  436. sx={{
  437. "--DataGrid-overlayHeight": "100px",
  438. ".MuiDataGrid-row .MuiDataGrid-cell.hasError": {
  439. border: "1px solid",
  440. borderColor: "error.main",
  441. },
  442. ".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": {
  443. border: "1px solid",
  444. borderColor: "warning.main",
  445. },
  446. }}
  447. disableColumnMenu
  448. editMode="row"
  449. rows={entries}
  450. rowModesModel={rowModesModel}
  451. onRowModesModelChange={setRowModesModel}
  452. onRowEditStop={handleEditStop}
  453. processRowUpdate={processRowUpdate}
  454. columns={columns}
  455. getCellClassName={(params: GridCellParams<TimeEntryRow>) => {
  456. let classname = "";
  457. if (params.row._error?.[params.field as keyof TimeEntry]) {
  458. classname = "hasError";
  459. } else if (
  460. params.field === "taskGroupId" &&
  461. params.row.isPlanned !== undefined &&
  462. !params.row.isPlanned
  463. ) {
  464. classname = "hasWarning";
  465. }
  466. return classname;
  467. }}
  468. slots={{
  469. footer: FooterToolbar,
  470. noRowsOverlay: NoRowsOverlay,
  471. }}
  472. slotProps={{
  473. footer: { child: footer },
  474. }}
  475. />
  476. {fastEntryEnabled && (
  477. <FastTimeEntryModal
  478. allProjects={allProjects}
  479. assignedProjects={assignedProjects}
  480. open={fastEntryModalOpen}
  481. isHoliday={Boolean(isHoliday)}
  482. onClose={closeFastEntryModal}
  483. onSave={onSaveFastEntry}
  484. />
  485. )}
  486. </>
  487. );
  488. };
  489. const NoRowsOverlay: React.FC = () => {
  490. const { t } = useTranslation("home");
  491. return (
  492. <Box
  493. display="flex"
  494. justifyContent="center"
  495. alignItems="center"
  496. height="100%"
  497. >
  498. <Typography variant="caption">{t("Add some time entries!")}</Typography>
  499. </Box>
  500. );
  501. };
  502. const FooterToolbar: React.FC<FooterPropsOverrides> = ({ child }) => {
  503. return <GridToolbarContainer sx={{ p: 2 }}>{child}</GridToolbarContainer>;
  504. };
  505. export default EntryInputTable;