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 = ({ 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(); const currentEntries = getValues(day); const [entries, setEntries] = useState(currentEntries || []); const [rowModesModel, setRowModesModel] = useState({}); 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>( (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( () => [ { 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)} />, ]; }, }, { 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) { return ( { 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) { return ( { 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) { return ( { 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) { const errorMessage = params.row._error?.[params.field as keyof TimeEntry]; 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) { const errorMessage = params.row._error?.[params.field as keyof TimeEntry]; 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("Remark"), sortable: false, flex: 1, editable: true, renderEditCell(params: GridRenderEditCellParams) { const errorMessage = params.row._error?.[params.field as keyof TimeEntry]; const content = ; return errorMessage ? ( {content} ) : ( 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 = ( {fastEntryEnabled && } {hasOutOfPlannedStages && ( {t("There are entries for stages out of planned dates!")} )} ); return ( <> ) => { 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 && ( )} ); }; const NoRowsOverlay: React.FC = () => { const { t } = useTranslation("home"); return ( {t("Add some time entries!")} ); }; const FooterToolbar: React.FC = ({ child }) => { return {child}; }; export default EntryInputTable;