@@ -0,0 +1,317 @@ | |||||
import { Check, ExpandMore } from "@mui/icons-material"; | |||||
import { | |||||
Accordion, | |||||
AccordionDetails, | |||||
AccordionSummary, | |||||
Alert, | |||||
Box, | |||||
Button, | |||||
Divider, | |||||
FormControl, | |||||
FormHelperText, | |||||
InputLabel, | |||||
MenuItem, | |||||
Modal, | |||||
ModalProps, | |||||
Paper, | |||||
Select, | |||||
SxProps, | |||||
TextField, | |||||
Typography, | |||||
} from "@mui/material"; | |||||
import React, { useCallback, useMemo } from "react"; | |||||
import { Controller, useForm } from "react-hook-form"; | |||||
import { useTranslation } from "react-i18next"; | |||||
import { | |||||
INPUT_DATE_FORMAT, | |||||
moneyFormatter, | |||||
OUTPUT_DATE_FORMAT, | |||||
} from "@/app/utils/formatUtil"; | |||||
import { PaymentInputs } from "@/app/api/projects/actions"; | |||||
import dayjs from "dayjs"; | |||||
import { DatePicker } from "@mui/x-date-pickers"; | |||||
export interface BulkAddPaymentForm { | |||||
numberOfEntries: number; | |||||
amountToDivide: number; | |||||
dateType: "monthly" | "weekly" | "fixed"; | |||||
dateReference?: dayjs.Dayjs; | |||||
description: string; | |||||
} | |||||
export interface Props extends Omit<ModalProps, "children"> { | |||||
onSave: (payments: PaymentInputs[]) => Promise<void>; | |||||
modalSx?: SxProps; | |||||
} | |||||
const modalSx: SxProps = { | |||||
position: "absolute", | |||||
top: "50%", | |||||
left: "50%", | |||||
transform: "translate(-50%, -50%)", | |||||
width: "90%", | |||||
maxWidth: "sm", | |||||
maxHeight: "90%", | |||||
padding: 3, | |||||
display: "flex", | |||||
flexDirection: "column", | |||||
gap: 2, | |||||
}; | |||||
let idOffset = Date.now(); | |||||
const getID = () => { | |||||
return ++idOffset; | |||||
}; | |||||
const BulkAddPaymentModal: React.FC<Props> = ({ | |||||
onSave, | |||||
open, | |||||
onClose, | |||||
modalSx: mSx, | |||||
}) => { | |||||
const { t } = useTranslation(); | |||||
const { register, reset, trigger, formState, watch, control } = | |||||
useForm<BulkAddPaymentForm>({ | |||||
defaultValues: { dateType: "monthly", dateReference: dayjs() }, | |||||
}); | |||||
const formValues = watch(); | |||||
const newPayments = useMemo<PaymentInputs[]>(() => { | |||||
const { | |||||
numberOfEntries, | |||||
amountToDivide, | |||||
dateType, | |||||
dateReference = dayjs(), | |||||
description, | |||||
} = formValues; | |||||
if (numberOfEntries > 0 && amountToDivide && description) { | |||||
const dividedAmount = amountToDivide / numberOfEntries; | |||||
return Array(numberOfEntries) | |||||
.fill(undefined) | |||||
.map((_, index) => { | |||||
const date = | |||||
dateType === "fixed" | |||||
? dateReference | |||||
: dateReference.add( | |||||
index, | |||||
dateType === "monthly" ? "month" : "week", | |||||
); | |||||
return { | |||||
id: getID(), | |||||
amount: dividedAmount, | |||||
description: replaceTemplateString(description, { | |||||
"{index}": (index + 1).toString(), | |||||
"{date}": date.format(OUTPUT_DATE_FORMAT), | |||||
}), | |||||
date: date.format(INPUT_DATE_FORMAT), | |||||
}; | |||||
}); | |||||
} else { | |||||
return []; | |||||
} | |||||
}, [formValues]); | |||||
const saveHandler = useCallback(async () => { | |||||
const valid = await trigger(); | |||||
if (valid) { | |||||
onSave(newPayments); | |||||
reset(); | |||||
} | |||||
}, [trigger, onSave, reset, newPayments]); | |||||
const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>( | |||||
(...args) => { | |||||
onClose?.(...args); | |||||
reset(); | |||||
}, | |||||
[onClose, reset], | |||||
); | |||||
return ( | |||||
<Modal open={open} onClose={closeHandler}> | |||||
<Paper sx={{ ...modalSx, ...mSx }}> | |||||
<TextField | |||||
type="number" | |||||
label={t("Total amount to divide")} | |||||
fullWidth | |||||
{...register("amountToDivide", { | |||||
valueAsNumber: true, | |||||
required: t("Required"), | |||||
})} | |||||
error={Boolean(formState.errors.amountToDivide)} | |||||
helperText={ | |||||
formState.errors.amountToDivide?.message || | |||||
t( | |||||
"The inputted amount will be evenly divided by the inputted number of payments", | |||||
) | |||||
} | |||||
/> | |||||
<TextField | |||||
type="number" | |||||
label={t("Number of payments")} | |||||
fullWidth | |||||
{...register("numberOfEntries", { | |||||
valueAsNumber: true, | |||||
required: t("Required"), | |||||
})} | |||||
error={Boolean(formState.errors.numberOfEntries)} | |||||
helperText={formState.errors.numberOfEntries?.message} | |||||
/> | |||||
<FormControl fullWidth error={Boolean(formState.errors.dateType)}> | |||||
<InputLabel shrink>{t("Default Date Type")}</InputLabel> | |||||
<Controller | |||||
control={control} | |||||
name="dateType" | |||||
render={({ field }) => ( | |||||
<Select | |||||
label={t("Date type")} | |||||
{...field} | |||||
error={Boolean(formState.errors.dateType)} | |||||
> | |||||
<MenuItem value="monthly">{t("Monthly from")}</MenuItem> | |||||
<MenuItem value="weekly">{t("Weekly from")}</MenuItem> | |||||
<MenuItem value="fixed">{t("Fixed")}</MenuItem> | |||||
</Select> | |||||
)} | |||||
rules={{ | |||||
required: t("Required"), | |||||
}} | |||||
/> | |||||
<FormHelperText> | |||||
{"The type of date to use for each payment entry."} | |||||
</FormHelperText> | |||||
</FormControl> | |||||
<FormControl fullWidth error={Boolean(formState.errors.dateReference)}> | |||||
<Controller | |||||
control={control} | |||||
name="dateReference" | |||||
render={({ field }) => ( | |||||
<DatePicker | |||||
label={t("Date reference")} | |||||
format={OUTPUT_DATE_FORMAT} | |||||
{...field} | |||||
/> | |||||
)} | |||||
rules={{ | |||||
required: t("Required"), | |||||
}} | |||||
/> | |||||
<FormHelperText> | |||||
{ | |||||
"The reference date for calculating the date for each payment entry." | |||||
} | |||||
</FormHelperText> | |||||
</FormControl> | |||||
<TextField | |||||
label={t("Payment description")} | |||||
fullWidth | |||||
multiline | |||||
rows={2} | |||||
error={Boolean(formState.errors.description)} | |||||
{...register("description", { | |||||
required: t("Required"), | |||||
})} | |||||
helperText={ | |||||
formState.errors.description?.message || | |||||
t( | |||||
'The description to be added to each payment entry. You can use {date} to reference the generated date and {index} to reference the payment entry index. For example, "Payment {index} ({date})" will create entries with "Payment 1 (YYYY/MM/DD)", "Payment 2 (YYYY/MM/DD)"..., and so on.', | |||||
) | |||||
} | |||||
/> | |||||
<Accordion variant="outlined" sx={{ overflowY: "scroll" }}> | |||||
<AccordionSummary expandIcon={<ExpandMore />}> | |||||
<Typography variant="subtitle2"> | |||||
{t("Milestone payments preview")} | |||||
</Typography> | |||||
</AccordionSummary> | |||||
<AccordionDetails> | |||||
{newPayments.length > 0 ? ( | |||||
<PaymentSummary paymentInputs={newPayments} /> | |||||
) : ( | |||||
<Alert severity="warning"> | |||||
{t( | |||||
"Please input the amount to divde, the number of payments, and the description.", | |||||
)} | |||||
</Alert> | |||||
)} | |||||
</AccordionDetails> | |||||
</Accordion> | |||||
<Box display="flex" justifyContent="flex-end"> | |||||
<Button | |||||
variant="contained" | |||||
startIcon={<Check />} | |||||
onClick={saveHandler} | |||||
> | |||||
{t("Save")} | |||||
</Button> | |||||
</Box> | |||||
</Paper> | |||||
</Modal> | |||||
); | |||||
}; | |||||
const PaymentSummary: React.FC<{ paymentInputs: PaymentInputs[] }> = ({ | |||||
paymentInputs, | |||||
}) => { | |||||
const { t } = useTranslation(); | |||||
return ( | |||||
<Box | |||||
sx={{ | |||||
display: "flex", | |||||
flexDirection: "column", | |||||
gap: 2, | |||||
}} | |||||
> | |||||
{paymentInputs.map(({ id, date, description, amount }, index) => { | |||||
return ( | |||||
<Box key={`${index}-${id}`}> | |||||
<Box marginBlockEnd={1}> | |||||
<Typography variant="body2" component="div" fontWeight="bold"> | |||||
{t("Description")} | |||||
</Typography> | |||||
<Typography component="p">{description}</Typography> | |||||
</Box> | |||||
<Box display="flex" gap={2} justifyContent="space-between"> | |||||
<Box> | |||||
<Typography variant="body2" component="div" fontWeight="bold"> | |||||
{t("Date")} | |||||
</Typography> | |||||
<Typography component="p">{date}</Typography> | |||||
</Box> | |||||
<Box> | |||||
<Typography variant="body2" component="div" fontWeight="bold"> | |||||
{t("Amount")} | |||||
</Typography> | |||||
<Typography component="p"> | |||||
{moneyFormatter.format(amount)} | |||||
</Typography> | |||||
</Box> | |||||
</Box> | |||||
{index !== paymentInputs.length - 1 && ( | |||||
<Divider sx={{ marginBlockStart: 2 }} /> | |||||
)} | |||||
</Box> | |||||
); | |||||
})} | |||||
</Box> | |||||
); | |||||
}; | |||||
const replaceTemplateString = ( | |||||
template: string, | |||||
replacements: { [key: string]: string }, | |||||
): string => { | |||||
let returnString = template; | |||||
Object.entries(replacements).forEach(([key, replacement]) => { | |||||
returnString = returnString.replaceAll(key, replacement); | |||||
}); | |||||
return returnString; | |||||
}; | |||||
export default BulkAddPaymentModal; |
@@ -21,6 +21,7 @@ import { | |||||
useGridApiRef, | useGridApiRef, | ||||
GridRowModel, | GridRowModel, | ||||
GridRenderEditCellParams, | GridRenderEditCellParams, | ||||
GridCellParams, | |||||
} from "@mui/x-data-grid"; | } from "@mui/x-data-grid"; | ||||
import { LocalizationProvider, DatePicker } from "@mui/x-date-pickers"; | import { LocalizationProvider, DatePicker } from "@mui/x-date-pickers"; | ||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | ||||
@@ -36,12 +37,33 @@ import { | |||||
moneyFormatter, | moneyFormatter, | ||||
} from "@/app/utils/formatUtil"; | } from "@/app/utils/formatUtil"; | ||||
import isDate from "lodash/isDate"; | import isDate from "lodash/isDate"; | ||||
import BulkAddPaymentModal from "./BulkAddPaymentModal"; | |||||
interface Props { | interface Props { | ||||
taskGroupId: TaskGroup["id"]; | taskGroupId: TaskGroup["id"]; | ||||
} | } | ||||
type PaymentRow = Partial<PaymentInputs & { _isNew: boolean; _error: string }>; | |||||
type PaymentRowError = keyof PaymentInputs; | |||||
type PaymentRow = Partial< | |||||
PaymentInputs & { _isNew: boolean; _errors: Set<string> } | |||||
>; | |||||
class ProcessRowUpdateError extends Error { | |||||
public readonly rowId: GridRowId; | |||||
public readonly errors: Set<PaymentRowError> | undefined; | |||||
constructor( | |||||
rowId: GridRowId, | |||||
message?: string, | |||||
errors?: Set<PaymentRowError>, | |||||
) { | |||||
super(message); | |||||
this.rowId = rowId; | |||||
this.errors = errors; | |||||
Object.setPrototypeOf(this, ProcessRowUpdateError.prototype); | |||||
} | |||||
} | |||||
const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | ||||
const { | const { | ||||
@@ -72,23 +94,18 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||||
})); | })); | ||||
}, [payments]); | }, [payments]); | ||||
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"; | |||||
} | |||||
const validateRow = useCallback((row: PaymentRow) => { | |||||
const errors = new Set<PaymentRowError>(); | |||||
if (!row.description) { | |||||
errors.add("description"); | |||||
} else if (!(isDate(row.date) && row.date.getTime())) { | |||||
errors.add("date"); | |||||
} else if (!(row.amount && row.amount >= 0)) { | |||||
errors.add("amount"); | |||||
} | |||||
apiRef.current.updateRows([{ id, _error: error }]); | |||||
return !error; | |||||
}, | |||||
[apiRef], | |||||
); | |||||
return errors; | |||||
}, []); | |||||
const handleCancel = useCallback( | const handleCancel = useCallback( | ||||
(id: GridRowId) => () => { | (id: GridRowId) => () => { | ||||
@@ -113,30 +130,45 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||||
const handleSave = useCallback( | const handleSave = useCallback( | ||||
(id: GridRowId) => () => { | (id: GridRowId) => () => { | ||||
if (validateRow(id)) { | |||||
setRowModesModel((model) => ({ | |||||
...model, | |||||
[id]: { mode: GridRowModes.View }, | |||||
})); | |||||
} | |||||
setRowModesModel((model) => ({ | |||||
...model, | |||||
[id]: { mode: GridRowModes.View }, | |||||
})); | |||||
}, | }, | ||||
[validateRow], | |||||
[], | |||||
); | ); | ||||
const handleEditStop = useCallback<GridEventListener<"rowEditStop">>( | |||||
(params, event) => { | |||||
if (!validateRow(params.id)) { | |||||
event.defaultMuiPrevented = true; | |||||
const processRowUpdate = useCallback( | |||||
(newRow: GridRowModel<PaymentRow>) => { | |||||
const errors = validateRow(newRow); | |||||
if (errors.size > 0) { | |||||
throw new ProcessRowUpdateError(newRow.id!, "validation error", errors); | |||||
} | } | ||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars | |||||
const { _isNew, _errors, ...updatedRow } = newRow; | |||||
setPayments((ps) => | |||||
ps.map((p) => (p.id === updatedRow.id ? updatedRow : p)), | |||||
); | |||||
return updatedRow; | |||||
}, | }, | ||||
[validateRow], | [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 onProcessRowUpdateError = useCallback( | |||||
(updateError: ProcessRowUpdateError) => { | |||||
const errors = updateError.errors; | |||||
const rowId = updateError.rowId; | |||||
apiRef.current.updateRows([ | |||||
{ | |||||
id: rowId, | |||||
_errors: errors, | |||||
}, | |||||
]); | |||||
}, | |||||
[apiRef], | |||||
); | |||||
const columns = useMemo<GridColDef[]>( | const columns = useMemo<GridColDef[]>( | ||||
() => [ | () => [ | ||||
@@ -229,7 +261,7 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||||
[taskGroupId]: { | [taskGroupId]: { | ||||
...milestones[taskGroupId], | ...milestones[taskGroupId], | ||||
payments: payments | payments: payments | ||||
.filter((p) => !p._isNew && !p._error) | |||||
.filter((p) => !p._isNew && !p._errors) | |||||
.map((p) => ({ | .map((p) => ({ | ||||
description: p.description!, | description: p.description!, | ||||
id: p.id!, | id: p.id!, | ||||
@@ -240,15 +272,38 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||||
}); | }); | ||||
}, [getValues, payments, setValue, taskGroupId]); | }, [getValues, payments, setValue, taskGroupId]); | ||||
// Bulk add modal related | |||||
const [bulkAddModalOpen, setBulkAddModalOpen] = useState(false); | |||||
const closeBulkAddModal = useCallback(() => { | |||||
setBulkAddModalOpen(false); | |||||
}, []); | |||||
const openBulkAddModal = useCallback(() => { | |||||
setBulkAddModalOpen(true); | |||||
}, []); | |||||
const onBulkAddSave = useCallback(async (entries: PaymentInputs[]) => { | |||||
setPayments((currentPayments) => [...currentPayments, ...entries]); | |||||
setBulkAddModalOpen(false); | |||||
}, []); | |||||
const footer = ( | const footer = ( | ||||
<Button | |||||
variant="outlined" | |||||
startIcon={<Add />} | |||||
onClick={addRow} | |||||
size="small" | |||||
> | |||||
{t("Add Payment Milestone")} | |||||
</Button> | |||||
<Box display="flex" gap={2} alignItems="center"> | |||||
<Button | |||||
variant="outlined" | |||||
startIcon={<Add />} | |||||
onClick={addRow} | |||||
size="small" | |||||
> | |||||
{t("Add Payment Milestone")} | |||||
</Button> | |||||
<Button | |||||
variant="outlined" | |||||
startIcon={<Add />} | |||||
onClick={openBulkAddModal} | |||||
size="small" | |||||
> | |||||
{t("Bulk Add Payment Milestones")} | |||||
</Button> | |||||
</Box> | |||||
); | ); | ||||
const startDate = getValues("milestones")[taskGroupId]?.startDate; | const startDate = getValues("milestones")[taskGroupId]?.startDate; | ||||
@@ -345,11 +400,11 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||||
rows={payments} | rows={payments} | ||||
rowModesModel={rowModesModel} | rowModesModel={rowModesModel} | ||||
onRowModesModelChange={setRowModesModel} | onRowModesModelChange={setRowModesModel} | ||||
onRowEditStop={handleEditStop} | |||||
processRowUpdate={processRowUpdate} | processRowUpdate={processRowUpdate} | ||||
onProcessRowUpdateError={onProcessRowUpdateError} | |||||
columns={columns} | columns={columns} | ||||
getCellClassName={(params) => { | |||||
return params.row._error === params.field ? "hasError" : ""; | |||||
getCellClassName={(params: GridCellParams<PaymentRow>) => { | |||||
return params.row._errors?.has(params.field) ? "hasError" : ""; | |||||
}} | }} | ||||
slots={{ | slots={{ | ||||
footer: FooterToolbar, | footer: FooterToolbar, | ||||
@@ -361,6 +416,11 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||||
/> | /> | ||||
</Box> | </Box> | ||||
</Stack> | </Stack> | ||||
<BulkAddPaymentModal | |||||
onSave={onBulkAddSave} | |||||
open={bulkAddModalOpen} | |||||
onClose={closeBulkAddModal} | |||||
/> | |||||
</LocalizationProvider> | </LocalizationProvider> | ||||
); | ); | ||||
}; | }; | ||||