You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

318 lines
8.9 KiB

  1. import { Check, ExpandMore } from "@mui/icons-material";
  2. import {
  3. Accordion,
  4. AccordionDetails,
  5. AccordionSummary,
  6. Alert,
  7. Box,
  8. Button,
  9. Divider,
  10. FormControl,
  11. FormHelperText,
  12. InputLabel,
  13. MenuItem,
  14. Modal,
  15. ModalProps,
  16. Paper,
  17. Select,
  18. SxProps,
  19. TextField,
  20. Typography,
  21. } from "@mui/material";
  22. import React, { useCallback, useMemo } from "react";
  23. import { Controller, useForm } from "react-hook-form";
  24. import { useTranslation } from "react-i18next";
  25. import {
  26. INPUT_DATE_FORMAT,
  27. moneyFormatter,
  28. OUTPUT_DATE_FORMAT,
  29. } from "@/app/utils/formatUtil";
  30. import { PaymentInputs } from "@/app/api/projects/actions";
  31. import dayjs from "dayjs";
  32. import { DatePicker } from "@mui/x-date-pickers";
  33. export interface BulkAddPaymentForm {
  34. numberOfEntries: number;
  35. amountToDivide: number;
  36. dateType: "monthly" | "weekly" | "fixed";
  37. dateReference?: dayjs.Dayjs;
  38. description: string;
  39. }
  40. export interface Props extends Omit<ModalProps, "children"> {
  41. onSave: (payments: PaymentInputs[]) => Promise<void>;
  42. modalSx?: SxProps;
  43. }
  44. const modalSx: SxProps = {
  45. position: "absolute",
  46. top: "50%",
  47. left: "50%",
  48. transform: "translate(-50%, -50%)",
  49. width: "90%",
  50. maxWidth: "sm",
  51. maxHeight: "90%",
  52. padding: 3,
  53. display: "flex",
  54. flexDirection: "column",
  55. gap: 2,
  56. };
  57. let idOffset = Date.now();
  58. const getID = () => {
  59. return ++idOffset;
  60. };
  61. const BulkAddPaymentModal: React.FC<Props> = ({
  62. onSave,
  63. open,
  64. onClose,
  65. modalSx: mSx,
  66. }) => {
  67. const { t } = useTranslation();
  68. const { register, reset, trigger, formState, watch, control } =
  69. useForm<BulkAddPaymentForm>({
  70. defaultValues: { dateType: "monthly", dateReference: dayjs() },
  71. });
  72. const formValues = watch();
  73. const newPayments = useMemo<PaymentInputs[]>(() => {
  74. const {
  75. numberOfEntries,
  76. amountToDivide,
  77. dateType,
  78. dateReference = dayjs(),
  79. description,
  80. } = formValues;
  81. if (numberOfEntries > 0 && amountToDivide && description) {
  82. const dividedAmount = amountToDivide / numberOfEntries;
  83. return Array(numberOfEntries)
  84. .fill(undefined)
  85. .map((_, index) => {
  86. const date =
  87. dateType === "fixed"
  88. ? dateReference
  89. : dateReference.add(
  90. index,
  91. dateType === "monthly" ? "month" : "week",
  92. );
  93. return {
  94. id: getID(),
  95. amount: dividedAmount,
  96. description: replaceTemplateString(description, {
  97. "{index}": (index + 1).toString(),
  98. "{date}": date.format(OUTPUT_DATE_FORMAT),
  99. }),
  100. date: date.format(INPUT_DATE_FORMAT),
  101. };
  102. });
  103. } else {
  104. return [];
  105. }
  106. }, [formValues]);
  107. const saveHandler = useCallback(async () => {
  108. const valid = await trigger();
  109. if (valid) {
  110. onSave(newPayments);
  111. reset();
  112. }
  113. }, [trigger, onSave, reset, newPayments]);
  114. const closeHandler = useCallback<NonNullable<ModalProps["onClose"]>>(
  115. (...args) => {
  116. onClose?.(...args);
  117. reset();
  118. },
  119. [onClose, reset],
  120. );
  121. return (
  122. <Modal open={open} onClose={closeHandler}>
  123. <Paper sx={{ ...modalSx, ...mSx }}>
  124. <TextField
  125. type="number"
  126. label={t("Total amount to divide")}
  127. fullWidth
  128. {...register("amountToDivide", {
  129. valueAsNumber: true,
  130. required: t("Required"),
  131. })}
  132. error={Boolean(formState.errors.amountToDivide)}
  133. helperText={
  134. formState.errors.amountToDivide?.message ||
  135. t(
  136. "The inputted amount will be evenly divided by the inputted number of payments",
  137. )
  138. }
  139. />
  140. <TextField
  141. type="number"
  142. label={t("Number of payments")}
  143. fullWidth
  144. {...register("numberOfEntries", {
  145. valueAsNumber: true,
  146. required: t("Required"),
  147. })}
  148. error={Boolean(formState.errors.numberOfEntries)}
  149. helperText={formState.errors.numberOfEntries?.message}
  150. />
  151. <FormControl fullWidth error={Boolean(formState.errors.dateType)}>
  152. <InputLabel shrink>{t("Default Date Type")}</InputLabel>
  153. <Controller
  154. control={control}
  155. name="dateType"
  156. render={({ field }) => (
  157. <Select
  158. label={t("Date type")}
  159. {...field}
  160. error={Boolean(formState.errors.dateType)}
  161. >
  162. <MenuItem value="monthly">{t("Monthly from")}</MenuItem>
  163. <MenuItem value="weekly">{t("Weekly from")}</MenuItem>
  164. <MenuItem value="fixed">{t("Fixed")}</MenuItem>
  165. </Select>
  166. )}
  167. rules={{
  168. required: t("Required"),
  169. }}
  170. />
  171. <FormHelperText>
  172. {"The type of date to use for each payment entry."}
  173. </FormHelperText>
  174. </FormControl>
  175. <FormControl fullWidth error={Boolean(formState.errors.dateReference)}>
  176. <Controller
  177. control={control}
  178. name="dateReference"
  179. render={({ field }) => (
  180. <DatePicker
  181. label={t("Date reference")}
  182. format={OUTPUT_DATE_FORMAT}
  183. {...field}
  184. />
  185. )}
  186. rules={{
  187. required: t("Required"),
  188. }}
  189. />
  190. <FormHelperText>
  191. {
  192. "The reference date for calculating the date for each payment entry."
  193. }
  194. </FormHelperText>
  195. </FormControl>
  196. <TextField
  197. label={t("Payment description")}
  198. fullWidth
  199. multiline
  200. rows={2}
  201. error={Boolean(formState.errors.description)}
  202. {...register("description", {
  203. required: t("Required"),
  204. })}
  205. helperText={
  206. formState.errors.description?.message ||
  207. t(
  208. '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.',
  209. )
  210. }
  211. />
  212. <Accordion variant="outlined" sx={{ overflowY: "scroll" }}>
  213. <AccordionSummary expandIcon={<ExpandMore />}>
  214. <Typography variant="subtitle2">
  215. {t("Milestone payments preview")}
  216. </Typography>
  217. </AccordionSummary>
  218. <AccordionDetails>
  219. {newPayments.length > 0 ? (
  220. <PaymentSummary paymentInputs={newPayments} />
  221. ) : (
  222. <Alert severity="warning">
  223. {t(
  224. "Please input the amount to divde, the number of payments, and the description.",
  225. )}
  226. </Alert>
  227. )}
  228. </AccordionDetails>
  229. </Accordion>
  230. <Box display="flex" justifyContent="flex-end">
  231. <Button
  232. variant="contained"
  233. startIcon={<Check />}
  234. onClick={saveHandler}
  235. >
  236. {t("Save")}
  237. </Button>
  238. </Box>
  239. </Paper>
  240. </Modal>
  241. );
  242. };
  243. const PaymentSummary: React.FC<{ paymentInputs: PaymentInputs[] }> = ({
  244. paymentInputs,
  245. }) => {
  246. const { t } = useTranslation();
  247. return (
  248. <Box
  249. sx={{
  250. display: "flex",
  251. flexDirection: "column",
  252. gap: 2,
  253. }}
  254. >
  255. {paymentInputs.map(({ id, date, description, amount }, index) => {
  256. return (
  257. <Box key={`${index}-${id}`}>
  258. <Box marginBlockEnd={1}>
  259. <Typography variant="body2" component="div" fontWeight="bold">
  260. {t("Description")}
  261. </Typography>
  262. <Typography component="p">{description}</Typography>
  263. </Box>
  264. <Box display="flex" gap={2} justifyContent="space-between">
  265. <Box>
  266. <Typography variant="body2" component="div" fontWeight="bold">
  267. {t("Date")}
  268. </Typography>
  269. <Typography component="p">{date}</Typography>
  270. </Box>
  271. <Box>
  272. <Typography variant="body2" component="div" fontWeight="bold">
  273. {t("Amount")}
  274. </Typography>
  275. <Typography component="p">
  276. {moneyFormatter.format(amount)}
  277. </Typography>
  278. </Box>
  279. </Box>
  280. {index !== paymentInputs.length - 1 && (
  281. <Divider sx={{ marginBlockStart: 2 }} />
  282. )}
  283. </Box>
  284. );
  285. })}
  286. </Box>
  287. );
  288. };
  289. const replaceTemplateString = (
  290. template: string,
  291. replacements: { [key: string]: string },
  292. ): string => {
  293. let returnString = template;
  294. Object.entries(replacements).forEach(([key, replacement]) => {
  295. returnString = returnString.replaceAll(key, replacement);
  296. });
  297. return returnString;
  298. };
  299. export default BulkAddPaymentModal;