diff --git a/src/components/CreateProject/BulkAddPaymentModal.tsx b/src/components/CreateProject/BulkAddPaymentModal.tsx new file mode 100644 index 0000000..6555dc8 --- /dev/null +++ b/src/components/CreateProject/BulkAddPaymentModal.tsx @@ -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 { + onSave: (payments: PaymentInputs[]) => Promise; + 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 = ({ + onSave, + open, + onClose, + modalSx: mSx, +}) => { + const { t } = useTranslation(); + + const { register, reset, trigger, formState, watch, control } = + useForm({ + defaultValues: { dateType: "monthly", dateReference: dayjs() }, + }); + + const formValues = watch(); + + const newPayments = useMemo(() => { + 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>( + (...args) => { + onClose?.(...args); + reset(); + }, + [onClose, reset], + ); + + return ( + + + + + + {t("Default Date Type")} + ( + + )} + rules={{ + required: t("Required"), + }} + /> + + {"The type of date to use for each payment entry."} + + + + ( + + )} + rules={{ + required: t("Required"), + }} + /> + + { + "The reference date for calculating the date for each payment entry." + } + + + + + }> + + {t("Milestone payments preview")} + + + + {newPayments.length > 0 ? ( + + ) : ( + + {t( + "Please input the amount to divde, the number of payments, and the description.", + )} + + )} + + + + + + + + ); +}; + +const PaymentSummary: React.FC<{ paymentInputs: PaymentInputs[] }> = ({ + paymentInputs, +}) => { + const { t } = useTranslation(); + + return ( + + {paymentInputs.map(({ id, date, description, amount }, index) => { + return ( + + + + {t("Description")} + + {description} + + + + + {t("Date")} + + {date} + + + + {t("Amount")} + + + {moneyFormatter.format(amount)} + + + + {index !== paymentInputs.length - 1 && ( + + )} + + ); + })} + + ); +}; + +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; diff --git a/src/components/CreateProject/MilestoneSection.tsx b/src/components/CreateProject/MilestoneSection.tsx index de810b6..0275189 100644 --- a/src/components/CreateProject/MilestoneSection.tsx +++ b/src/components/CreateProject/MilestoneSection.tsx @@ -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; +type PaymentRowError = keyof PaymentInputs; + +type PaymentRow = Partial< + PaymentInputs & { _isNew: boolean; _errors: Set } +>; + +class ProcessRowUpdateError extends Error { + public readonly rowId: GridRowId; + public readonly errors: Set | undefined; + constructor( + rowId: GridRowId, + message?: string, + errors?: Set, + ) { + super(message); + this.rowId = rowId; + this.errors = errors; + + Object.setPrototypeOf(this, ProcessRowUpdateError.prototype); + } +} const MilestoneSection: React.FC = ({ taskGroupId }) => { const { @@ -72,23 +94,18 @@ const MilestoneSection: React.FC = ({ 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(); + 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 = ({ 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>( - (params, event) => { - if (!validateRow(params.id)) { - event.defaultMuiPrevented = true; + const processRowUpdate = useCallback( + (newRow: GridRowModel) => { + 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( () => [ @@ -229,7 +261,7 @@ const MilestoneSection: React.FC = ({ 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 = ({ 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 startDate = getValues("milestones")[taskGroupId]?.startDate; @@ -345,11 +400,11 @@ const MilestoneSection: React.FC = ({ 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) => { + return params.row._errors?.has(params.field) ? "hasError" : ""; }} slots={{ footer: FooterToolbar, @@ -361,6 +416,11 @@ const MilestoneSection: React.FC = ({ taskGroupId }) => { /> + ); };