diff --git a/package.json b/package.json index 45e6de4a..d952f195 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "i18next-http-backend": "^3.0.2", "input-otp": "^1.2.3", "lucide-react": "^0.544.0", + "motion": "^12.23.24", "nanoid": "^5.0.6", "next": "15.4.7", "next-auth": "^4.24.11", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d33219b..da868da8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: lucide-react: specifier: ^0.544.0 version: 0.544.0(react@19.1.1) + motion: + specifier: ^12.23.24 + version: 12.23.24(react-dom@19.1.1(react@19.1.1))(react@19.1.1) nanoid: specifier: ^5.0.6 version: 5.1.5 @@ -3272,6 +3275,20 @@ packages: resolution: {integrity: sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==} engines: {node: '>= 6'} + framer-motion@12.23.24: + resolution: {integrity: sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -3866,6 +3883,26 @@ packages: engines: {node: '>=10'} hasBin: true + motion-dom@12.23.23: + resolution: {integrity: sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==} + + motion-utils@12.23.6: + resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==} + + motion@12.23.24: + resolution: {integrity: sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -8400,6 +8437,15 @@ snapshots: combined-stream: 1.0.8 mime-types: 2.1.35 + framer-motion@12.23.24(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + motion-dom: 12.23.23 + motion-utils: 12.23.6 + tslib: 2.8.1 + optionalDependencies: + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -9218,6 +9264,20 @@ snapshots: mkdirp@3.0.1: {} + motion-dom@12.23.23: + dependencies: + motion-utils: 12.23.6 + + motion-utils@12.23.6: {} + + motion@12.23.24(react-dom@19.1.1(react@19.1.1))(react@19.1.1): + dependencies: + framer-motion: 12.23.24(react-dom@19.1.1(react@19.1.1))(react@19.1.1) + tslib: 2.8.1 + optionalDependencies: + react: 19.1.1 + react-dom: 19.1.1(react@19.1.1) + ms@2.1.3: {} nano-spawn@1.0.2: {} diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 76eeee63..af13eeee 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -220,6 +220,12 @@ "text": "This action cannot be undone. This will permanently delete your expense.", "title": "Are you absolutely sure?" }, + "multiple_transactions": { + "title": "Add multiple transactions", + "description": "You have selected multiple transactions. How do you want to add them?", + "add_all": "Add all", + "add_one_by_one": "Add one by one" + }, "title": "Add expense", "title_mobile": "Add", "clear": "Clear", diff --git a/src/components/AddExpense/AddBankTransactions.tsx b/src/components/AddExpense/AddBankTransactions.tsx index fe329a19..b3886913 100644 --- a/src/components/AddExpense/AddBankTransactions.tsx +++ b/src/components/AddExpense/AddBankTransactions.tsx @@ -1,151 +1,140 @@ import React, { useCallback } from 'react'; -import { useAddExpenseStore } from '~/store/addStore'; +import { calculateParticipantSplit, useAddExpenseStore } from '~/store/addStore'; import type { TransactionAddInputModel } from '~/types'; import { BankingTransactionList } from './BankTransactions/BankingTransactionList'; -import { isCurrencyCode } from '~/lib/currency'; +import { useRouter } from 'next/router'; +import { api } from '~/utils/api'; +import { type CreateExpense } from '~/types/expense.types'; const AddBankTransactions: React.FC<{ - // clearFields: () => void; + clearFields: () => void; bankConnectionEnabled: boolean; children: React.ReactNode; -}> = ({ bankConnectionEnabled, children }) => { - // const participants = useAddExpenseStore((s) => s.participants); - // const group = useAddExpenseStore((s) => s.group); - // const category = useAddExpenseStore((s) => s.category); - // const isExpenseSettled = useAddExpenseStore((s) => s.canSplitScreenClosed); - // const splitShares = useAddExpenseStore((s) => s.splitShares); - // const paidBy = useAddExpenseStore((s) => s.paidBy); - // const splitType = useAddExpenseStore((s) => s.splitType); - // const fileKey = useAddExpenseStore((s) => s.fileKey); - // const multipleTransactions = useAddExpenseStore((s) => s.multipleTransactions); - // const isTransactionLoading = useAddExpenseStore((s) => s.isTransactionLoading); - - const { - setCurrency, - setDescription, - // resetState, - // setSplitScreenOpen, - setExpenseDate, - setTransactionId, - setAmountStr, - // setMultipleTransactions, - // setIsTransactionLoading, - } = useAddExpenseStore((s) => s.actions); - - // const addExpenseMutation = api.expense.addOrEditExpense.useMutation(); - - // const router = useRouter(); - - // const addMultipleExpenses = useCallback(async () => { - // setIsTransactionLoading(true); - - // if (!paidBy) { - // return; - // } - - // if (!isExpenseSettled) { - // setSplitScreenOpen(true); - // return; - // } - - // const seen = new Set(); - // const deduplicated = multipleTransactions.filter((item) => { - // if (seen.has(item.transactionId)) { - // return false; - // } - // seen.add(item.transactionId); - // return true; - // }); - - // const expensePromises = deduplicated.map(async (tempItem) => { - // if (tempItem) { - // const normalizedAmount = tempItem.amount.replace(',', '.'); - // const _amtBigInt = BigInt(Math.round(Number(normalizedAmount) * 100)); - - // const { participants: tempParticipants } = calculateParticipantSplit( - // _amtBigInt, - // participants, - // splitType, - // splitShares, - // paidBy, - // ); - - // return addExpenseMutation.mutateAsync({ - // name: tempItem.description, - // currency: tempItem.currency, - // amount: _amtBigInt, - // groupId: group?.id ?? null, - // splitType, - // participants: tempParticipants.map((p) => ({ - // userId: p.id, - // amount: p.amount ?? 0n, - // })), - // paidBy: paidBy.id, - // category, - // fileKey, - // expenseDate: tempItem.date, - // expenseId: tempItem.expenseId, - // transactionId: tempItem.transactionId, - // }); - // } - // return Promise.resolve(); - // }); - - // await Promise.all(expensePromises); - - // setMultipleTransactions([]); - // setIsTransactionLoading(false); - // router.back(); - // resetState(); - // }, [ - // setSplitScreenOpen, - // router, - // resetState, - // addExpenseMutation, - // group, - // paidBy, - // splitType, - // fileKey, - // isExpenseSettled, - // multipleTransactions, - // participants, - // category, - // setIsTransactionLoading, - // splitShares, - // setMultipleTransactions, - // ]); - - const addViaBankTransaction = useCallback( - (obj: TransactionAddInputModel) => { - setExpenseDate(obj.date); - setDescription(obj.description); - if (isCurrencyCode(obj.currency)) { - setCurrency(obj.currency); - } else { - console.warn(`Invalid currency code: ${obj.currency}`); - } - setAmountStr(obj.amount); - setTransactionId(obj.transactionId); + addViaBankTransaction: (obj: TransactionAddInputModel) => void; +}> = ({ bankConnectionEnabled, children, clearFields, addViaBankTransaction }) => { + const participants = useAddExpenseStore((s) => s.participants); + const group = useAddExpenseStore((s) => s.group); + const category = useAddExpenseStore((s) => s.category); + const isExpenseSettled = useAddExpenseStore((s) => s.canSplitScreenClosed); + const splitShares = useAddExpenseStore((s) => s.splitShares); + const paidBy = useAddExpenseStore((s) => s.paidBy); + const splitType = useAddExpenseStore((s) => s.splitType); + const fileKey = useAddExpenseStore((s) => s.fileKey); + const multipleTransactions = useAddExpenseStore((s) => s.multipleTransactions); + const isTransactionLoading = useAddExpenseStore((s) => s.isTransactionLoading); + + const { resetState, setSplitScreenOpen, setMultipleTransactions, setIsTransactionLoading } = + useAddExpenseStore((s) => s.actions); + + const addExpenseMutation = api.expense.addOrEditExpense.useMutation(); + + const router = useRouter(); + + const addAllMultipleExpenses = useCallback(async () => { + setIsTransactionLoading(true); + + if (!paidBy) { + return; + } + + if (!isExpenseSettled) { + setSplitScreenOpen(true); + return; + } + + const seen = new Set(); + + const expenses = multipleTransactions + .filter((item) => { + if (seen.has(item.transactionId)) { + return false; + } + seen.add(item.transactionId); + return true; + }) + .map((tempItem) => { + const { participants: tempParticipants } = calculateParticipantSplit( + tempItem.amount, + participants, + splitType, + splitShares, + paidBy, + ); + + return { + name: tempItem.description, + currency: tempItem.currency, + amount: tempItem.amount, + groupId: group?.id ?? null, + splitType, + participants: tempParticipants.map((p) => ({ + userId: p.id, + amount: p.amount ?? 0n, + })), + paidBy: paidBy.id, + category, + fileKey, + expenseDate: tempItem.date, + expenseId: tempItem.expenseId, + transactionId: tempItem.transactionId, + }; + }) as CreateExpense[]; + + await addExpenseMutation.mutateAsync(expenses, { + onSuccess: () => { + setMultipleTransactions([]); + setIsTransactionLoading(false); + router.back(); + resetState(); + }, + onError: () => { + setIsTransactionLoading(false); + }, + }); + }, [ + setSplitScreenOpen, + router, + resetState, + addExpenseMutation, + group, + paidBy, + splitType, + fileKey, + isExpenseSettled, + multipleTransactions, + participants, + category, + setIsTransactionLoading, + splitShares, + setMultipleTransactions, + ]); + + const addOneByOneMultipleExpenses = useCallback(async () => { + const allTransactions = [...multipleTransactions]; + const transactionToAdd = allTransactions.pop(); + if (transactionToAdd) { + setMultipleTransactions(allTransactions); + addViaBankTransaction(transactionToAdd); + } + }, [multipleTransactions, setMultipleTransactions, addViaBankTransaction]); + + const handleSetMultipleTransactions = useCallback( + (a: TransactionAddInputModel[]) => { + setMultipleTransactions(a); }, - [setExpenseDate, setDescription, setCurrency, setAmountStr, setTransactionId], + [setMultipleTransactions], ); - // const handleSetMultipleTransactions = useCallback( - // (a: TransactionAddInputModel[]) => { - // setMultipleTransactions(a); - // }, - // [setMultipleTransactions], - // ); - return ( {children} diff --git a/src/components/AddExpense/AddExpensePage.tsx b/src/components/AddExpense/AddExpensePage.tsx index 10076f18..d87296f6 100644 --- a/src/components/AddExpense/AddExpensePage.tsx +++ b/src/components/AddExpense/AddExpensePage.tsx @@ -27,6 +27,7 @@ import { CurrencyInput } from '../ui/currency-input'; import { CurrencyConversion } from '../Friend/CurrencyConversion'; import { currencyConversion } from '~/utils/numbers'; import { CURRENCY_CONVERSION_ICON } from '../ui/categoryIcons'; +import type { TransactionAddInputModel } from '~/types'; export const AddOrEditExpensePage: React.FC<{ isStorageConfigured: boolean; @@ -53,6 +54,7 @@ export const AddOrEditExpensePage: React.FC<{ const splitShares = useAddExpenseStore((s) => s.splitShares); const transactionId = useAddExpenseStore((s) => s.transactionId); const cronExpression = useAddExpenseStore((s) => s.cronExpression); + const multipleTransactions = useAddExpenseStore((s) => s.multipleTransactions); const { t, displayName, generateSplitDescription } = useTranslationWithUtils(); @@ -98,6 +100,18 @@ export const AddOrEditExpensePage: React.FC<{ [setAmount, setAmountStr], ); + const addViaBankTransaction = useCallback( + (obj: TransactionAddInputModel) => { + setExpenseDate(obj.date); + setDescription(obj.description); + setCurrency(obj.currency); + setAmountStr(obj.amountStr); + setAmount(obj.amount); + setTransactionId(obj.transactionId); + }, + [setExpenseDate, setDescription, setCurrency, setAmountStr, setAmount, setTransactionId], + ); + const addExpense = useCallback(async () => { if (!paidBy) { return; @@ -115,48 +129,60 @@ export const AddOrEditExpensePage: React.FC<{ try { await addExpenseMutation.mutateAsync( - { - name: description, - currency, - amount: amount * sign, - groupId: group?.id ?? null, - splitType, - participants: participants.map((p) => ({ - userId: p.id, - amount: (p.amount ?? 0n) * sign, - })), - paidBy: paidBy.id, - category, - fileKey, - expenseDate, - expenseId, - transactionId, - cronExpression: cronExpression ? cronToBackend(cronExpression) : undefined, - }, + [ + { + name: description, + currency, + amount: amount * sign, + groupId: group?.id ?? null, + splitType, + participants: participants.map((p) => ({ + userId: p.id, + amount: (p.amount ?? 0n) * sign, + })), + paidBy: paidBy.id, + category, + fileKey, + expenseDate, + expenseId, + transactionId, + cronExpression: cronExpression ? cronToBackend(cronExpression) : undefined, + }, + ], { onSuccess: (d) => { if (d) { - const id = d?.id ?? expenseId; + if (multipleTransactions.length > 0) { + const allTransactions = [...multipleTransactions]; + const transactionToAdd = allTransactions.pop(); + if (transactionToAdd) { + setMultipleTransactions(allTransactions); + addViaBankTransaction(transactionToAdd); + } + return; + } else { + const id = d.length > 0 ? d[0]?.id : expenseId; - let navPromise: () => Promise = () => Promise.resolve(true); + let navPromise: () => Promise = () => Promise.resolve(true); - const { friendId, groupId } = router.query; + const { friendId, groupId } = router.query; - if (friendId && !groupId) { - navPromise = () => router.push(`/balances/${friendId as string}/expenses/${id}`); - } else if (groupId) { - navPromise = () => router.push(`/groups/${groupId as string}/expenses/${id}`); - } else { - navPromise = () => router.push(`/expenses/${id}?keepAdding=1`); - } + if (friendId && !groupId) { + navPromise = () => router.push(`/balances/${friendId as string}/expenses/${id}`); + } else if (groupId) { + navPromise = () => router.push(`/groups/${groupId as string}/expenses/${id}`); + } else { + navPromise = () => router.push(`/expenses/${id}?keepAdding=1`); + } - if (expenseId) { - navPromise = async () => router.back(); - } + if (expenseId) { + navPromise = async () => router.back(); + } - navPromise() - .then(() => resetState()) - .catch(console.error); + navPromise() + .then(() => resetState()) + .catch(console.error); + } } }, }, @@ -191,6 +217,8 @@ export const AddOrEditExpensePage: React.FC<{ transactionId, setIsTransactionLoading, cronExpression, + multipleTransactions, + addViaBankTransaction, ]); const handleDescriptionChange = useCallback( @@ -208,6 +236,11 @@ export const AddOrEditExpensePage: React.FC<{ setExpenseDate(new Date()); }, [setAmount, setDescription, setAmountStr, setTransactionId, setExpenseDate]); + const clearTransaction = useCallback(() => { + clearFields(); + setMultipleTransactions([]); + }, [clearFields, setMultipleTransactions]); + const previousCurrencyRef = React.useRef(null); const onConvertAmount: React.ComponentProps['onSubmit'] = useCallback( @@ -368,8 +401,9 @@ export const AddOrEditExpensePage: React.FC<{
diff --git a/src/components/AddExpense/BankTransactions/BankTransactionItem.tsx b/src/components/AddExpense/BankTransactions/BankTransactionItem.tsx index 618bbd94..ed48aec0 100644 --- a/src/components/AddExpense/BankTransactions/BankTransactionItem.tsx +++ b/src/components/AddExpense/BankTransactions/BankTransactionItem.tsx @@ -1,10 +1,17 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useState } from 'react'; import { Button } from '../../ui/button'; -// import { Checkbox } from '../../ui/checkbox'; -// import type { TransactionAddInputModel } from '~/types'; +import { Checkbox } from '../../ui/checkbox'; +import type { TransactionAddInputModel } from '~/types'; import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; import { cn } from '~/lib/utils'; import type { TransactionWithPendingStatus } from './BankingTransactionList'; +import { AnimatePresence, motion } from 'motion/react'; + +const checkboxAnimationInitial = { opacity: 0, x: -8 }; +const checkboxAnimationAnimate = { opacity: 1, x: 0 }; +const checkboxAnimationExit = { opacity: 0, x: -8 }; +const checkboxAnimationTransition = { duration: 0.2, ease: 'easeOut' as const }; +const contentLayoutTransition = { duration: 0.2, ease: 'easeOut' as const }; export const BankTransactionItem: React.FC<{ index: number; @@ -12,14 +19,17 @@ export const BankTransactionItem: React.FC<{ item: TransactionWithPendingStatus; onTransactionRowClick: (item: TransactionWithPendingStatus, multiple: boolean) => void; groupName: string; - // multipleTransactions: TransactionAddInputModel[]; -}> = ({ index, alreadyAdded, item, onTransactionRowClick, groupName }) => { + multipleTransactions: TransactionAddInputModel[]; +}> = ({ index, alreadyAdded, item, onTransactionRowClick, groupName, multipleTransactions }) => { const { t, toUIDate } = useTranslationWithUtils(); + const [isHovered, setIsHovered] = useState(false); - // const createCheckboxHandler = useCallback( - // (item: TransactionWithPendingStatus) => () => onTransactionRowClick(item, true), - // [onTransactionRowClick], - // ); + const createCheckboxHandler = useCallback( + (item: TransactionWithPendingStatus) => () => { + onTransactionRowClick(item, true); + }, + [onTransactionRowClick], + ); const createClickHandler = useCallback( () => onTransactionRowClick(item, false), @@ -30,18 +40,67 @@ export const BankTransactionItem: React.FC<{ ? Number(item.transactionAmount.amount) < 0 : false; + const hasMultiple = multipleTransactions.length > 0; + + const isChecked = multipleTransactions?.some( + (cItem) => cItem.transactionId === item.transactionId, + ); + + const shouldShowCheckbox = (hasMultiple || isHovered) && (!alreadyAdded || hasMultiple); + + const handleMouseEnter = useCallback(() => setIsHovered(true), []); + const handleMouseLeave = useCallback(() => setIsHovered(false), []); + return ( -
-
- {/* cItem.transactionId === item.transactionId, - )} +
+
+
+ +
+ +
+ + {shouldShowCheckbox && ( + + + + )} + +
+
+ + +
-
+

-
+ +
void; - // addMultipleExpenses: () => void; - // multipleTransactions: TransactionAddInputModel[]; - // setMultipleTransactions: (a: TransactionAddInputModel[]) => void; - // isTransactionLoading: boolean; + addAllMultipleExpenses: () => void; + addOneByOneMultipleExpenses: () => void; + multipleTransactions: TransactionAddInputModel[]; + setMultipleTransactions: (a: TransactionAddInputModel[]) => void; + isTransactionLoading: boolean; bankConnectionEnabled: boolean; children: React.ReactNode; - // clearFields: () => void; + clearFields: () => void; }> = ({ add, - // addMultipleExpenses, - // multipleTransactions, - // setMultipleTransactions, - // isTransactionLoading, + addAllMultipleExpenses, + addOneByOneMultipleExpenses, + multipleTransactions, + setMultipleTransactions, + isTransactionLoading, bankConnectionEnabled, children, - // clearFields, + clearFields, }) => { const { t } = useTranslationWithUtils(); const [open, setOpen] = React.useState(false); + const [showMultipleTransactionModal, setShowMultipleTransactionModal] = React.useState(false); const userQuery = api.user.me.useQuery(); const transactions = api.bankTransactions.getTransactions.useQuery( @@ -74,45 +79,75 @@ export const BankingTransactionList: React.FC<{ const transactionsArray = returnTransactionsArray(); const onTransactionRowClick = useCallback( - (item: TransactionWithPendingStatus) => { - const transactionData = { + (item: TransactionWithPendingStatus, multiple: boolean) => { + if (!isCurrencyCode(item.transactionAmount.currency)) { + console.warn(`Invalid currency code: ${item.transactionAmount.currency}`); + return; + } + + const normalizedAmount = item.transactionAmount.amount.replaceAll('-', '').replace(',', '.'); + const parsedAmount = Number(normalizedAmount); + + if (isNaN(parsedAmount) || !isFinite(parsedAmount)) { + console.warn(`Invalid amount: ${item.transactionAmount.amount}`); + return; + } + + const bigIntAmount = BigInt(Math.round(parsedAmount * 100)); + + const transactionData: TransactionAddInputModel = { date: new Date(item.bookingDate), - amount: item.transactionAmount.amount.replace('-', ''), - currency: item.transactionAmount.currency, + amountStr: item.transactionAmount.amount.replaceAll('-', ''), + amount: bigIntAmount, + currency: parseCurrencyCode(item.transactionAmount.currency), description: item.description, transactionId: item.transactionId, }; - // if (multiple) { - // clearFields(); - // const isInMultipleTransactions = multipleTransactions?.some( - // (cItem) => cItem.transactionId === item.transactionId, - // ); - - // setMultipleTransactions( - // isInMultipleTransactions - // ? multipleTransactions.filter((cItem) => cItem.transactionId !== item.transactionId) - // : [...multipleTransactions, transactionData], - // ); - // } else { - if (alreadyAdded(item.transactionId)) { - return; + if (multiple) { + clearFields(); + const isInMultipleTransactions = multipleTransactions?.some( + (cItem) => cItem.transactionId === item.transactionId, + ); + + setMultipleTransactions( + isInMultipleTransactions + ? multipleTransactions.filter((cItem) => cItem.transactionId !== item.transactionId) + : [...multipleTransactions, transactionData as TransactionAddInputModel], + ); + } else { + if (alreadyAdded(item.transactionId)) { + return; + } + add(transactionData); + setOpen(false); + document.getElementById('mainlayout')?.scrollTo({ top: 0, behavior: 'instant' }); } - add(transactionData); - setOpen(false); - document.getElementById('mainlayout')?.scrollTo({ top: 0, behavior: 'instant' }); - // } }, - [add, alreadyAdded], + [add, alreadyAdded, multipleTransactions, setMultipleTransactions, clearFields], ); - const setOpenClose = useCallback((open: boolean) => { - setOpen(open); - // if (!open) { - // setMultipleTransactions([]); - // } + const setOpenClose = useCallback( + (open: boolean) => { + setOpen(open); + if (!open) { + setMultipleTransactions([]); + } + }, + [setMultipleTransactions], + ); + + const handleAddMultipleExpenses = useCallback(() => { + setShowMultipleTransactionModal(true); }, []); + const hasMultipleTransactions = multipleTransactions.length > 0; + + const handleAddOneByOneMultipleExpenses = useCallback(() => { + setOpen(false); + addOneByOneMultipleExpenses(); + }, [addOneByOneMultipleExpenses]); + if (!bankConnectionEnabled || !userQuery.data?.obapiProviderId) { return null; } @@ -124,13 +159,9 @@ export const BankingTransactionList: React.FC<{ open={open} onOpenChange={setOpenClose} className="h-[80vh]" - // actionTitle={t('expense_details.submit_all')} - // actionOnClick={addMultipleExpenses} - // actionDisabled={ - // (multipleTransactions?.length || 0) === 0 || - // isTransactionLoading - // } - shouldCloseOnAction + actionTitle={hasMultipleTransactions ? t('expense_details.submit_all') : undefined} + actionOnClick={hasMultipleTransactions ? handleAddMultipleExpenses : undefined} + actionDisabled={(multipleTransactions?.length || 0) === 0 || isTransactionLoading} >
{transactions?.isLoading ? ( @@ -152,12 +183,18 @@ export const BankingTransactionList: React.FC<{ alreadyAdded={alreadyAdded(item.transactionId)} onTransactionRowClick={onTransactionRowClick} groupName={returnGroupName(item.transactionId)} - // multipleTransactions={multipleTransactions} + multipleTransactions={multipleTransactions} /> ))} )}
+ ); }; diff --git a/src/components/AddExpense/BankTransactions/MultipleTransactionModal.tsx b/src/components/AddExpense/BankTransactions/MultipleTransactionModal.tsx new file mode 100644 index 00000000..713ace42 --- /dev/null +++ b/src/components/AddExpense/BankTransactions/MultipleTransactionModal.tsx @@ -0,0 +1,56 @@ +import { useCallback } from 'react'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '~/components/ui/alert-dialog'; +import { useTranslationWithUtils } from '~/hooks/useTranslationWithUtils'; + +export const MultipleTransactionModal = ({ + modalOpen, + setModalOpen, + onAddAll, + onAddOneByOne, +}: { + modalOpen: boolean; + setModalOpen: (open: boolean) => void; + onAddAll: () => void; + onAddOneByOne: () => void; +}) => { + const { t } = useTranslationWithUtils(); + + const handleOnAddAll = useCallback(() => { + setModalOpen(false); + onAddAll(); + }, [setModalOpen, onAddAll]); + + const handleOnAddOneByOne = useCallback(() => { + setModalOpen(false); + onAddOneByOne(); + }, [setModalOpen, onAddOneByOne]); + + return ( + + + + {t('expense_details.multiple_transactions.title')} + + {t('expense_details.multiple_transactions.description')} + + + + + {t('expense_details.multiple_transactions.add_all')} + + + {t('expense_details.multiple_transactions.add_one_by_one')} + + + + + ); +}; diff --git a/src/components/Friend/GroupSettleup.tsx b/src/components/Friend/GroupSettleup.tsx index 49ba8e5f..30c88819 100644 --- a/src/components/Friend/GroupSettleup.tsx +++ b/src/components/Friend/GroupSettleup.tsx @@ -49,25 +49,27 @@ export const GroupSettleUp: React.FC<{ } addExpenseMutation.mutate( - { - name: t('ui.settle_up_name'), - currency: currency, - amount, - splitType: SplitType.SETTLEMENT, - groupId, - participants: [ - { - userId: sender.id, - amount, - }, - { - userId: receiver.id, - amount: -amount, - }, - ], - paidBy: sender.id, - category: DEFAULT_CATEGORY, - }, + [ + { + name: t('ui.settle_up_name'), + currency: currency, + amount, + splitType: SplitType.SETTLEMENT, + groupId, + participants: [ + { + userId: sender.id, + amount, + }, + { + userId: receiver.id, + amount: -amount, + }, + ], + paidBy: sender.id, + category: DEFAULT_CATEGORY, + }, + ], { onSuccess: () => { utils.group.invalidate().catch(console.error); diff --git a/src/components/Friend/Settleup.tsx b/src/components/Friend/Settleup.tsx index 94909fbc..bd056276 100644 --- a/src/components/Friend/Settleup.tsx +++ b/src/components/Friend/Settleup.tsx @@ -66,25 +66,27 @@ export const SettleUp: React.FC< } addExpenseMutation.mutate( - { - name: t('ui.settle_up_name'), - currency: balanceToSettle.currency, - amount, - splitType: SplitType.SETTLEMENT, - participants: [ - { - userId: currentUser.id, - amount: isCurrentUserPaying ? amount : -amount, - }, - { - userId: friend.id, - amount: isCurrentUserPaying ? -amount : amount, - }, - ], - paidBy: isCurrentUserPaying ? currentUser.id : friend.id, - category: DEFAULT_CATEGORY, - groupId: null, - }, + [ + { + name: t('ui.settle_up_name'), + currency: balanceToSettle.currency, + amount, + splitType: SplitType.SETTLEMENT, + participants: [ + { + userId: currentUser.id, + amount: isCurrentUserPaying ? amount : -amount, + }, + { + userId: friend.id, + amount: isCurrentUserPaying ? -amount : amount, + }, + ], + paidBy: isCurrentUserPaying ? currentUser.id : friend.id, + category: DEFAULT_CATEGORY, + groupId: null, + }, + ], { onSuccess: () => { utils.user.invalidate().catch(console.error); diff --git a/src/server/api/routers/expense.ts b/src/server/api/routers/expense.ts index 70a0e7f4..01d9b388 100644 --- a/src/server/api/routers/expense.ts +++ b/src/server/api/routers/expense.ts @@ -80,63 +80,73 @@ export const expenseRouter = createTRPCRouter({ }), addOrEditExpense: protectedProcedure - .input(createExpenseSchema) - .mutation(async ({ input, ctx }) => { - if (input.expenseId) { - await validateEditExpensePermission(input.expenseId, ctx.session.user.id); - } - if (input.splitType === SplitType.CURRENCY_CONVERSION) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid split type' }); - } - - if (input.groupId) { - const group = await db.group.findUnique({ - where: { id: input.groupId }, - select: { archivedAt: true }, - }); - if (!group) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' }); + .input(z.array(createExpenseSchema)) + .mutation(async ({ input: expenses, ctx }) => { + const results = []; + for (const input of expenses) { + if (input.expenseId) { + await validateEditExpensePermission(input.expenseId, ctx.session.user.id); } - if (group.archivedAt) { - throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' }); + if (input.splitType === SplitType.CURRENCY_CONVERSION) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Invalid split type' }); } - } - try { - const expense = input.expenseId - ? await editExpense(input, ctx.session.user.id) - : await createExpense(input, ctx.session.user.id); - - if (expense && input.cronExpression) { - const [{ schedule }] = await createRecurringExpenseJob(expense.id, input.cronExpression); - console.log('Created recurring expense job with jobid:', schedule); - - await db.expense.update({ - where: { id: expense.id }, - data: { - recurrence: { - upsert: { - create: { - job: { - connect: { jobid: schedule }, + if (input.groupId) { + const group = await db.group.findUnique({ + where: { id: input.groupId }, + select: { archivedAt: true }, + }); + if (!group) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group not found' }); + } + if (group.archivedAt) { + throw new TRPCError({ code: 'BAD_REQUEST', message: 'Group is archived' }); + } + } + + try { + const expense = input.expenseId + ? await editExpense(input, ctx.session.user.id) + : await createExpense(input, ctx.session.user.id); + + if (expense && input.cronExpression) { + const [{ schedule }] = await createRecurringExpenseJob( + expense.id, + input.cronExpression, + ); + console.log('Created recurring expense job with jobid:', schedule); + + await db.expense.update({ + where: { id: expense.id }, + data: { + recurrence: { + upsert: { + create: { + job: { + connect: { jobid: schedule }, + }, }, - }, - update: { - job: { - connect: { jobid: schedule }, + update: { + job: { + connect: { jobid: schedule }, + }, }, }, }, }, - }, + }); + } + + results.push(expense); + } catch (error) { + console.error(error); + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to create expense', }); } - - return expense; - } catch (error) { - console.error(error); - throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'Failed to create expense' }); } + return results; }), addOrEditCurrencyConversion: protectedProcedure diff --git a/src/types.ts b/src/types.ts index 3e3f82b3..88c2ac39 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,7 @@ import { type NextPage } from 'next'; import { type User } from 'next-auth'; import { z } from 'zod'; +import { type CurrencyCode } from './lib/currency'; export type NextPageWithUser = NextPage<{ user: User } & T> & { auth: boolean }; @@ -39,8 +40,9 @@ export interface SplitwiseGroup { export interface TransactionAddInputModel { date: Date; description: string; - amount: string; - currency: string; + amountStr: string; + amount: bigint; + currency: CurrencyCode; transactionId?: string; expenseId?: string; }