|
|
@@ -1,6 +1,6 @@ |
|
|
|
import { CreateProjectInputs } from "@/app/api/projects/actions"; |
|
|
|
import { CreateProjectInputs, PaymentInputs } from "@/app/api/projects/actions"; |
|
|
|
import { TaskGroup } from "@/app/api/tasks"; |
|
|
|
import { Add, Delete } from "@mui/icons-material"; |
|
|
|
import { Add, Check, Close, Delete } from "@mui/icons-material"; |
|
|
|
import { |
|
|
|
Stack, |
|
|
|
Typography, |
|
|
@@ -13,41 +13,159 @@ import { |
|
|
|
GridColDef, |
|
|
|
GridActionsCellItem, |
|
|
|
GridToolbarContainer, |
|
|
|
GridRowModesModel, |
|
|
|
GridRowModes, |
|
|
|
FooterPropsOverrides, |
|
|
|
GridRowId, |
|
|
|
GridEventListener, |
|
|
|
useGridApiRef, |
|
|
|
GridRowModel, |
|
|
|
} from "@mui/x-data-grid"; |
|
|
|
import { LocalizationProvider, DatePicker } from "@mui/x-date-pickers"; |
|
|
|
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; |
|
|
|
import dayjs from "dayjs"; |
|
|
|
import "dayjs/locale/zh-hk"; |
|
|
|
import React, { useMemo } from "react"; |
|
|
|
import React, { useCallback, useEffect, useMemo, useState } from "react"; |
|
|
|
import { useFormContext } from "react-hook-form"; |
|
|
|
import { useTranslation } from "react-i18next"; |
|
|
|
import StyledDataGrid from "../StyledDataGrid"; |
|
|
|
import { moneyFormatter } from "@/app/utils/formatUtil"; |
|
|
|
import isDate from "lodash/isDate"; |
|
|
|
|
|
|
|
interface Props { |
|
|
|
taskGroupId: TaskGroup["id"]; |
|
|
|
} |
|
|
|
|
|
|
|
interface FooterToolbarProps { |
|
|
|
onAdd: () => void; |
|
|
|
declare module "@mui/x-data-grid" { |
|
|
|
interface FooterPropsOverrides { |
|
|
|
onAdd: () => void; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
type PaymentRow = Partial<PaymentInputs & { _isNew: boolean; _error: string }>; |
|
|
|
|
|
|
|
const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { |
|
|
|
const { t } = useTranslation(); |
|
|
|
const {} = useFormContext<CreateProjectInputs>(); |
|
|
|
const { getValues, setValue } = useFormContext<CreateProjectInputs>(); |
|
|
|
const [payments, setPayments] = useState<PaymentRow[]>( |
|
|
|
getValues("milestones")[taskGroupId]?.payments || [], |
|
|
|
); |
|
|
|
const [rowModesModel, setRowModesModel] = useState<GridRowModesModel>({}); |
|
|
|
// Change the payments table depending on the TaskGroupId |
|
|
|
useEffect(() => { |
|
|
|
setPayments(getValues("milestones")[taskGroupId]?.payments || []); |
|
|
|
setRowModesModel({}); |
|
|
|
}, [getValues, taskGroupId]); |
|
|
|
|
|
|
|
const apiRef = useGridApiRef(); |
|
|
|
const addRow = useCallback(() => { |
|
|
|
const id = Date.now(); |
|
|
|
setPayments((p) => [...p, { id, _isNew: true }]); |
|
|
|
setRowModesModel((model) => ({ |
|
|
|
...model, |
|
|
|
[id]: { mode: GridRowModes.Edit, fieldToFocus: "description" }, |
|
|
|
})); |
|
|
|
}, []); |
|
|
|
|
|
|
|
const validateRow = useCallback( |
|
|
|
(id: GridRowId) => { |
|
|
|
const row = apiRef.current.getRowWithUpdatedValues(id, "") as PaymentRow; |
|
|
|
let error: keyof PaymentInputs | "" = ""; |
|
|
|
if (!row.description) { |
|
|
|
error = "description"; |
|
|
|
} else if (!(isDate(row.date) && row.date.getTime())) { |
|
|
|
error = "date"; |
|
|
|
} else if (!(row.amount && row.amount >= 0)) { |
|
|
|
error = "amount"; |
|
|
|
} |
|
|
|
|
|
|
|
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 = payments.find((payment) => payment.id === id); |
|
|
|
if (editedRow?._isNew) { |
|
|
|
setPayments((ps) => ps.filter((p) => p.id !== id)); |
|
|
|
} |
|
|
|
}, |
|
|
|
[payments], |
|
|
|
); |
|
|
|
|
|
|
|
const handleDelete = useCallback( |
|
|
|
(id: GridRowId) => () => { |
|
|
|
setPayments((ps) => ps.filter((p) => p.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 }; |
|
|
|
setPayments((ps) => ps.map((p) => (p.id === newRow.id ? updatedRow : p))); |
|
|
|
return updatedRow; |
|
|
|
}, []); |
|
|
|
|
|
|
|
const columns = useMemo<GridColDef[]>( |
|
|
|
() => [ |
|
|
|
{ |
|
|
|
type: "actions", |
|
|
|
field: "actions", |
|
|
|
headerName: t("Actions"), |
|
|
|
getActions: () => [ |
|
|
|
<GridActionsCellItem |
|
|
|
key="delete-action" |
|
|
|
icon={<Delete />} |
|
|
|
label={t("Remove")} |
|
|
|
onClick={() => {}} |
|
|
|
/>, |
|
|
|
], |
|
|
|
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: "description", |
|
|
@@ -61,6 +179,9 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { |
|
|
|
width: 200, |
|
|
|
type: "date", |
|
|
|
editable: true, |
|
|
|
valueGetter(params) { |
|
|
|
return new Date(params.value); |
|
|
|
}, |
|
|
|
}, |
|
|
|
{ |
|
|
|
field: "amount", |
|
|
@@ -68,11 +189,32 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { |
|
|
|
width: 300, |
|
|
|
editable: true, |
|
|
|
type: "number", |
|
|
|
valueFormatter(params) { |
|
|
|
return moneyFormatter.format(params.value); |
|
|
|
}, |
|
|
|
}, |
|
|
|
], |
|
|
|
[t], |
|
|
|
[handleCancel, handleDelete, handleSave, rowModesModel, t], |
|
|
|
); |
|
|
|
|
|
|
|
useEffect(() => { |
|
|
|
const milestones = getValues("milestones"); |
|
|
|
setValue("milestones", { |
|
|
|
...milestones, |
|
|
|
[taskGroupId]: { |
|
|
|
...milestones[taskGroupId], |
|
|
|
payments: payments |
|
|
|
.filter((p) => !p._isNew && !p._error) |
|
|
|
.map((p) => ({ |
|
|
|
description: p.description!, |
|
|
|
id: p.id!, |
|
|
|
amount: p.amount!, |
|
|
|
date: dayjs(p.date!).toISOString(), |
|
|
|
})), |
|
|
|
}, |
|
|
|
}); |
|
|
|
}, [getValues, payments, setValue, taskGroupId]); |
|
|
|
|
|
|
|
return ( |
|
|
|
<Stack gap={1}> |
|
|
|
<Typography variant="overline" display="block" marginBlockEnd={1}> |
|
|
@@ -84,13 +226,38 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { |
|
|
|
<FormControl fullWidth> |
|
|
|
<DatePicker |
|
|
|
label={t("Stage Start Date")} |
|
|
|
defaultValue={dayjs()} |
|
|
|
value={dayjs(getValues("milestones")[taskGroupId]?.startDate)} |
|
|
|
onChange={(date) => { |
|
|
|
if (!date) return; |
|
|
|
const milestones = getValues("milestones"); |
|
|
|
setValue("milestones", { |
|
|
|
...milestones, |
|
|
|
[taskGroupId]: { |
|
|
|
...milestones[taskGroupId], |
|
|
|
startDate: date.toISOString(), |
|
|
|
}, |
|
|
|
}); |
|
|
|
}} |
|
|
|
/> |
|
|
|
</FormControl> |
|
|
|
</Grid> |
|
|
|
<Grid item xs> |
|
|
|
<FormControl fullWidth> |
|
|
|
<DatePicker label={t("Stage End Date")} defaultValue={dayjs()} /> |
|
|
|
<DatePicker |
|
|
|
label={t("Stage End Date")} |
|
|
|
value={dayjs(getValues("milestones")[taskGroupId]?.endDate)} |
|
|
|
onChange={(date) => { |
|
|
|
if (!date) return; |
|
|
|
const milestones = getValues("milestones"); |
|
|
|
setValue("milestones", { |
|
|
|
...milestones, |
|
|
|
[taskGroupId]: { |
|
|
|
...milestones[taskGroupId], |
|
|
|
endDate: date.toISOString(), |
|
|
|
}, |
|
|
|
}); |
|
|
|
}} |
|
|
|
/> |
|
|
|
</FormControl> |
|
|
|
</Grid> |
|
|
|
</Grid> |
|
|
@@ -103,17 +270,32 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { |
|
|
|
})} |
|
|
|
> |
|
|
|
<StyledDataGrid |
|
|
|
apiRef={apiRef} |
|
|
|
autoHeight |
|
|
|
sx={{ "--DataGrid-overlayHeight": "100px" }} |
|
|
|
sx={{ |
|
|
|
"--DataGrid-overlayHeight": "100px", |
|
|
|
".MuiDataGrid-row .MuiDataGrid-cell.hasError": { |
|
|
|
border: "1px solid", |
|
|
|
borderColor: "error.main", |
|
|
|
}, |
|
|
|
}} |
|
|
|
disableColumnMenu |
|
|
|
rows={[]} |
|
|
|
editMode="row" |
|
|
|
rows={payments} |
|
|
|
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: undefined, |
|
|
|
footer: { onAdd: addRow }, |
|
|
|
}} |
|
|
|
/> |
|
|
|
</Box> |
|
|
@@ -130,12 +312,14 @@ const NoRowsOverlay: React.FC = () => { |
|
|
|
alignItems="center" |
|
|
|
height="100%" |
|
|
|
> |
|
|
|
<Typography variant="caption">{t("Add some milestones!")}</Typography> |
|
|
|
<Typography variant="caption"> |
|
|
|
{t("Add some payment milestones!")} |
|
|
|
</Typography> |
|
|
|
</Box> |
|
|
|
); |
|
|
|
}; |
|
|
|
|
|
|
|
const FooterToolbar: React.FC<FooterToolbarProps> = ({ onAdd }) => { |
|
|
|
const FooterToolbar: React.FC<FooterPropsOverrides> = ({ onAdd }) => { |
|
|
|
const { t } = useTranslation(); |
|
|
|
return ( |
|
|
|
<GridToolbarContainer sx={{ p: 2 }}> |
|
|
@@ -145,7 +329,7 @@ const FooterToolbar: React.FC<FooterToolbarProps> = ({ onAdd }) => { |
|
|
|
onClick={onAdd} |
|
|
|
size="small" |
|
|
|
> |
|
|
|
{t("Add Milestone")} |
|
|
|
{t("Add Payment Milestone")} |
|
|
|
</Button> |
|
|
|
</GridToolbarContainer> |
|
|
|
); |
|
|
|