|
- 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<Props> = ({
- 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<RecordTimeLeaveInput>();
- const currentEntries = getValues(day);
-
- const getRowId = useCallback<GridRowIdGetter<TimeLeaveRow>>(
- (row) => `${row.type}-${row.id}`,
- [],
- );
-
- const [entries, setEntries] = useState<TimeLeaveRow[]>(currentEntries || []);
-
- const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({});
-
- 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<TimeLeaveRow>,
- originalRow: GridRowModel<TimeLeaveRow>,
- ) => {
- 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<GridColDef[]>(
- () => [
- {
- 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<TimeLeaveRow>(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<TimeLeaveRow, number>) {
- return <TwoLineCell>{params.formattedValue}</TwoLineCell>;
- },
- renderEditCell(params: GridRenderEditCellParams<TimeLeaveRow, number>) {
- return (
- <ProjectSelect
- referenceDay={dayjs(day)}
- includeLeaves
- leaveTypes={leaveTypes}
- multiple={false}
- allProjects={allProjects}
- assignedProjects={assignedProjects}
- value={
- (params.row.type === "leaveEntry"
- ? `leave-${params.row.leaveTypeId}`
- : undefined) ||
- (params.row.type === "timeEntry"
- ? params.row.projectId
- : undefined)
- }
- onProjectSelect={async (projectOrLeaveId, isLeave) => {
- 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<TimeLeaveRow, number>) {
- if (params.row.type === "timeEntry") {
- if (params.row.projectId) {
- return (
- <TaskGroupSelect
- projectId={params.row.projectId}
- value={params.value}
- taskGroupsByProject={taskGroupsByProject}
- onTaskGroupSelect={async (taskGroupId) => {
- 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 (
- <TaskGroupSelectWithoutProject
- value={params.value}
- onTaskGroupSelect={async (taskGroupId) => {
- 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 <DisabledEdit />;
- }
- },
- 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<TimeLeaveRow, number>) {
- return <TwoLineCell>{params.formattedValue}</TwoLineCell>;
- },
- },
- {
- field: "taskId",
- headerName: t("Task"),
- width: 200,
- editable: true,
- renderEditCell(params: GridRenderEditCellParams<TimeLeaveRow, number>) {
- if (params.row.type === "timeEntry") {
- 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");
- }}
- miscTasks={miscTasks}
- />
- );
- } else {
- return <DisabledEdit />;
- }
- },
- 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<TimeLeaveRow, number>) {
- return <TwoLineCell>{params.formattedValue}</TwoLineCell>;
- },
- },
- {
- field: "inputHours",
- headerName: t("Hours"),
- width: 100,
- editable: true,
- type: "number",
- renderEditCell(params: GridRenderEditCellParams<TimeLeaveRow>) {
- const errorMessage =
- params.row._error?.[
- params.field as keyof Omit<TimeLeaveEntry, "type">
- ];
- const content = (
- <GridEditInputCell {...params} inputProps={{ min: 0 }} />
- );
- 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<TimeLeaveRow>) {
- if (params.row.type === "leaveEntry") {
- return <DisabledEdit />;
- }
- const errorMessage =
- params.row._error?.[
- params.field as keyof Omit<TimeLeaveEntry, "type">
- ];
- const content = (
- <GridEditInputCell {...params} inputProps={{ min: 0 }} />
- );
- 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("Remarks"),
- sortable: false,
- flex: 1,
- editable: true,
- renderEditCell(params: GridRenderEditCellParams<TimeLeaveRow>) {
- const errorMessage =
- params.row._error?.[
- params.field as keyof Omit<TimeLeaveEntry, "type">
- ];
- const content = <GridEditInputCell {...params} />;
- return errorMessage ? (
- <Tooltip title={t(errorMessage)}>
- <Box width="100%">{content}</Box>
- </Tooltip>
- ) : (
- content
- );
- },
- renderCell(params: GridRenderCellParams<TimeLeaveRow>) {
- return <TwoLineCell>{params.value}</TwoLineCell>;
- },
- },
- {
- 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)}
- />,
- ];
- },
- },
- ],
- [
- 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 = (
- <Box display="flex" gap={2} alignItems="center">
- <Button
- disableRipple
- variant="outlined"
- startIcon={<Add />}
- onClick={addRow}
- size="small"
- >
- {t("Record time or leave")}
- </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
- getRowId={getRowId}
- 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"
- columnVisibilityModel={{ projectId: false, leaveTypeId: false }}
- rows={entries}
- rowModesModel={rowModesModel}
- onRowModesModelChange={setRowModesModel}
- processRowUpdate={processRowUpdate}
- onProcessRowUpdateError={onProcessRowUpdateError}
- columns={columns}
- getCellClassName={(params: GridCellParams<TimeLeaveRow>) => {
- let classname = "";
- if (
- params.row._error?.[
- params.field as keyof Omit<TimeLeaveEntry, "type">
- ]
- ) {
- 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
- recordDate={day}
- 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 TimeLeaveInputTable;
|