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