@@ -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, | |||
GridRowModel, | |||
GridRenderEditCellParams, | |||
GridCellParams, | |||
} from "@mui/x-data-grid"; | |||
import { LocalizationProvider, DatePicker } from "@mui/x-date-pickers"; | |||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; | |||
@@ -36,12 +37,33 @@ import { | |||
moneyFormatter, | |||
} from "@/app/utils/formatUtil"; | |||
import isDate from "lodash/isDate"; | |||
import BulkAddPaymentModal from "./BulkAddPaymentModal"; | |||
interface Props { | |||
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 { | |||
@@ -72,23 +94,18 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
})); | |||
}, [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( | |||
(id: GridRowId) => () => { | |||
@@ -113,30 +130,45 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
const handleSave = useCallback( | |||
(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], | |||
); | |||
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[]>( | |||
() => [ | |||
@@ -229,7 +261,7 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
[taskGroupId]: { | |||
...milestones[taskGroupId], | |||
payments: payments | |||
.filter((p) => !p._isNew && !p._error) | |||
.filter((p) => !p._isNew && !p._errors) | |||
.map((p) => ({ | |||
description: p.description!, | |||
id: p.id!, | |||
@@ -240,15 +272,38 @@ const MilestoneSection: React.FC<Props> = ({ 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 = ( | |||
<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; | |||
@@ -345,11 +400,11 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
rows={payments} | |||
rowModesModel={rowModesModel} | |||
onRowModesModelChange={setRowModesModel} | |||
onRowEditStop={handleEditStop} | |||
processRowUpdate={processRowUpdate} | |||
onProcessRowUpdateError={onProcessRowUpdateError} | |||
columns={columns} | |||
getCellClassName={(params) => { | |||
return params.row._error === params.field ? "hasError" : ""; | |||
getCellClassName={(params: GridCellParams<PaymentRow>) => { | |||
return params.row._errors?.has(params.field) ? "hasError" : ""; | |||
}} | |||
slots={{ | |||
footer: FooterToolbar, | |||
@@ -361,6 +416,11 @@ const MilestoneSection: React.FC<Props> = ({ taskGroupId }) => { | |||
/> | |||
</Box> | |||
</Stack> | |||
<BulkAddPaymentModal | |||
onSave={onBulkAddSave} | |||
open={bulkAddModalOpen} | |||
onClose={closeBulkAddModal} | |||
/> | |||
</LocalizationProvider> | |||
); | |||
}; | |||