import { Add, Check, Close, Delete } from "@mui/icons-material"; import { Box, Button, Tooltip, Typography } from "@mui/material"; import { FooterPropsOverrides, GridActionsCellItem, GridCellParams, GridColDef, GridEditInputCell, GridRenderCellParams, GridRenderEditCellParams, GridRowId, GridRowIdGetter, 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 { RecordTimeLeaveInput, TimeEntry, TimeLeaveEntry, } from "@/app/api/timesheets/actions"; import { manhourFormatter } from "@/app/utils/formatUtil"; import { AssignedProject, ProjectWithTasks } from "@/app/api/projects"; import uniqBy from "lodash/uniqBy"; import { Task, TaskGroup } from "@/app/api/tasks"; import dayjs from "dayjs"; import isBetween from "dayjs/plugin/isBetween"; import ProjectSelect from "../TimesheetTable/ProjectSelect"; import TaskGroupSelect, { TaskGroupSelectWithoutProject, } from "../TimesheetTable/TaskGroupSelect"; import TaskSelect from "../TimesheetTable/TaskSelect"; import { DAILY_NORMAL_MAX_HOURS, LeaveEntryError, TimeEntryError, validateLeaveEntry, validateTimeEntry, } from "@/app/api/timesheets/utils"; import { roundToNearestQuarter } from "@/app/utils/manhourUtils"; import FastTimeEntryModal from "../TimesheetTable/FastTimeEntryModal"; import { LeaveType } from "@/app/api/timesheets"; import DisabledEdit from "./DisabledEdit"; import TwoLineCell from "./TwoLineCell"; dayjs.extend(isBetween); interface Props { day: string; isHoliday: boolean; allProjects: ProjectWithTasks[]; assignedProjects: AssignedProject[]; fastEntryEnabled?: boolean; leaveTypes: LeaveType[]; miscTasks: Task[]; } export type TimeLeaveRow = Partial< TimeLeaveEntry & { _isNew: boolean; _error: TimeEntryError | LeaveEntryError; _isPlanned?: boolean; } >; class ProcessRowUpdateError extends Error { public readonly row: TimeLeaveRow; public readonly errors: TimeEntryError | LeaveEntryError | undefined; constructor( row: TimeLeaveRow, message?: string, errors?: TimeEntryError | LeaveEntryError, ) { super(message); this.row = row; this.errors = errors; Object.setPrototypeOf(this, ProcessRowUpdateError.prototype); } } const TimeLeaveInputTable: React.FC = ({ day, allProjects, assignedProjects, isHoliday, fastEntryEnabled, leaveTypes, miscTasks, }) => { 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]); const taskGroupsWithoutProject = useMemo( () => uniqBy( miscTasks.map((t) => t.taskGroup), "id", ), [miscTasks], ); // 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, setError } = useFormContext(); const currentEntries = getValues(day); const getRowId = useCallback>( (row) => `${row.type}-${row.id}`, [], ); const [entries, setEntries] = useState(currentEntries || []); const [rowModesModel, setRowModesModel] = useState({}); const apiRef = useGridApiRef(); const addRow = useCallback(() => { const type = "timeEntry" as const; const newEntry = { id: Date.now(), _isNew: true, type }; setEntries((e) => [...e, newEntry]); setRowModesModel((model) => ({ ...model, [getRowId(newEntry)]: { mode: GridRowModes.Edit, fieldToFocus: "projectId", }, })); }, [getRowId]); const validateRow = useCallback( (row: TimeLeaveRow) => { if (row.type === "timeEntry") { return validateTimeEntry(row, isHoliday); } else { return validateLeaveEntry(row, isHoliday); } }, [isHoliday], ); const verifyIsPlanned = useCallback( (row: TimeLeaveRow) => { if ( row.type === "timeEntry" && 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 return dayjs(day).isBetween(startDate, endDate, "day", "[]"); } }, [day, milestonesByProject], ); const handleCancel = useCallback( (id: GridRowId) => () => { setRowModesModel((model) => ({ ...model, [id]: { mode: GridRowModes.View, ignoreModifications: true }, })); const editedRow = entries.find((entry) => getRowId(entry) === id); if (editedRow?._isNew) { setEntries((es) => es.filter((e) => getRowId(e) !== id)); } else { setEntries((es) => es.map((e) => getRowId(e) === id ? { ...e, _error: undefined, _isPlanned: undefined } : e, ), ); } }, [entries, getRowId], ); const handleDelete = useCallback( (id: GridRowId) => () => { setEntries((es) => es.filter((e) => getRowId(e) !== id)); }, [getRowId], ); const handleSave = useCallback( (id: GridRowId) => () => { setRowModesModel((model) => ({ ...model, [id]: { mode: GridRowModes.View }, })); }, [], ); const processRowUpdate = useCallback( ( newRow: GridRowModel, originalRow: GridRowModel, ) => { const errors = validateRow(newRow); if (errors) { throw new ProcessRowUpdateError( originalRow, "validation error", errors, ); } // eslint-disable-next-line @typescript-eslint/no-unused-vars const { _isNew, _error, _isPlanned, ...updatedRow } = newRow; const newIsPlanned = verifyIsPlanned(updatedRow); const rowToSave = { ...updatedRow, ...(updatedRow.type === "timeEntry" ? { leaveTypeId: undefined, } : { projectId: undefined, taskGroupId: undefined, taskId: undefined, }), _isPlanned: newIsPlanned, } satisfies TimeLeaveRow; setEntries((es) => es.map((e) => (getRowId(e) === getRowId(originalRow) ? rowToSave : e)), ); return rowToSave; }, [validateRow, verifyIsPlanned, getRowId], ); const onProcessRowUpdateError = useCallback( (updateError: ProcessRowUpdateError) => { const errors = updateError.errors; const oldRow = updateError.row; apiRef.current.updateRows([{ ...oldRow, _error: errors }]); }, [apiRef], ); const columns = useMemo( () => [ { field: "projectId", editable: true, }, { field: "leaveTypeId", editable: true, }, { field: "type", headerName: t("Project or Leave"), width: 300, editable: true, valueFormatter(params) { const row = params.id ? params.api.getRow(params.id) : null; if (!row) { return null; } if (row.type === "timeEntry") { const project = allProjects.find((p) => p.id === row.projectId); return project ? `${project.code} - ${project.name}` : t("None"); } else if (row.type === "leaveEntry") { const leave = leaveTypes.find((l) => l.id === row.leaveTypeId); return leave?.name || "Unknown leave"; } }, renderCell(params: GridRenderCellParams) { return {params.formattedValue}; }, renderEditCell(params: GridRenderEditCellParams) { return ( { await params.api.setEditCellValue({ id: params.id, field: params.field, value: isLeave ? "leaveEntry" : "timeEntry", }); await params.api.setEditCellValue({ id: params.id, field: isLeave ? "leaveTypeId" : "projectId", value: projectOrLeaveId, }); await params.api.setEditCellValue({ id: params.id, field: isLeave ? "projectId" : "leaveTypeId", value: undefined, }); await params.api.setEditCellValue({ id: params.id, field: "taskGroupId", value: undefined, }); await params.api.setEditCellValue({ id: params.id, field: "taskId", value: undefined, }); params.api.setCellFocus( params.id, isLeave || !projectOrLeaveId ? "inputHours" : "taskGroupId", ); }} /> ); }, }, { field: "taskGroupId", headerName: t("Stage"), width: 200, editable: true, renderEditCell(params: GridRenderEditCellParams) { if (params.row.type === "timeEntry") { if (params.row.projectId) { return ( { await params.api.setEditCellValue({ id: params.id, field: params.field, value: taskGroupId, }); await params.api.setEditCellValue({ id: params.id, field: "taskId", value: undefined, }); params.api.setCellFocus(params.id, "taskId"); }} /> ); } else { return ( { await params.api.setEditCellValue({ id: params.id, field: params.field, value: taskGroupId, }); await params.api.setEditCellValue({ id: params.id, field: "taskId", value: undefined, }); params.api.setCellFocus(params.id, "taskId"); }} taskGroups={taskGroupsWithoutProject} /> ); } } else { return ; } }, valueFormatter(params) { if (!params.id) { return null; } const projectId = params.api.getRow(params.id).projectId; if (projectId) { const taskGroups = taskGroupsByProject[params.api.getRow(params.id).projectId] || []; const taskGroup = taskGroups.find( (tg) => tg.value === params.value, ); return taskGroup ? taskGroup.label : t("None"); } else { const taskGroupId = params.value; return ( taskGroupsWithoutProject.find((tg) => tg.id === taskGroupId) ?.name || t("None") ); } }, renderCell(params: GridRenderCellParams) { return {params.formattedValue}; }, }, { field: "taskId", headerName: t("Task"), width: 200, editable: true, renderEditCell(params: GridRenderEditCellParams) { if (params.row.type === "timeEntry") { return ( { params.api.setEditCellValue({ id: params.id, field: params.field, value: taskId, }); params.api.setCellFocus(params.id, "inputHours"); }} miscTasks={miscTasks} /> ); } else { return ; } }, valueFormatter(params) { const projectId = params.id ? params.api.getRow(params.id).projectId : undefined; const task = ( projectId ? allProjects.find((p) => p.id === projectId)?.tasks || [] : miscTasks ).find((t) => t.id === params.value); return task ? task.name : t("None"); }, renderCell(params: GridRenderCellParams) { return {params.formattedValue}; }, }, { field: "inputHours", headerName: t("Hours"), width: 100, editable: true, type: "number", renderEditCell(params: GridRenderEditCellParams) { const errorMessage = params.row._error?.[ params.field as keyof Omit ]; const content = ( ); return errorMessage ? ( {content} ) : ( 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) { if (params.row.type === "leaveEntry") { return ; } const errorMessage = params.row._error?.[ params.field as keyof Omit ]; const content = ( ); return errorMessage ? ( {content} ) : ( content ); }, valueParser(value) { return value ? roundToNearestQuarter(value) : value; }, valueFormatter(params) { return manhourFormatter.format(params.value || 0); }, }, { field: "remark", headerName: t("Remarks"), sortable: false, flex: 1, editable: true, renderEditCell(params: GridRenderEditCellParams) { const errorMessage = params.row._error?.[ params.field as keyof Omit ]; const content = ; return errorMessage ? ( {content} ) : ( content ); }, renderCell(params: GridRenderCellParams) { return {params.value}; }, }, { type: "actions", field: "actions", headerName: t("Actions"), getActions: ({ id }) => { if (rowModesModel[id]?.mode === GridRowModes.Edit) { return [ } label={t("Save")} onClick={handleSave(id)} />, } label={t("Cancel")} onClick={handleCancel(id)} />, ]; } return [ } label={t("Remove")} onClick={handleDelete(id)} />, ]; }, }, ], [ t, rowModesModel, handleDelete, handleSave, handleCancel, allProjects, leaveTypes, assignedProjects, taskGroupsByProject, taskGroupsWithoutProject, miscTasks, day, ], ); useEffect(() => { const newEntries: TimeLeaveEntry[] = entries .map((e) => { if (e._isNew || e._error || !e.id || !e.type) { return null; } return e; }) .filter((e): e is TimeLeaveEntry => Boolean(e)); setValue(day, newEntries); if (entries.some((e) => e._isNew)) { setError(day, { message: "There are some unsaved entries.", type: "custom", }); } else { clearErrors(day); } }, [getValues, entries, setValue, day, clearErrors, setError]); 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.map((newEntry) => ({ ...newEntry, type: "timeEntry" as const, })), ]); setFastEntryModalOpen(false); }, []); const footer = ( {fastEntryEnabled && ( )} {hasOutOfPlannedStages && ( {t("There are entries for stages out of planned dates!")} )} ); return ( <> ) => { let classname = ""; if ( params.row._error?.[ params.field as keyof Omit ] ) { 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 && ( )} ); }; const NoRowsOverlay: React.FC = () => { const { t } = useTranslation("home"); return ( {t("Add some time entries!")} ); }; const FooterToolbar: React.FC = ({ child }) => { return {child}; }; export default TimeLeaveInputTable;