| @@ -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> | |||
| ); | |||
| }; | |||