|
- 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,
- truncateMoney,
- } 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;
- }
-
- type DatetypeOption = {
- value: string;
- label: string;
- unit: 'week' | 'month' | 'year';
- step: number;
- };
-
- const datetypeOptions: DatetypeOption[] = [
- { value: "weekly", label: "Weekly", unit: "week", step: 1 },
- { value: "biweekly", label: "Bi-Weekly", unit: "week", step: 2 },
- { value: "monthly", label: "Monthly", unit: "month", step: 1 },
- // { value: "bimonthly", label: "Every two months from", unit: "month", step: 2 },
- { value: "quarterly", label: "Quarterly", unit: "month", step: 3 },
- { value: "half-year", label: "Half Year", unit: "month", step: 6 },
- // { value: "yearly", label: "Yearly from", unit: "year", step: 1 },
- { value: "fixed", label: "Fixed", unit: "month", step: 0 }, // unit/step not used for fixed
- ];
-
- 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>({
- mode: "onTouched",
- defaultValues: { dateType: "monthly", dateReference: dayjs() },
- });
-
- const formValues = watch();
-
- const newPayments = useMemo<PaymentInputs[]>(() => {
- const {
- numberOfEntries,
- amountToDivide,
- dateType,
- dateReference = dayjs(),
- description,
- } = formValues;
-
- if (
- Number.isInteger(numberOfEntries) &&
- numberOfEntries > 0 &&
- amountToDivide &&
- description
- ) {
- const dividedAmount = truncateMoney(amountToDivide / numberOfEntries)!;
- const amountForLastItem = truncateMoney(
- amountToDivide - dividedAmount * (numberOfEntries - 1),
- )!;
-
- const datetypeOption = datetypeOptions.find(r => r.value === dateType);
-
- return Array(numberOfEntries)
- .fill(undefined)
- .map((_, index) => {
- const date =
- dateType === "fixed"
- ? dateReference
- : dateReference.add(
- index * (datetypeOption?.step ?? 0),
- datetypeOption?.unit ?? "month",
- );
-
- return {
- id: getID(),
- amount:
- index === numberOfEntries - 1 ? amountForLastItem : 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"),
- validate: (value) => {
- if (!value) {
- return t("Required");
- } else if (value < 0) {
- return t("Number must be positive");
- } else if (!Number.isInteger(value)) {
- return t("Number must be an integer");
- } else {
- return true;
- }
- },
- })}
- 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)}
- >
- {datetypeOptions.map(option => (
- <MenuItem key={option.value} value={option.value}>
- {t(option.label)}
- </MenuItem>
- ))}
- {/* <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;
|