Non puoi selezionare più di 25 argomenti Gli argomenti devono iniziare con una lettera o un numero, possono includere trattini ('-') e possono essere lunghi fino a 35 caratteri.
 
 

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