|
|
|
@@ -0,0 +1,430 @@ |
|
|
|
import { Add, Check, Close, Delete } from "@mui/icons-material"; |
|
|
|
import { Box, Button, Typography } from "@mui/material"; |
|
|
|
import { |
|
|
|
FooterPropsOverrides, |
|
|
|
GridActionsCellItem, |
|
|
|
GridColDef, |
|
|
|
GridEventListener, |
|
|
|
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 } from "@/app/api/projects"; |
|
|
|
import uniqBy from "lodash/uniqBy"; |
|
|
|
import { TaskGroup } from "@/app/api/tasks"; |
|
|
|
|
|
|
|
const mockProjects: AssignedProject[] = [ |
|
|
|
{ |
|
|
|
id: 1, |
|
|
|
name: "Consultancy Project A", |
|
|
|
code: "M1001 (C)", |
|
|
|
tasks: [ |
|
|
|
{ |
|
|
|
id: 1, |
|
|
|
name: "1.1 Preparation of preliminary Cost Estimate / Cost Plan including Revised & Refined", |
|
|
|
description: null, |
|
|
|
taskGroup: { |
|
|
|
id: 1, |
|
|
|
name: "1. Design & Cost Planning / Estimating", |
|
|
|
}, |
|
|
|
}, |
|
|
|
{ |
|
|
|
id: 6, |
|
|
|
name: "2.1 Advise on tendering & contractual arrangement", |
|
|
|
description: null, |
|
|
|
taskGroup: { |
|
|
|
id: 2, |
|
|
|
name: "2. Tender Documentation", |
|
|
|
}, |
|
|
|
}, |
|
|
|
], |
|
|
|
}, |
|
|
|
{ |
|
|
|
id: 2, |
|
|
|
name: "Consultancy Project B", |
|
|
|
code: "M1354 (C)", |
|
|
|
tasks: [ |
|
|
|
{ |
|
|
|
id: 1, |
|
|
|
name: "1.1 Preparation of preliminary Cost Estimate / Cost Plan including Revised & Refined", |
|
|
|
description: null, |
|
|
|
taskGroup: { |
|
|
|
id: 1, |
|
|
|
name: "1. Design & Cost Planning / Estimating", |
|
|
|
}, |
|
|
|
}, |
|
|
|
{ |
|
|
|
id: 10, |
|
|
|
name: "3.5 Attend tender interviews", |
|
|
|
description: null, |
|
|
|
taskGroup: { |
|
|
|
id: 3, |
|
|
|
name: "3. Tender Analysis & Report & Contract Documentation", |
|
|
|
}, |
|
|
|
}, |
|
|
|
], |
|
|
|
}, |
|
|
|
{ |
|
|
|
id: 3, |
|
|
|
name: "Consultancy Project C", |
|
|
|
code: "M1973 (C)", |
|
|
|
tasks: [ |
|
|
|
{ |
|
|
|
id: 1, |
|
|
|
name: "1.1 Preparation of preliminary Cost Estimate / Cost Plan including Revised & Refined", |
|
|
|
description: null, |
|
|
|
taskGroup: { |
|
|
|
id: 1, |
|
|
|
name: "1. Design & Cost Planning / Estimating", |
|
|
|
}, |
|
|
|
}, |
|
|
|
{ |
|
|
|
id: 20, |
|
|
|
name: "4.10 Preparation of Statement of Final Account", |
|
|
|
description: null, |
|
|
|
taskGroup: { |
|
|
|
id: 4, |
|
|
|
name: "4. Construction / Post Construction", |
|
|
|
}, |
|
|
|
}, |
|
|
|
], |
|
|
|
}, |
|
|
|
]; |
|
|
|
|
|
|
|
type TimeEntryRow = Partial< |
|
|
|
TimeEntry & { |
|
|
|
_isNew: boolean; |
|
|
|
_error: string; |
|
|
|
id: string; |
|
|
|
taskGroupId: number; |
|
|
|
} |
|
|
|
>; |
|
|
|
|
|
|
|
const EntryInputTable: React.FC<{ day: string }> = ({ day }) => { |
|
|
|
const { t } = useTranslation("home"); |
|
|
|
const taskGroupsByProject = useMemo(() => { |
|
|
|
return mockProjects.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", |
|
|
|
), |
|
|
|
}; |
|
|
|
}, {}); |
|
|
|
}, []); |
|
|
|
|
|
|
|
const { getValues, setValue } = useFormContext<RecordTimesheetInput>(); |
|
|
|
const currentEntries = getValues(day); |
|
|
|
|
|
|
|
const [entries, setEntries] = useState<TimeEntryRow[]>( |
|
|
|
currentEntries.map((e, index) => ({ ...e, id: `${day}-${index}` })) || [], |
|
|
|
); |
|
|
|
|
|
|
|
const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); |
|
|
|
|
|
|
|
const apiRef = useGridApiRef(); |
|
|
|
const addRow = useCallback(() => { |
|
|
|
const id = `${day}-${Date.now()}`; |
|
|
|
setEntries((e) => [...e, { id, _isNew: true }]); |
|
|
|
setRowModesModel((model) => ({ |
|
|
|
...model, |
|
|
|
[id]: { mode: GridRowModes.Edit, fieldToFocus: "projectId" }, |
|
|
|
})); |
|
|
|
}, [day]); |
|
|
|
|
|
|
|
const validateRow = useCallback( |
|
|
|
(id: GridRowId) => { |
|
|
|
const row = apiRef.current.getRowWithUpdatedValues( |
|
|
|
id, |
|
|
|
"", |
|
|
|
) as TimeEntryRow; |
|
|
|
let error: keyof TimeEntry | "taskGroupId" | "" = ""; |
|
|
|
if (!row.projectId) { |
|
|
|
error = "projectId"; |
|
|
|
} else if (!row.taskGroupId) { |
|
|
|
error = "taskGroupId"; |
|
|
|
} else if (!row.taskId) { |
|
|
|
error = "taskId"; |
|
|
|
} else if (!row.inputHours || !(row.inputHours >= 0)) { |
|
|
|
error = "inputHours"; |
|
|
|
} |
|
|
|
|
|
|
|
apiRef.current.updateRows([{ id, _error: error }]); |
|
|
|
return !error; |
|
|
|
}, |
|
|
|
[apiRef], |
|
|
|
); |
|
|
|
|
|
|
|
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: 200, |
|
|
|
editable: true, |
|
|
|
type: "singleSelect", |
|
|
|
valueOptions() { |
|
|
|
return mockProjects.map((p) => ({ value: p.id, label: p.name })); |
|
|
|
}, |
|
|
|
}, |
|
|
|
{ |
|
|
|
field: "taskGroupId", |
|
|
|
headerName: t("Stage"), |
|
|
|
width: 200, |
|
|
|
editable: true, |
|
|
|
type: "singleSelect", |
|
|
|
valueOptions(params) { |
|
|
|
const updatedRow = params.id |
|
|
|
? apiRef.current.getRowWithUpdatedValues(params.id, "") |
|
|
|
: null; |
|
|
|
if (!updatedRow) { |
|
|
|
return []; |
|
|
|
} |
|
|
|
|
|
|
|
const projectInfo = mockProjects.find( |
|
|
|
(p) => p.id === updatedRow.projectId, |
|
|
|
); |
|
|
|
|
|
|
|
if (!projectInfo) { |
|
|
|
return []; |
|
|
|
} |
|
|
|
|
|
|
|
return taskGroupsByProject[projectInfo.id]; |
|
|
|
}, |
|
|
|
}, |
|
|
|
{ |
|
|
|
field: "taskId", |
|
|
|
headerName: t("Task"), |
|
|
|
width: 200, |
|
|
|
editable: true, |
|
|
|
type: "singleSelect", |
|
|
|
valueOptions(params) { |
|
|
|
const updatedRow = params.id |
|
|
|
? apiRef.current.getRowWithUpdatedValues(params.id, "") |
|
|
|
: null; |
|
|
|
if (!updatedRow) { |
|
|
|
return []; |
|
|
|
} |
|
|
|
|
|
|
|
const projectInfo = mockProjects.find( |
|
|
|
(p) => p.id === updatedRow.projectId, |
|
|
|
); |
|
|
|
|
|
|
|
if (!projectInfo) { |
|
|
|
return []; |
|
|
|
} |
|
|
|
|
|
|
|
return projectInfo.tasks |
|
|
|
.filter((t) => t.taskGroup.id === updatedRow.taskGroupId) |
|
|
|
.map((t) => ({ |
|
|
|
value: t.id, |
|
|
|
label: t.name, |
|
|
|
})); |
|
|
|
}, |
|
|
|
}, |
|
|
|
{ |
|
|
|
field: "inputHours", |
|
|
|
headerName: t("Hours"), |
|
|
|
width: 100, |
|
|
|
editable: true, |
|
|
|
type: "number", |
|
|
|
valueFormatter(params) { |
|
|
|
return manhourFormatter.format(params.value); |
|
|
|
}, |
|
|
|
}, |
|
|
|
], |
|
|
|
[ |
|
|
|
t, |
|
|
|
rowModesModel, |
|
|
|
handleDelete, |
|
|
|
handleSave, |
|
|
|
handleCancel, |
|
|
|
apiRef, |
|
|
|
taskGroupsByProject, |
|
|
|
], |
|
|
|
); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
setValue(day, [ |
|
|
|
...entries |
|
|
|
.filter( |
|
|
|
(e) => |
|
|
|
!e._isNew && |
|
|
|
!e._error && |
|
|
|
e.inputHours && |
|
|
|
e.projectId && |
|
|
|
e.taskId && |
|
|
|
e.taskGroupId, |
|
|
|
) |
|
|
|
.map((e) => ({ |
|
|
|
inputHours: e.inputHours!, |
|
|
|
projectId: e.projectId!, |
|
|
|
taskId: e.taskId!, |
|
|
|
taskGroupId: e.taskGroupId!, |
|
|
|
})), |
|
|
|
]); |
|
|
|
}, [getValues, entries, setValue, day]); |
|
|
|
|
|
|
|
return ( |
|
|
|
<StyledDataGrid |
|
|
|
apiRef={apiRef} |
|
|
|
autoHeight |
|
|
|
sx={{ |
|
|
|
"--DataGrid-overlayHeight": "100px", |
|
|
|
".MuiDataGrid-row .MuiDataGrid-cell.hasError": { |
|
|
|
border: "1px solid", |
|
|
|
borderColor: "error.main", |
|
|
|
}, |
|
|
|
}} |
|
|
|
disableColumnMenu |
|
|
|
editMode="row" |
|
|
|
rows={entries} |
|
|
|
rowModesModel={rowModesModel} |
|
|
|
onRowModesModelChange={setRowModesModel} |
|
|
|
onRowEditStop={handleEditStop} |
|
|
|
processRowUpdate={processRowUpdate} |
|
|
|
columns={columns} |
|
|
|
getCellClassName={(params) => { |
|
|
|
return params.row._error === params.field ? "hasError" : ""; |
|
|
|
}} |
|
|
|
slots={{ |
|
|
|
footer: FooterToolbar, |
|
|
|
noRowsOverlay: NoRowsOverlay, |
|
|
|
}} |
|
|
|
slotProps={{ |
|
|
|
footer: { onAdd: addRow }, |
|
|
|
}} |
|
|
|
/> |
|
|
|
); |
|
|
|
}; |
|
|
|
|
|
|
|
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> = ({ onAdd }) => { |
|
|
|
const { t } = useTranslation(); |
|
|
|
return ( |
|
|
|
<GridToolbarContainer sx={{ p: 2 }}> |
|
|
|
<Button |
|
|
|
disableRipple |
|
|
|
variant="outlined" |
|
|
|
startIcon={<Add />} |
|
|
|
onClick={onAdd} |
|
|
|
size="small" |
|
|
|
> |
|
|
|
{t("Record time")} |
|
|
|
</Button> |
|
|
|
</GridToolbarContainer> |
|
|
|
); |
|
|
|
}; |
|
|
|
|
|
|
|
export default EntryInputTable; |