瀏覽代碼

Bulk add milestone payments

tags/Baseline_30082024_FRONTEND_UAT
Wayne 1 年之前
父節點
當前提交
1a513bdb90
共有 2 個檔案被更改,包括 422 行新增45 行删除
  1. +317
    -0
      src/components/CreateProject/BulkAddPaymentModal.tsx
  2. +105
    -45
      src/components/CreateProject/MilestoneSection.tsx

+ 317
- 0
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<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;

+ 105
- 45
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<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>
);
};


Loading…
取消
儲存