|
- import { Add, Check, Close, Delete } from "@mui/icons-material";
- import { Box, Button, Tooltip, Typography } from "@mui/material";
- import {
- FooterPropsOverrides,
- GridActionsCellItem,
- GridCellParams,
- GridColDef,
- GridEditInputCell,
- GridEventListener,
- GridRenderEditCellParams,
- GridRowId,
- GridRowModel,
- GridRowModes,
- GridRowModesModel,
- GridToolbarContainer,
- useGridApiRef,
- } from "@mui/x-data-grid";
- import { useTranslation } from "react-i18next";
- import StyledDataGrid from "../StyledDataGrid";
- import React, { useCallback, useEffect, useMemo, useState } from "react";
- import { useFormContext } from "react-hook-form";
- import { RecordTimesheetInput, TimeEntry } from "@/app/api/timesheets/actions";
- import { manhourFormatter } from "@/app/utils/formatUtil";
- import { AssignedProject, ProjectWithTasks } from "@/app/api/projects";
- import uniqBy from "lodash/uniqBy";
- import { TaskGroup } from "@/app/api/tasks";
- import dayjs from "dayjs";
- import isBetween from "dayjs/plugin/isBetween";
- import ProjectSelect from "./ProjectSelect";
- import TaskGroupSelect from "./TaskGroupSelect";
- import TaskSelect from "./TaskSelect";
- import {
- DAILY_NORMAL_MAX_HOURS,
- TimeEntryError,
- validateTimeEntry,
- } from "@/app/api/timesheets/utils";
- import { roundToNearestQuarter } from "@/app/utils/manhourUtils";
- import FastTimeEntryModal from "./FastTimeEntryModal";
-
- dayjs.extend(isBetween);
-
- interface Props {
- day: string;
- isHoliday: boolean;
- allProjects: ProjectWithTasks[];
- assignedProjects: AssignedProject[];
- fastEntryEnabled?: boolean;
- }
-
- export type TimeEntryRow = Partial<
- TimeEntry & {
- _isNew: boolean;
- _error: TimeEntryError;
- isPlanned?: boolean;
- }
- >;
-
- const EntryInputTable: React.FC<Props> = ({
- day,
- allProjects,
- assignedProjects,
- isHoliday,
- fastEntryEnabled,
- }) => {
- const { t } = useTranslation("home");
- const taskGroupsByProject = useMemo(() => {
- return allProjects.reduce<{
- [projectId: AssignedProject["id"]]: {
- value: TaskGroup["id"];
- label: string;
- }[];
- }>((acc, project) => {
- return {
- ...acc,
- [project.id]: uniqBy(
- project.tasks.map((t) => ({
- value: t.taskGroup.id,
- label: t.taskGroup.name,
- })),
- "value",
- ),
- };
- }, {});
- }, [allProjects]);
-
- // To check for start / end planned dates
- const milestonesByProject = useMemo(() => {
- return assignedProjects.reduce<{
- [projectId: AssignedProject["id"]]: AssignedProject["milestones"];
- }>((acc, project) => {
- return { ...acc, [project.id]: { ...project.milestones } };
- }, {});
- }, [assignedProjects]);
-
- const { getValues, setValue, clearErrors } =
- useFormContext<RecordTimesheetInput>();
- const currentEntries = getValues(day);
-
- const [entries, setEntries] = useState<TimeEntryRow[]>(currentEntries || []);
-
- const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
-
- const apiRef = useGridApiRef();
- const addRow = useCallback(() => {
- const id = Date.now();
- setEntries((e) => [...e, { id, _isNew: true }]);
- setRowModesModel((model) => ({
- ...model,
- [id]: { mode: GridRowModes.Edit, fieldToFocus: "projectId" },
- }));
- }, []);
-
- const validateRow = useCallback(
- (id: GridRowId) => {
- const row = apiRef.current.getRowWithUpdatedValues(
- id,
- "",
- ) as TimeEntryRow;
-
- const error = validateTimeEntry(row, isHoliday);
-
- // Test for warnings
- let isPlanned;
- if (
- row.projectId &&
- row.taskGroupId &&
- milestonesByProject[row.projectId]
- ) {
- const milestone =
- milestonesByProject[row.projectId][row.taskGroupId] || {};
- const { startDate, endDate } = milestone;
- // Check if the current day is between the start and end date inclusively
- isPlanned = dayjs(day).isBetween(startDate, endDate, "day", "[]");
- }
-
- apiRef.current.updateRows([{ id, _error: error, isPlanned }]);
- return !error;
- },
- [apiRef, day, isHoliday, milestonesByProject],
- );
-
- const handleCancel = useCallback(
- (id: GridRowId) => () => {
- setRowModesModel((model) => ({
- ...model,
- [id]: { mode: GridRowModes.View, ignoreModifications: true },
- }));
- const editedRow = entries.find((entry) => entry.id === id);
- if (editedRow?._isNew) {
- setEntries((es) => es.filter((e) => e.id !== id));
- }
- },
- [entries],
- );
-
- const handleDelete = useCallback(
- (id: GridRowId) => () => {
- setEntries((es) => es.filter((e) => e.id !== id));
- },
- [],
- );
-
- const handleSave = useCallback(
- (id: GridRowId) => () => {
- if (validateRow(id)) {
- setRowModesModel((model) => ({
- ...model,
- [id]: { mode: GridRowModes.View },
- }));
- }
- },
- [validateRow],
- );
-
- const handleEditStop = useCallback<GridEventListener<"rowEditStop">>(
- (params, event) => {
- if (!validateRow(params.id)) {
- event.defaultMuiPrevented = true;
- }
- },
- [validateRow],
- );
-
- const processRowUpdate = useCallback((newRow: GridRowModel) => {
- const updatedRow = { ...newRow, _isNew: false };
- setEntries((es) => es.map((e) => (e.id === newRow.id ? updatedRow : e)));
- return updatedRow;
- }, []);
-
- const columns = useMemo<GridColDef[]>(
- () => [
- {
- type: "actions",
- field: "actions",
- headerName: t("Actions"),
- getActions: ({ id }) => {
- if (rowModesModel[id]?.mode === GridRowModes.Edit) {
- return [
- <GridActionsCellItem
- key="accpet-action"
- icon={<Check />}
- label={t("Save")}
- onClick={handleSave(id)}
- />,
- <GridActionsCellItem
- key="cancel-action"
- icon={<Close />}
- label={t("Cancel")}
- onClick={handleCancel(id)}
- />,
- ];
- }
-
- return [
- <GridActionsCellItem
- key="delete-action"
- icon={<Delete />}
- label={t("Remove")}
- onClick={handleDelete(id)}
- />,
- ];
- },
- },
- {
- field: "projectId",
- headerName: t("Project Code and Name"),
- width: 300,
- editable: true,
- valueFormatter(params) {
- const project = allProjects.find((p) => p.id === params.value);
- return project ? `${project.code} - ${project.name}` : t("None");
- },
- renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) {
- return (
- <ProjectSelect
- multiple={false}
- allProjects={allProjects}
- assignedProjects={assignedProjects}
- value={params.value}
- onProjectSelect={(projectId) => {
- params.api.setEditCellValue({
- id: params.id,
- field: params.field,
- value: projectId,
- });
- params.api.setCellFocus(params.id, "taskGroupId");
- }}
- />
- );
- },
- },
- {
- field: "taskGroupId",
- headerName: t("Stage"),
- width: 200,
- editable: true,
- renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) {
- return (
- <TaskGroupSelect
- projectId={params.row.projectId}
- value={params.value}
- taskGroupsByProject={taskGroupsByProject}
- onTaskGroupSelect={(taskGroupId) => {
- params.api.setEditCellValue({
- id: params.id,
- field: params.field,
- value: taskGroupId,
- });
- params.api.setCellFocus(params.id, "taskId");
- }}
- />
- );
- },
- valueFormatter(params) {
- const taskGroups = params.id
- ? taskGroupsByProject[params.api.getRow(params.id).projectId] || []
- : [];
- const taskGroup = taskGroups.find((tg) => tg.value === params.value);
- return taskGroup ? taskGroup.label : t("None");
- },
- },
- {
- field: "taskId",
- headerName: t("Task"),
- width: 200,
- editable: true,
- renderEditCell(params: GridRenderEditCellParams<TimeEntryRow, number>) {
- return (
- <TaskSelect
- value={params.value}
- projectId={params.row.projectId}
- taskGroupId={params.row.taskGroupId}
- allProjects={allProjects}
- onTaskSelect={(taskId) => {
- params.api.setEditCellValue({
- id: params.id,
- field: params.field,
- value: taskId,
- });
- params.api.setCellFocus(params.id, "inputHours");
- }}
- />
- );
- },
- valueFormatter(params) {
- const projectId = params.id
- ? params.api.getRow(params.id).projectId
- : undefined;
-
- const task = projectId
- ? allProjects
- .find((p) => p.id === projectId)
- ?.tasks.find((t) => t.id === params.value)
- : undefined;
-
- return task ? task.name : t("None");
- },
- },
- {
- field: "inputHours",
- headerName: t("Hours"),
- width: 100,
- editable: true,
- type: "number",
- renderEditCell(params: GridRenderEditCellParams<TimeEntryRow>) {
- const errorMessage =
- params.row._error?.[params.field as keyof TimeEntry];
- const content = <GridEditInputCell {...params} />;
- return errorMessage ? (
- <Tooltip title={t(errorMessage, { DAILY_NORMAL_MAX_HOURS })}>
- <Box width="100%">{content}</Box>
- </Tooltip>
- ) : (
- content
- );
- },
- valueParser(value) {
- return value ? roundToNearestQuarter(value) : value;
- },
- valueFormatter(params) {
- return manhourFormatter.format(params.value || 0);
- },
- },
- {
- field: "otHours",
- headerName: t("Other Hours"),
- width: 150,
- editable: true,
- type: "number",
- renderEditCell(params: GridRenderEditCellParams<TimeEntryRow>) {
- const errorMessage =
- params.row._error?.[params.field as keyof TimeEntry];
- const content = <GridEditInputCell {...params} />;
- return errorMessage ? (
- <Tooltip title={t(errorMessage)}>
- <Box width="100%">{content}</Box>
- </Tooltip>
- ) : (
- content
- );
- },
- valueParser(value) {
- return value ? roundToNearestQuarter(value) : value;
- },
- valueFormatter(params) {
- return manhourFormatter.format(params.value || 0);
- },
- },
- {
- field: "remark",
- headerName: t("Remark"),
- sortable: false,
- flex: 1,
- editable: true,
- renderEditCell(params: GridRenderEditCellParams<TimeEntryRow>) {
- const errorMessage =
- params.row._error?.[params.field as keyof TimeEntry];
- const content = <GridEditInputCell {...params} />;
- return errorMessage ? (
- <Tooltip title={t(errorMessage)}>
- <Box width="100%">{content}</Box>
- </Tooltip>
- ) : (
- content
- );
- },
- },
- ],
- [
- t,
- rowModesModel,
- handleDelete,
- handleSave,
- handleCancel,
- assignedProjects,
- allProjects,
- taskGroupsByProject,
- ],
- );
-
- useEffect(() => {
- setValue(day, [
- ...entries
- .filter((e) => !e._isNew && !e._error && e.id)
- .map(({ isPlanned, _error, _isNew, ...entry }) => ({
- id: entry.id!,
- ...entry,
- })),
- ]);
- clearErrors(day);
- }, [getValues, entries, setValue, day, clearErrors]);
-
- const hasOutOfPlannedStages = entries.some(
- (entry) => entry.isPlanned !== undefined && !entry.isPlanned,
- );
-
- // Fast entry modal
- const [fastEntryModalOpen, setFastEntryModalOpen] = useState(false);
- const closeFastEntryModal = useCallback(() => {
- setFastEntryModalOpen(false);
- }, []);
- const openFastEntryModal = useCallback(() => {
- setFastEntryModalOpen(true);
- }, []);
- const onSaveFastEntry = useCallback(async (entries: TimeEntry[]) => {
- setEntries((e) => [...e, ...entries]);
- setFastEntryModalOpen(false);
- }, []);
-
- const footer = (
- <Box display="flex" gap={2} alignItems="center">
- <Button
- disableRipple
- variant="outlined"
- startIcon={<Add />}
- onClick={addRow}
- size="small"
- >
- {t("Record time")}
- </Button>
- {fastEntryEnabled && <Button
- disableRipple
- variant="outlined"
- startIcon={<Add />}
- onClick={openFastEntryModal}
- size="small"
- >
- {t("Fast time entry")}
- </Button>}
- {hasOutOfPlannedStages && (
- <Typography color="warning.main" variant="body2">
- {t("There are entries for stages out of planned dates!")}
- </Typography>
- )}
- </Box>
- );
-
- return (
- <>
- <StyledDataGrid
- apiRef={apiRef}
- autoHeight
- sx={{
- "--DataGrid-overlayHeight": "100px",
- ".MuiDataGrid-row .MuiDataGrid-cell.hasError": {
- border: "1px solid",
- borderColor: "error.main",
- },
- ".MuiDataGrid-row .MuiDataGrid-cell.hasWarning": {
- border: "1px solid",
- borderColor: "warning.main",
- },
- }}
- disableColumnMenu
- editMode="row"
- rows={entries}
- rowModesModel={rowModesModel}
- onRowModesModelChange={setRowModesModel}
- onRowEditStop={handleEditStop}
- processRowUpdate={processRowUpdate}
- columns={columns}
- getCellClassName={(params: GridCellParams<TimeEntryRow>) => {
- let classname = "";
- if (params.row._error?.[params.field as keyof TimeEntry]) {
- classname = "hasError";
- } else if (
- params.field === "taskGroupId" &&
- params.row.isPlanned !== undefined &&
- !params.row.isPlanned
- ) {
- classname = "hasWarning";
- }
- return classname;
- }}
- slots={{
- footer: FooterToolbar,
- noRowsOverlay: NoRowsOverlay,
- }}
- slotProps={{
- footer: { child: footer },
- }}
- />
- {fastEntryEnabled && (
- <FastTimeEntryModal
- allProjects={allProjects}
- assignedProjects={assignedProjects}
- open={fastEntryModalOpen}
- isHoliday={Boolean(isHoliday)}
- onClose={closeFastEntryModal}
- onSave={onSaveFastEntry}
- />
- )}
- </>
- );
- };
-
- const NoRowsOverlay: React.FC = () => {
- const { t } = useTranslation("home");
- return (
- <Box
- display="flex"
- justifyContent="center"
- alignItems="center"
- height="100%"
- >
- <Typography variant="caption">{t("Add some time entries!")}</Typography>
- </Box>
- );
- };
-
- const FooterToolbar: React.FC<FooterPropsOverrides> = ({ child }) => {
- return <GridToolbarContainer sx={{ p: 2 }}>{child}</GridToolbarContainer>;
- };
-
- export default EntryInputTable;
|