From 8a9a37b26594b14d0cbeb95988c8dec3ed6fd067 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Fri, 16 May 2025 12:19:51 +0000 Subject: [PATCH 001/156] Precisamos adicionar uma situacao de conversao correta de moedas entreas contas. Por exemplo eu uso a Wise para trocar EUR por BRL. e vice versa, adicionei uma transacao dessa e acabou salvando a trasacao da Wise em EUR foi para a conta de Nubank que e em BRL. Precisamos dar a opcao de uma conta poder ter duas moedas e dois saldos, como o caso da Wise --- .../transactions/add-transaction-form.tsx | 80 ++++++++++++------- 1 file changed, 52 insertions(+), 28 deletions(-) diff --git a/src/components/transactions/add-transaction-form.tsx b/src/components/transactions/add-transaction-form.tsx index db8cc30..4466e4d 100644 --- a/src/components/transactions/add-transaction-form.tsx +++ b/src/components/transactions/add-transaction-form.tsx @@ -1,3 +1,4 @@ + 'use client'; import { FC, useMemo, useEffect } from 'react'; @@ -85,10 +86,10 @@ interface AddTransactionFormProps { onTransferAdded?: (data: { fromAccountId: string; toAccountId: string; - amount: number; - transactionCurrency: string; - toAccountAmount?: number; - toAccountCurrency?: string; + amount: number; // Amount in fromAccount's currency + transactionCurrency: string; // Currency of fromAccount + toAccountAmount: number; // Amount in toAccount's currency + toAccountCurrency: string; // Currency of toAccount date: Date; description?: string; tags?: string[]; @@ -166,20 +167,36 @@ const AddTransactionForm: FC = ({ if (values.type === 'transfer') { if (onTransferAdded) { - let toAccountAmount = values.amount; - if (values.transactionCurrency !== values.toAccountCurrency && values.exchangeRate) { - toAccountAmount = values.amount * values.exchangeRate; + const fromAccount = accounts.find(acc => acc.id === values.fromAccountId); + const toAccount = accounts.find(acc => acc.id === values.toAccountId); + + if (!fromAccount || !toAccount) { + toast({ title: "Error", description: "Source or destination account not found.", variant: "destructive"}); + return; + } + + const finalTransactionCurrency = fromAccount.currency; + const finalToAccountCurrency = toAccount.currency; + let finalToAccountAmount = values.amount; + + if (finalTransactionCurrency !== finalToAccountCurrency) { + if (!values.exchangeRate || values.exchangeRate <= 0) { + form.setError("exchangeRate", { type: "manual", message: "Exchange rate is required and must be positive for cross-currency transfers." }); + return; + } + finalToAccountAmount = values.amount * values.exchangeRate; } + await onTransferAdded({ fromAccountId: values.fromAccountId, toAccountId: values.toAccountId, - amount: values.amount, - transactionCurrency: values.transactionCurrency, - toAccountAmount: toAccountAmount, - toAccountCurrency: values.toAccountCurrency || values.transactionCurrency, + amount: values.amount, // Amount in fromAccount's currency + transactionCurrency: finalTransactionCurrency, // Currency of fromAccount + toAccountAmount: finalToAccountAmount, // Amount in toAccount's currency + toAccountCurrency: finalToAccountCurrency, // Currency of toAccount date: values.date, - description: values.description || `Transfer to ${accounts.find(a=>a.id === values.toAccountId)?.name || 'account'}`, + description: values.description || `Transfer to ${toAccount.name}`, tags: finalTags, }); } else { @@ -220,21 +237,33 @@ const AddTransactionForm: FC = ({ const toAccount = accounts.find(acc => acc.id === selectedToAccountId); if (toAccount && form.getValues('toAccountCurrency') !== toAccount.currency) { form.setValue('toAccountCurrency', toAccount.currency); - if (fromAccount?.currency === toAccount.currency) { - form.setValue('exchangeRate', 1); + } + // This logic should be done when both currencies are known + const currentFromCurrency = fromAccount?.currency || form.getValues('transactionCurrency'); + const currentToCurrency = toAccount?.currency || form.getValues('toAccountCurrency'); + + if (currentFromCurrency && currentToCurrency) { + if (currentFromCurrency === currentToCurrency) { + if (form.getValues('exchangeRate') !== 1) { + form.setValue('exchangeRate', 1); + } } else { - form.setValue('exchangeRate', undefined); + // Only clear exchangeRate if it was previously 1 (for same currencies) + // or if one of the currencies changed making the old rate invalid. + // User might have already input a valid rate. + // This part might need more nuanced handling if we want to pre-fill rates from an API. + // For now, if currencies differ, we expect user input or it remains undefined. + // If it was 1 (from same currencies), it MUST be cleared. + if (form.getValues('exchangeRate') === 1) { + form.setValue('exchangeRate', undefined); + } } } - // If currencies became same after account change, ensure rate is 1 - if (form.getValues('transactionCurrency') === form.getValues('toAccountCurrency') && form.getValues('exchangeRate') !== 1) { - form.setValue('exchangeRate', 1); - } } }, [selectedAccountId, selectedFromAccountId, selectedToAccountId, transactionType, accounts, form]); const getButtonText = () => { - const isEditing = !!(initialData && initialData.id); // Check if initialData AND initialData.id exist + const isEditing = !!(initialData && initialData.id); if (isLoading) { return isEditing ? "Saving..." : "Adding..."; @@ -243,13 +272,8 @@ const AddTransactionForm: FC = ({ if (isEditing) { return "Save Changes"; } - - // For new transactions - let typeLabel = 'Transaction'; // Default fallback - const currentTransactionType = form.getValues('type'); // Get current type from form state - if (currentTransactionType && typeof currentTransactionType === 'string' && currentTransactionType.length > 0) { - typeLabel = currentTransactionType.charAt(0).toUpperCase() + currentTransactionType.slice(1); - } + + let typeLabel = transactionType ? transactionType.charAt(0).toUpperCase() + transactionType.slice(1) : 'Transaction'; return `Add ${typeLabel}`; }; @@ -338,7 +362,7 @@ const AddTransactionForm: FC = ({ form.setValue('transactionCurrency', acc.currency); if (acc.currency === form.getValues('toAccountCurrency')) { form.setValue('exchangeRate', 1); - } else if (form.getValues('toAccountCurrency')) { + } else if (form.getValues('toAccountCurrency')) { form.setValue('exchangeRate', undefined); } } From 147cc3ba67a2c46b695e2c9cdd4e78fe6960cdf0 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Fri, 16 May 2025 12:25:36 +0000 Subject: [PATCH 002/156] Copie o formato da transacao debaixo para a visualiaao das contas. Primeiro em BRL e em baixo EUR --- src/app/accounts/[accountId]/page.tsx | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/app/accounts/[accountId]/page.tsx b/src/app/accounts/[accountId]/page.tsx index bf4a298..d8531bc 100644 --- a/src/app/accounts/[accountId]/page.tsx +++ b/src/app/accounts/[accountId]/page.tsx @@ -113,7 +113,7 @@ export default function AccountDetailPage() { if (typeof window !== 'undefined' && event.type === 'storage') { const isLikelyOurCustomEvent = event.key === null; const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'userCategories', 'userTags', `transactions-${accountId}`]; - const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key.includes(k)); + const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key!.includes(k)); if (isLikelyOurCustomEvent || isRelevantExternalChange) { @@ -231,10 +231,12 @@ export default function AccountDetailPage() { } }; - const handleTransferAdded = async (data: { fromAccountId: string; toAccountId: string; amount: number; date: Date; description?: string; tags?: string[]; transactionCurrency: string; }) => { + const handleTransferAdded = async (data: { fromAccountId: string; toAccountId: string; amount: number; date: Date; description?: string; tags?: string[]; transactionCurrency: string; toAccountAmount: number; toAccountCurrency: string;}) => { try { - const transferAmount = Math.abs(data.amount); + const fromAmount = -Math.abs(data.amount); + const toAmount = Math.abs(data.toAccountAmount); const formattedDate = formatDateFns(data.date, 'yyyy-MM-dd'); + const currentAccounts = await getAccounts(); const fromAccountName = currentAccounts.find(a=>a.id === data.fromAccountId)?.name || 'Unknown'; const toAccountName = currentAccounts.find(a=>a.id === data.toAccountId)?.name || 'Unknown'; @@ -242,7 +244,7 @@ export default function AccountDetailPage() { await addTransaction({ accountId: data.fromAccountId, - amount: -transferAmount, + amount: fromAmount, transactionCurrency: data.transactionCurrency, date: formattedDate, description: desc, @@ -252,8 +254,8 @@ export default function AccountDetailPage() { await addTransaction({ accountId: data.toAccountId, - amount: transferAmount, - transactionCurrency: data.transactionCurrency, + amount: toAmount, + transactionCurrency: data.toAccountCurrency, date: formattedDate, description: desc, category: 'Transfer', @@ -573,6 +575,7 @@ export default function AccountDetailPage() { categories={allCategories} tags={allTags} onTransactionAdded={handleUpdateTransaction} + onTransferAdded={handleTransferAdded} isLoading={isLoading} initialData={{ ...selectedTransaction, @@ -614,7 +617,7 @@ export default function AccountDetailPage() { (transactionTypeToAdd !== 'transfer' && account ? { accountId: account.id, transactionCurrency: account.currency, date: new Date() } : (transactionTypeToAdd === 'transfer' && account - ? { fromAccountId: account.id, transactionCurrency: account.currency, date: new Date() } + ? { fromAccountId: account.id, transactionCurrency: account.currency, date: new Date(), toAccountCurrency: account.currency, exchangeRate: 1 } : {date: new Date()}) ) } From 70d859a44ce93a6057b1efeeb86c24c25fee6d28 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Fri, 16 May 2025 12:49:40 +0000 Subject: [PATCH 003/156] For Point 2 (Recording Internal Currency Conversions within Wise as a "Transfer"): This is a bit trickier with our current model where each account in our app has only one primary currency and balance. An internal conversion within your real-world Wise account (e.g., EUR to BRL) doesn't involve moving money between two different accounts that our app knows about as separate entities. To model this as a "transfer" within our app, you would essentially need to represent the different currency pockets of your Wise account as separate accounts in our app. For example: Create an account in our app called "Wise EUR" with currency EUR. Create another account in our app called "Wise BRL" with currency BRL. Then, when you convert 100 EUR to, say, 525 BRL within your actual Wise account, you would record it in our app as: A transfer of -100 EUR from your "Wise EUR" (app account). A corresponding transfer of +525 BRL to your "Wise BRL" (app account). You'd need to input the exchange rate used for this conversion in the transfer form. This approach uses the existing transfer mechanism to log the internal conversion. The "tipo de conta com moedas" (account type with multiple currencies/balances) you mentioned is a more advanced feature that would require changing our Account model to hold multiple balances (e.g., [{currency: 'EUR', balance: X}, {currency: 'BRL', balance: Y}] for a single Wise account). We can definitely consider this for a future enhancement! Tudo bem, entao ao inves de fazer isso, vamos deixar como esta, em duas transacoes mesmo, agora acho que a transacao entrou corretamente, porem o valor que entrou em real na conta da Nubank foi o valor em real calculado pelo app, nao pelo rate que eu defini na transacao, altere apenas isso, o valor que entrar na outra conta com uma moeda diferente deve ser calculado pelo rate definido --- .../transactions/add-transaction-form.tsx | 77 +++++++++++-------- 1 file changed, 46 insertions(+), 31 deletions(-) diff --git a/src/components/transactions/add-transaction-form.tsx b/src/components/transactions/add-transaction-form.tsx index 4466e4d..273ac23 100644 --- a/src/components/transactions/add-transaction-form.tsx +++ b/src/components/transactions/add-transaction-form.tsx @@ -66,12 +66,12 @@ const formSchema = z.discriminatedUnion('type', [ message: "Source and destination accounts must be different for transfers.", path: ['toAccountId'], }).refine(data => { - if (data.type === 'transfer' && data.transactionCurrency !== data.toAccountCurrency) { + if (data.type === 'transfer' && data.transactionCurrency && data.toAccountCurrency && data.transactionCurrency !== data.toAccountCurrency) { return data.exchangeRate !== undefined && data.exchangeRate > 0; } return true; }, { - message: "Exchange rate is required for cross-currency transfers.", + message: "Exchange rate is required and must be positive for cross-currency transfers.", path: ['exchangeRate'], }); @@ -114,7 +114,7 @@ const AddTransactionForm: FC = ({ initialType: initialTypeFromParent, initialData }) => { - const resolvedInitialType = initialTypeFromParent ?? 'expense'; + const resolvedInitialType = initialTypeFromParent ?? (initialData?.type || 'expense'); const form = useForm({ resolver: zodResolver(formSchema), @@ -159,7 +159,8 @@ const AddTransactionForm: FC = ({ } else if (transactionType === 'transfer') { accIdToUse = selectedFromAccountId; } - return accounts.find(acc => acc.id === accIdToUse)?.currency || formTransactionCurrency || 'BRL'; + const account = accounts.find(acc => acc.id === accIdToUse); + return account?.currency || formTransactionCurrency || 'BRL'; }, [selectedAccountId, selectedFromAccountId, accounts, transactionType, formTransactionCurrency]); async function onSubmit(values: AddTransactionFormData) { @@ -175,13 +176,14 @@ const AddTransactionForm: FC = ({ return; } - const finalTransactionCurrency = fromAccount.currency; - const finalToAccountCurrency = toAccount.currency; + const actualSourceCurrency = fromAccount.currency; + const actualDestinationCurrency = toAccount.currency; let finalToAccountAmount = values.amount; - if (finalTransactionCurrency !== finalToAccountCurrency) { + if (actualSourceCurrency !== actualDestinationCurrency) { if (!values.exchangeRate || values.exchangeRate <= 0) { form.setError("exchangeRate", { type: "manual", message: "Exchange rate is required and must be positive for cross-currency transfers." }); + toast({ title: "Error", description: "Exchange rate is required for cross-currency transfers.", variant: "destructive" }); return; } finalToAccountAmount = values.amount * values.exchangeRate; @@ -191,12 +193,12 @@ const AddTransactionForm: FC = ({ await onTransferAdded({ fromAccountId: values.fromAccountId, toAccountId: values.toAccountId, - amount: values.amount, // Amount in fromAccount's currency - transactionCurrency: finalTransactionCurrency, // Currency of fromAccount - toAccountAmount: finalToAccountAmount, // Amount in toAccount's currency - toAccountCurrency: finalToAccountCurrency, // Currency of toAccount + amount: values.amount, + transactionCurrency: actualSourceCurrency, + toAccountAmount: finalToAccountAmount, + toAccountCurrency: actualDestinationCurrency, date: values.date, - description: values.description || `Transfer to ${toAccount.name}`, + description: values.description || `Transfer from ${fromAccount.name} to ${toAccount.name}`, tags: finalTags, }); } else { @@ -226,6 +228,7 @@ const AddTransactionForm: FC = ({ useEffect(() => { if (transactionType === 'expense' || transactionType === 'income') { const account = accounts.find(acc => acc.id === selectedAccountId); + // Only update transactionCurrency if an account is selected and the currency differs if (account && form.getValues('transactionCurrency') !== account.currency) { form.setValue('transactionCurrency', account.currency); } @@ -238,7 +241,7 @@ const AddTransactionForm: FC = ({ if (toAccount && form.getValues('toAccountCurrency') !== toAccount.currency) { form.setValue('toAccountCurrency', toAccount.currency); } - // This logic should be done when both currencies are known + const currentFromCurrency = fromAccount?.currency || form.getValues('transactionCurrency'); const currentToCurrency = toAccount?.currency || form.getValues('toAccountCurrency'); @@ -248,13 +251,7 @@ const AddTransactionForm: FC = ({ form.setValue('exchangeRate', 1); } } else { - // Only clear exchangeRate if it was previously 1 (for same currencies) - // or if one of the currencies changed making the old rate invalid. - // User might have already input a valid rate. - // This part might need more nuanced handling if we want to pre-fill rates from an API. - // For now, if currencies differ, we expect user input or it remains undefined. - // If it was 1 (from same currencies), it MUST be cleared. - if (form.getValues('exchangeRate') === 1) { + if (form.getValues('exchangeRate') === 1 || form.getValues('exchangeRate') === undefined) { form.setValue('exchangeRate', undefined); } } @@ -273,7 +270,7 @@ const AddTransactionForm: FC = ({ return "Save Changes"; } - let typeLabel = transactionType ? transactionType.charAt(0).toUpperCase() + transactionType.slice(1) : 'Transaction'; + const typeLabel = transactionType ? transactionType.charAt(0).toUpperCase() + transactionType.slice(1) : 'Transaction'; return `Add ${typeLabel}`; }; @@ -312,7 +309,7 @@ const AddTransactionForm: FC = ({ } }} defaultValue={field.value} - disabled={isEditingExisting && initialData?.type === 'transfer'} + disabled={isEditingExisting && initialData?.type === 'transfer' && transactionType === 'transfer'} > @@ -368,7 +365,7 @@ const AddTransactionForm: FC = ({ } }} defaultValue={field.value} - disabled={isEditingExisting} + disabled={isEditingExisting && initialData?.type === 'transfer'} > @@ -407,7 +404,7 @@ const AddTransactionForm: FC = ({ } }} defaultValue={field.value} - disabled={isEditingExisting} + disabled={isEditingExisting && initialData?.type === 'transfer'} > @@ -509,7 +506,7 @@ const AddTransactionForm: FC = ({ Amount ({getCurrencySymbol(formTransactionCurrency || selectedAccountCurrency)}) - + @@ -524,7 +521,16 @@ const AddTransactionForm: FC = ({ {transactionType === 'transfer' ? "From Account Currency" : "Transaction Currency"} {transactionType === 'transfer' ? - "Currency of the 'From Account'." : - "Determined by selected Account." + "Usually determined by 'From Account'." : + "Usually determined by selected Account." } @@ -561,7 +567,16 @@ const AddTransactionForm: FC = ({ To Account Currency - Currency of the 'To Account'. + Usually determined by 'To Account'. )} @@ -591,7 +606,7 @@ const AddTransactionForm: FC = ({ Exchange Rate (1 {formTransactionCurrency} = ? {formToAccountCurrency}) - + Required for cross-currency transfer. From 00170890ef56cb19499cf84578b5e763238e6c29 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Fri, 16 May 2025 13:18:17 +0000 Subject: [PATCH 004/156] A quantidade em real que esta chegando na conta da nubank ainda nao esta refletindo ao rate, tem que ser exatamente o rate que foi adicionado pelo usuario --- .../transactions/add-transaction-form.tsx | 148 ++++++++++-------- 1 file changed, 87 insertions(+), 61 deletions(-) diff --git a/src/components/transactions/add-transaction-form.tsx b/src/components/transactions/add-transaction-form.tsx index 273ac23..dfb39a5 100644 --- a/src/components/transactions/add-transaction-form.tsx +++ b/src/components/transactions/add-transaction-form.tsx @@ -66,7 +66,7 @@ const formSchema = z.discriminatedUnion('type', [ message: "Source and destination accounts must be different for transfers.", path: ['toAccountId'], }).refine(data => { - if (data.type === 'transfer' && data.transactionCurrency && data.toAccountCurrency && data.transactionCurrency !== data.toAccountCurrency) { + if (data.type === 'transfer' && data.transactionCurrency && data.toAccountCurrency && data.transactionCurrency.toUpperCase() !== data.toAccountCurrency.toUpperCase()) { return data.exchangeRate !== undefined && data.exchangeRate > 0; } return true; @@ -147,21 +147,22 @@ const AddTransactionForm: FC = ({ const selectedAccountId = form.watch('accountId'); const selectedFromAccountId = form.watch('fromAccountId'); const selectedToAccountId = form.watch('toAccountId'); - const formTransactionCurrency = form.watch('transactionCurrency'); - const formToAccountCurrency = form.watch('toAccountCurrency'); + // const formTransactionCurrency = form.watch('transactionCurrency'); // We'll derive this more directly for display + // const formToAccountCurrency = form.watch('toAccountCurrency'); // We'll derive this more directly for display const isEditingExisting = !!(initialData && 'id' in initialData && initialData.id); - const selectedAccountCurrency = useMemo(() => { - let accIdToUse: string | undefined; - if (transactionType === 'expense' || transactionType === 'income') { - accIdToUse = selectedAccountId; - } else if (transactionType === 'transfer') { - accIdToUse = selectedFromAccountId; + const fromAccountForDisplay = useMemo(() => accounts.find(acc => acc.id === selectedFromAccountId), [accounts, selectedFromAccountId]); + const toAccountForDisplay = useMemo(() => accounts.find(acc => acc.id === selectedToAccountId), [accounts, selectedToAccountId]); + const singleAccountForDisplay = useMemo(() => accounts.find(acc => acc.id === selectedAccountId), [accounts, selectedAccountId]); + + const currentTransactionCurrencyForDisplay = useMemo(() => { + if (transactionType === 'transfer') { + return fromAccountForDisplay?.currency || form.getValues('transactionCurrency') || 'BRL'; } - const account = accounts.find(acc => acc.id === accIdToUse); - return account?.currency || formTransactionCurrency || 'BRL'; - }, [selectedAccountId, selectedFromAccountId, accounts, transactionType, formTransactionCurrency]); + return singleAccountForDisplay?.currency || form.getValues('transactionCurrency') || 'BRL'; + }, [transactionType, fromAccountForDisplay, singleAccountForDisplay, form]); + async function onSubmit(values: AddTransactionFormData) { const finalTags = values.tags || []; @@ -180,7 +181,7 @@ const AddTransactionForm: FC = ({ const actualDestinationCurrency = toAccount.currency; let finalToAccountAmount = values.amount; - if (actualSourceCurrency !== actualDestinationCurrency) { + if (actualSourceCurrency.toUpperCase() !== actualDestinationCurrency.toUpperCase()) { if (!values.exchangeRate || values.exchangeRate <= 0) { form.setError("exchangeRate", { type: "manual", message: "Exchange rate is required and must be positive for cross-currency transfers." }); toast({ title: "Error", description: "Exchange rate is required for cross-currency transfers.", variant: "destructive" }); @@ -193,10 +194,10 @@ const AddTransactionForm: FC = ({ await onTransferAdded({ fromAccountId: values.fromAccountId, toAccountId: values.toAccountId, - amount: values.amount, - transactionCurrency: actualSourceCurrency, - toAccountAmount: finalToAccountAmount, - toAccountCurrency: actualDestinationCurrency, + amount: values.amount, + transactionCurrency: actualSourceCurrency, + toAccountAmount: finalToAccountAmount, + toAccountCurrency: actualDestinationCurrency, date: values.date, description: values.description || `Transfer from ${fromAccount.name} to ${toAccount.name}`, tags: finalTags, @@ -213,12 +214,12 @@ const AddTransactionForm: FC = ({ const transactionAmount = values.type === 'expense' ? -Math.abs(values.amount) : Math.abs(values.amount); const transactionData: Omit | Transaction = { ...(initialData && (initialData as Transaction).id && { id: (initialData as Transaction).id }), - accountId: values.accountId, + accountId: values.accountId!, // Assert accountId is present for expense/income amount: transactionAmount, transactionCurrency: values.transactionCurrency, date: formatDateFns(values.date, 'yyyy-MM-dd'), description: values.description || values.category || 'Transaction', - category: values.category!, + category: values.category!, // Assert category is present for expense/income tags: finalTags, }; await onTransactionAdded(transactionData); @@ -228,39 +229,50 @@ const AddTransactionForm: FC = ({ useEffect(() => { if (transactionType === 'expense' || transactionType === 'income') { const account = accounts.find(acc => acc.id === selectedAccountId); - // Only update transactionCurrency if an account is selected and the currency differs if (account && form.getValues('transactionCurrency') !== account.currency) { form.setValue('transactionCurrency', account.currency); } } else if (transactionType === 'transfer') { const fromAccount = accounts.find(acc => acc.id === selectedFromAccountId); - if (fromAccount && form.getValues('transactionCurrency') !== fromAccount.currency) { - form.setValue('transactionCurrency', fromAccount.currency); - } const toAccount = accounts.find(acc => acc.id === selectedToAccountId); - if (toAccount && form.getValues('toAccountCurrency') !== toAccount.currency) { - form.setValue('toAccountCurrency', toAccount.currency); + + if (fromAccount) { + if (form.getValues('transactionCurrency') !== fromAccount.currency) { + form.setValue('transactionCurrency', fromAccount.currency); + } + } else { + form.setValue('transactionCurrency', undefined); // Clear if no fromAccount } - - const currentFromCurrency = fromAccount?.currency || form.getValues('transactionCurrency'); - const currentToCurrency = toAccount?.currency || form.getValues('toAccountCurrency'); - if (currentFromCurrency && currentToCurrency) { - if (currentFromCurrency === currentToCurrency) { + if (toAccount) { + if (form.getValues('toAccountCurrency') !== toAccount.currency) { + form.setValue('toAccountCurrency', toAccount.currency); + } + } else { + form.setValue('toAccountCurrency', undefined); // Clear if no toAccount + } + + if (fromAccount && toAccount) { + if (fromAccount.currency.toUpperCase() === toAccount.currency.toUpperCase()) { if (form.getValues('exchangeRate') !== 1) { - form.setValue('exchangeRate', 1); + form.setValue('exchangeRate', 1, { shouldValidate: true }); } } else { - if (form.getValues('exchangeRate') === 1 || form.getValues('exchangeRate') === undefined) { - form.setValue('exchangeRate', undefined); + // If currencies are different and rate is 1 (default for same currency) or undefined, clear it to prompt user or indicate it's needed + const currentRate = form.getValues('exchangeRate'); + if (currentRate === 1 || currentRate === undefined) { + form.setValue('exchangeRate', undefined, { shouldValidate: true }); } } + } else { // If one or both accounts are not selected, clear exchange rate + form.setValue('exchangeRate', undefined); } } }, [selectedAccountId, selectedFromAccountId, selectedToAccountId, transactionType, accounts, form]); + const getButtonText = () => { - const isEditing = !!(initialData && initialData.id); + const isEditing = !!(initialData && initialData.id); if (isLoading) { return isEditing ? "Saving..." : "Adding..."; @@ -287,19 +299,20 @@ const AddTransactionForm: FC = ({ @@ -524,15 +542,20 @@ const AddTransactionForm: FC = ({ onValueChange={(value) => { field.onChange(value); if (transactionType === 'transfer') { - if (value === form.getValues('toAccountCurrency')) { + const toAccCurrency = form.getValues('toAccountCurrency'); + if (toAccCurrency && value.toUpperCase() === toAccCurrency.toUpperCase()) { form.setValue('exchangeRate', 1); - } else if (form.getValues('toAccountCurrency')) { + } else if (toAccCurrency) { form.setValue('exchangeRate', undefined); } } }} - value={field.value} - disabled={ (transactionType !== 'transfer' && !!selectedAccountId) || (transactionType === 'transfer' && !!selectedFromAccountId) } + value={field.value || ''} // Ensure value is not undefined for Select + // Disable if currency is determined by a selected account + disabled={ + (transactionType !== 'transfer' && !!selectedAccountId && !!accounts.find(a=>a.id === selectedAccountId)) || + (transactionType === 'transfer' && !!selectedFromAccountId && !!accounts.find(a=>a.id === selectedFromAccountId)) + } > @@ -569,16 +592,17 @@ const AddTransactionForm: FC = ({ @@ -632,7 +657,7 @@ const AddTransactionForm: FC = ({ {categories - .filter(cat => cat.name.toLowerCase() !== 'transfer') + .filter(cat => cat.name.toLowerCase() !== 'transfer') // Exclude 'Transfer' category for non-transfer types .sort((a, b) => a.name.localeCompare(b.name)) .map((cat) => ( @@ -651,7 +676,7 @@ const AddTransactionForm: FC = ({ name="tags" render={({ field: controllerField }) => ( - Tags + Tags (Optional) = ({ }; export default AddTransactionForm; + From 852af8a16e9cf78fb84e5ea649c6bd417c54484e Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Fri, 16 May 2025 14:20:08 +0000 Subject: [PATCH 005/156] Melhore o nosso app para o mobile, o menu nao esta aparecendo no celular --- src/components/layout/GlobalHeader.tsx | 35 +++++++++++----------- src/components/layout/auth-wrapper.tsx | 40 ++++++-------------------- 2 files changed, 28 insertions(+), 47 deletions(-) diff --git a/src/components/layout/GlobalHeader.tsx b/src/components/layout/GlobalHeader.tsx index 9b2cbc5..9bf1ced 100644 --- a/src/components/layout/GlobalHeader.tsx +++ b/src/components/layout/GlobalHeader.tsx @@ -1,3 +1,4 @@ + 'use client'; import type { FC } from 'react'; @@ -5,7 +6,7 @@ import DateRangePicker from '@/components/dashboard/date-range-picker'; import { useDateRange } from '@/contexts/DateRangeContext'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { PlusCircle, ArrowDownCircle, ArrowUpCircle, ArrowLeftRight as TransferIcon, ChevronDown } from 'lucide-react'; +import { PlusCircle, ArrowDownCircle, ArrowUpCircle, ArrowLeftRight as TransferIcon, ChevronDown, PanelLeft } from 'lucide-react'; // Added PanelLeft for consistency if needed, but SidebarTrigger has its own import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import AddTransactionForm from '@/components/transactions/add-transaction-form'; import type { Transaction } from '@/services/transactions'; @@ -14,9 +15,10 @@ import { getCategories, type Category } from '@/services/categories'; import { getTags, type Tag } from '@/services/tags'; import { useToast } from "@/hooks/use-toast"; import { useState, useEffect } from 'react'; -import { format as formatDateFns } from 'date-fns'; // Use aliased import +import { format as formatDateFns } from 'date-fns'; import { addTransaction } from '@/services/transactions'; import { Skeleton } from '@/components/ui/skeleton'; +import { SidebarTrigger } from '@/components/ui/sidebar'; // Import SidebarTrigger const GlobalHeader: FC = () => { @@ -36,14 +38,13 @@ const GlobalHeader: FC = () => { try { const fetchedAccounts = await getAccounts(); - // Perform checks based on freshly fetched accounts if (transactionTypeToAdd === 'transfer' && fetchedAccounts.length < 2) { toast({ title: "Not Enough Accounts", description: "You need at least two accounts to make a transfer.", variant: "destructive", }); - setIsAddTransactionDialogOpen(false); // Abort opening + setIsAddTransactionDialogOpen(false); setIsLoadingDataForForm(false); return; } @@ -53,14 +54,13 @@ const GlobalHeader: FC = () => { description: "Please add an account first before adding transactions.", variant: "destructive", }); - setIsAddTransactionDialogOpen(false); // Abort opening + setIsAddTransactionDialogOpen(false); setIsLoadingDataForForm(false); return; } - setAccounts(fetchedAccounts); // Set accounts state for the form + setAccounts(fetchedAccounts); - // If account checks pass, fetch categories and tags const [fetchedCategories, fetchedTagsList] = await Promise.all([ getCategories(), getTags() @@ -71,7 +71,7 @@ const GlobalHeader: FC = () => { } catch (error) { console.error("Failed to fetch data for transaction form:", error); toast({ title: "Error", description: "Could not load data for transaction form.", variant: "destructive" }); - setIsAddTransactionDialogOpen(false); // Close dialog on error + setIsAddTransactionDialogOpen(false); } finally { setIsLoadingDataForForm(false); } @@ -83,7 +83,7 @@ const GlobalHeader: FC = () => { const openAddTransactionDialog = (type: 'expense' | 'income' | 'transfer') => { - setTransactionTypeToAdd(type); // Set the type, useEffect will handle fetching and checks + setTransactionTypeToAdd(type); setIsAddTransactionDialogOpen(true); }; @@ -99,12 +99,10 @@ const GlobalHeader: FC = () => { } }; - const handleTransferAdded = async (data: { fromAccountId: string; toAccountId: string; amount: number; date: Date; description?: string; tags?: string[]; transactionCurrency: string }) => { + const handleTransferAdded = async (data: { fromAccountId: string; toAccountId: string; amount: number; transactionCurrency: string; toAccountAmount: number; toAccountCurrency: string; date: Date; description?: string; tags?: string[];}) => { try { - const transferAmount = Math.abs(data.amount); const formattedDate = formatDateFns(data.date, 'yyyy-MM-dd'); - // Fetch fresh account names for description, in case local state 'accounts' is not fully up-to-date here const currentAccounts = await getAccounts(); const fromAccountName = currentAccounts.find(a=>a.id === data.fromAccountId)?.name || 'Unknown Account'; const toAccountName = currentAccounts.find(a=>a.id === data.toAccountId)?.name || 'Unknown Account'; @@ -112,7 +110,7 @@ const GlobalHeader: FC = () => { await addTransaction({ accountId: data.fromAccountId, - amount: -transferAmount, + amount: -Math.abs(data.amount), // Amount from source account's perspective transactionCurrency: data.transactionCurrency, date: formattedDate, description: desc, @@ -122,8 +120,8 @@ const GlobalHeader: FC = () => { await addTransaction({ accountId: data.toAccountId, - amount: transferAmount, - transactionCurrency: data.transactionCurrency, + amount: Math.abs(data.toAccountAmount), // Amount for destination account + transactionCurrency: data.toAccountCurrency, date: formattedDate, description: desc, category: 'Transfer', @@ -141,7 +139,11 @@ const GlobalHeader: FC = () => { return ( -
+
+ {/* Mobile Menu Trigger */} + + + {/* Existing content pushed to the right */}
{ onTransferAdded={handleTransferAdded} isLoading={false} initialType={transactionTypeToAdd} + initialData={{date: new Date()}} // Ensure initialData is always an object /> ) : (
diff --git a/src/components/layout/auth-wrapper.tsx b/src/components/layout/auth-wrapper.tsx index 97230ad..e6bf412 100644 --- a/src/components/layout/auth-wrapper.tsx +++ b/src/components/layout/auth-wrapper.tsx @@ -8,7 +8,7 @@ import { SidebarProvider, Sidebar, SidebarHeader, - SidebarTrigger, + // SidebarTrigger, // Removed from here SidebarContent, SidebarMenu, SidebarMenuItem, @@ -19,7 +19,7 @@ import { SidebarGroupLabel, } from '@/components/ui/sidebar'; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Landmark, Wallet, ArrowLeftRight, Settings, ChevronDown, TrendingUp, TrendingDown, LayoutList, Upload, Users, LogOut, Network, PieChart, CalendarClock, Archive as ArchiveIcon, SlidersHorizontal } from 'lucide-react'; +import { Landmark, Wallet, ArrowLeftRight, Settings, ChevronDown, TrendingUp, TrendingDown, LayoutList, Upload, Users, LogOut, Network, PieChart, CalendarClock, Archive as ArchiveIcon, SlidersHorizontal, FileText } from 'lucide-react'; import Link from 'next/link'; import { useRouter, usePathname } from 'next/navigation'; import { useState, useEffect } from 'react'; @@ -28,7 +28,6 @@ import { DateRangeProvider } from '@/contexts/DateRangeContext'; import GlobalHeader from './GlobalHeader'; import { Button } from '@/components/ui/button'; -// New GoldQuest Logo const LogoIcon = () => ( ( xmlns="http://www.w3.org/2000/svg" className="mr-2 text-primary" > - {/* Thin grid lines */} - - - - - - - - - {/* Thick "G" shape lines */} - - {/* Circles at vertices */} - {/* Added this circle */} + ); @@ -79,13 +66,12 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { useEffect(() => { setIsClient(true); - // Set class name after mount to ensure styles are applied correctly setLoadingDivClassName("flex items-center justify-center min-h-screen bg-background text-foreground"); }, []); useEffect(() => { const applyTheme = () => { - if (!isClient) return; // Only run on client + if (!isClient) return; const root = document.documentElement; let currentTheme = theme; @@ -96,12 +82,11 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { root.classList.remove('dark', 'light'); root.classList.add(currentTheme); - root.style.colorScheme = currentTheme; // Important for native elements + root.style.colorScheme = currentTheme; }; applyTheme(); - // Listen for system theme changes if 'system' is selected if (theme === 'system' && typeof window !== 'undefined') { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const handleChange = () => applyTheme(); @@ -128,8 +113,6 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { if(typeof window !== 'undefined') { const hasLoggedInBefore = localStorage.getItem(firstLoginFlagKey); - // Check if preferences are loaded and specifically if the theme is set. - // This implies preferences have been fetched at least once. const preferencesLoadedAndThemeSet = userPreferences && userPreferences.theme; if (!hasLoggedInBefore && !preferencesLoadedAndThemeSet && pathname !== '/preferences') { @@ -157,10 +140,9 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { if (firebaseError && !isFirebaseActive) { if (pathname !== '/login' && pathname !== '/signup') { - router.push('/login'); // Redirect to login if firebase is critically broken and not on auth pages + router.push('/login'); return
Firebase not available. Redirecting...
; } - // Allow login/signup pages to render with the firebase error message handled within them } @@ -177,7 +159,6 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { } if (isAuthenticated || (!isFirebaseActive && (pathname === '/login' || pathname === '/signup'))) { - // If firebase is inactive but we are on login/signup, allow those pages to render if (!isAuthenticated && (pathname !== '/login' && pathname !== '/signup')) { return ; } @@ -194,7 +175,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { GoldQuest
- + {/* SidebarTrigger was here, moved to GlobalHeader for mobile */} @@ -208,7 +189,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { - + @@ -234,7 +215,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { isActive={isAnyTransactionRouteActive} >
- + {/* Changed icon */} Transactions
Preparing application...
; } - - From ff57081e9bebdea4b0359a265e6f36928d541a8b Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Fri, 16 May 2025 14:37:20 +0000 Subject: [PATCH 006/156] Nas tranferencias ainda nao esta dando certo o rate, na verdade vamos criar a opcao de inserir o quanto da outra moeda vai chegar na outra conta e o nosso app calcula o rate somente para mostrar --- src/app/accounts/[accountId]/page.tsx | 20 +- src/app/expenses/page.tsx | 17 +- src/app/revenue/page.tsx | 17 +- src/app/transfers/page.tsx | 73 +++-- src/components/layout/GlobalHeader.tsx | 13 +- .../transactions/add-transaction-form.tsx | 266 +++++++----------- 6 files changed, 179 insertions(+), 227 deletions(-) diff --git a/src/app/accounts/[accountId]/page.tsx b/src/app/accounts/[accountId]/page.tsx index d8531bc..fb40e27 100644 --- a/src/app/accounts/[accountId]/page.tsx +++ b/src/app/accounts/[accountId]/page.tsx @@ -113,7 +113,7 @@ export default function AccountDetailPage() { if (typeof window !== 'undefined' && event.type === 'storage') { const isLikelyOurCustomEvent = event.key === null; const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'userCategories', 'userTags', `transactions-${accountId}`]; - const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key!.includes(k)); + const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key && event.key.includes(k)); if (isLikelyOurCustomEvent || isRelevantExternalChange) { @@ -187,7 +187,6 @@ export default function AccountDetailPage() { setSelectedTransaction(null); toast({ title: "Success", description: `Transaction "${transactionToUpdate.description}" updated.` }); window.dispatchEvent(new Event('storage')); - // fetchData(); // Let the storage event handle the refetch } catch (err: any) { console.error("Failed to update transaction:", err); toast({ title: "Error", description: err.message || "Could not update transaction.", variant: "destructive" }); @@ -207,7 +206,6 @@ export default function AccountDetailPage() { await deleteTransaction(selectedTransaction.id, selectedTransaction.accountId); toast({ title: "Transaction Deleted", description: `Transaction "${selectedTransaction.description}" removed.` }); window.dispatchEvent(new Event('storage')); - // fetchData(); // Let the storage event handle the refetch } catch (err: any) { console.error("Failed to delete transaction:", err); toast({ title: "Error", description: err.message || "Could not delete transaction.", variant: "destructive" }); @@ -224,7 +222,6 @@ export default function AccountDetailPage() { setIsAddTransactionDialogOpen(false); setClonedTransactionData(undefined); window.dispatchEvent(new Event('storage')); - // fetchData(); // Let the storage event handle the refetch } catch (error: any) { console.error("Failed to add transaction:", error); toast({ title: "Error", description: `Could not add transaction: ${error.message}`, variant: "destructive" }); @@ -233,8 +230,6 @@ export default function AccountDetailPage() { const handleTransferAdded = async (data: { fromAccountId: string; toAccountId: string; amount: number; date: Date; description?: string; tags?: string[]; transactionCurrency: string; toAccountAmount: number; toAccountCurrency: string;}) => { try { - const fromAmount = -Math.abs(data.amount); - const toAmount = Math.abs(data.toAccountAmount); const formattedDate = formatDateFns(data.date, 'yyyy-MM-dd'); const currentAccounts = await getAccounts(); @@ -244,7 +239,7 @@ export default function AccountDetailPage() { await addTransaction({ accountId: data.fromAccountId, - amount: fromAmount, + amount: -Math.abs(data.amount), transactionCurrency: data.transactionCurrency, date: formattedDate, description: desc, @@ -254,7 +249,7 @@ export default function AccountDetailPage() { await addTransaction({ accountId: data.toAccountId, - amount: toAmount, + amount: Math.abs(data.toAccountAmount), transactionCurrency: data.toAccountCurrency, date: formattedDate, description: desc, @@ -266,7 +261,6 @@ export default function AccountDetailPage() { setIsAddTransactionDialogOpen(false); setClonedTransactionData(undefined); window.dispatchEvent(new Event('storage')); - // fetchData(); // Let the storage event handle the refetch } catch (error: any) { console.error("Failed to add transfer:", error); toast({ title: "Error", description: `Could not record transfer: ${error.message}`, variant: "destructive" }); @@ -575,7 +569,7 @@ export default function AccountDetailPage() { categories={allCategories} tags={allTags} onTransactionAdded={handleUpdateTransaction} - onTransferAdded={handleTransferAdded} + onTransferAdded={handleTransferAdded} // Ensure this is passed for consistency, though less likely used in single-account edit isLoading={isLoading} initialData={{ ...selectedTransaction, @@ -616,9 +610,9 @@ export default function AccountDetailPage() { clonedTransactionData || (transactionTypeToAdd !== 'transfer' && account ? { accountId: account.id, transactionCurrency: account.currency, date: new Date() } - : (transactionTypeToAdd === 'transfer' && account - ? { fromAccountId: account.id, transactionCurrency: account.currency, date: new Date(), toAccountCurrency: account.currency, exchangeRate: 1 } - : {date: new Date()}) + : (transactionTypeToAdd === 'transfer' && account // If adding a transfer and current account exists + ? { fromAccountId: account.id, transactionCurrency: account.currency, date: new Date(), toAccountCurrency: accounts.find(a => a.id !== account.id)?.currency || account.currency } + : {date: new Date()}) // Fallback for other cases or if account not found ) } /> diff --git a/src/app/expenses/page.tsx b/src/app/expenses/page.tsx index 2c056af..6141d10 100644 --- a/src/app/expenses/page.tsx +++ b/src/app/expenses/page.tsx @@ -110,7 +110,7 @@ export default function ExpensesPage() { if (typeof window !== 'undefined' && event.type === 'storage') { const isLikelyOurCustomEvent = event.key === null; const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'userCategories', 'userTags', 'transactions-']; - const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key.includes(k)); + const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key && event.key.includes(k)); if (isLikelyOurCustomEvent || isRelevantExternalChange) { @@ -177,7 +177,6 @@ export default function ExpensesPage() { description: `Transaction "${transactionToUpdate.description}" updated.`, }); window.dispatchEvent(new Event('storage')); - // fetchData(); // Let storage event handle refetching } catch (err: any) { console.error("Failed to update transaction:", err); toast({ @@ -205,7 +204,6 @@ export default function ExpensesPage() { description: `Transaction "${selectedTransaction.description}" removed.`, }); window.dispatchEvent(new Event('storage')); - // fetchData(); // Let storage event handle refetching } catch (err: any) { console.error("Failed to delete transaction:", err); toast({ @@ -226,16 +224,14 @@ export default function ExpensesPage() { setIsAddTransactionDialogOpen(false); setClonedTransactionData(undefined); window.dispatchEvent(new Event('storage')); - // fetchData(); // Let storage event handle refetching } catch (error: any) { console.error("Failed to add transaction:", error); toast({ title: "Error", description: `Could not add transaction: ${error.message}`, variant: "destructive" }); } }; - const handleTransferAdded = async (data: { fromAccountId: string; toAccountId: string; amount: number; date: Date; description?: string; tags?: string[], transactionCurrency: string }) => { + const handleTransferAdded = async (data: { fromAccountId: string; toAccountId: string; amount: number; transactionCurrency: string; toAccountAmount: number; toAccountCurrency: string; date: Date; description?: string; tags?: string[];}) => { try { - const transferAmount = Math.abs(data.amount); const formattedDate = formatDateFns(data.date, 'yyyy-MM-dd'); const fromAccountName = accounts.find(a=>a.id === data.fromAccountId)?.name || 'Unknown Account'; const toAccountName = accounts.find(a=>a.id === data.toAccountId)?.name || 'Unknown Account'; @@ -243,7 +239,7 @@ export default function ExpensesPage() { await addTransaction({ accountId: data.fromAccountId, - amount: -transferAmount, + amount: -Math.abs(data.amount), transactionCurrency: data.transactionCurrency, date: formattedDate, description: desc, @@ -253,8 +249,8 @@ export default function ExpensesPage() { await addTransaction({ accountId: data.toAccountId, - amount: transferAmount, - transactionCurrency: data.transactionCurrency, + amount: Math.abs(data.toAccountAmount), + transactionCurrency: data.toAccountCurrency, date: formattedDate, description: desc, category: 'Transfer', @@ -265,7 +261,6 @@ export default function ExpensesPage() { setIsAddTransactionDialogOpen(false); setClonedTransactionData(undefined); window.dispatchEvent(new Event('storage')); - // fetchData(); // Let storage event handle refetching } catch (error: any) { console.error("Failed to add transfer:", error); toast({ title: "Error", description: `Could not record transfer: ${error.message}`, variant: "destructive" }); @@ -552,6 +547,7 @@ export default function ExpensesPage() { categories={allCategories} tags={allTags} onTransactionAdded={handleUpdateTransaction} + onTransferAdded={handleTransferAdded} isLoading={isLoading} initialData={{ ...selectedTransaction, @@ -613,3 +609,4 @@ export default function ExpensesPage() { } + diff --git a/src/app/revenue/page.tsx b/src/app/revenue/page.tsx index 9b6028d..a5c5a85 100644 --- a/src/app/revenue/page.tsx +++ b/src/app/revenue/page.tsx @@ -110,7 +110,7 @@ export default function RevenuePage() { if (typeof window !== 'undefined' && event.type === 'storage') { const isLikelyOurCustomEvent = event.key === null; const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'userCategories', 'userTags', 'transactions-']; - const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key.includes(k)); + const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key && event.key.includes(k)); if (isLikelyOurCustomEvent || isRelevantExternalChange) { @@ -176,7 +176,6 @@ export default function RevenuePage() { description: `Transaction "${transactionToUpdate.description}" updated.`, }); window.dispatchEvent(new Event('storage')); - // fetchData(); // Let storage event handle refetching } catch (err: any) { console.error("Failed to update transaction:", err); toast({ @@ -204,7 +203,6 @@ export default function RevenuePage() { description: `Transaction "${selectedTransaction.description}" removed.`, }); window.dispatchEvent(new Event('storage')); - // fetchData(); // Let storage event handle refetching } catch (err: any) { console.error("Failed to delete transaction:", err); toast({ @@ -225,16 +223,14 @@ export default function RevenuePage() { setIsAddTransactionDialogOpen(false); setClonedTransactionData(undefined); window.dispatchEvent(new Event('storage')); - // fetchData(); // Let storage event handle refetching } catch (error: any) { console.error("Failed to add transaction:", error); toast({ title: "Error", description: `Could not add transaction: ${error.message}`, variant: "destructive" }); } }; - const handleTransferAdded = async (data: { fromAccountId: string; toAccountId: string; amount: number; date: Date; description?: string; tags?: string[]; transactionCurrency: string; }) => { + const handleTransferAdded = async (data: { fromAccountId: string; toAccountId: string; amount: number; transactionCurrency: string; toAccountAmount: number; toAccountCurrency: string; date: Date; description?: string; tags?: string[];}) => { try { - const transferAmount = Math.abs(data.amount); const formattedDate = formatDateFns(data.date, 'yyyy-MM-dd'); const fromAccountName = accounts.find(a=>a.id === data.fromAccountId)?.name || 'Unknown Account'; const toAccountName = accounts.find(a=>a.id === data.toAccountId)?.name || 'Unknown Account'; @@ -242,7 +238,7 @@ export default function RevenuePage() { await addTransaction({ accountId: data.fromAccountId, - amount: -transferAmount, + amount: -Math.abs(data.amount), transactionCurrency: data.transactionCurrency, date: formattedDate, description: desc, @@ -252,8 +248,8 @@ export default function RevenuePage() { await addTransaction({ accountId: data.toAccountId, - amount: transferAmount, - transactionCurrency: data.transactionCurrency, + amount: Math.abs(data.toAccountAmount), + transactionCurrency: data.toAccountCurrency, date: formattedDate, description: desc, category: 'Transfer', @@ -264,7 +260,6 @@ export default function RevenuePage() { setIsAddTransactionDialogOpen(false); setClonedTransactionData(undefined); window.dispatchEvent(new Event('storage')); - // fetchData(); // Let storage event handle refetching } catch (error: any) { console.error("Failed to add transfer:", error); toast({ title: "Error", description: `Could not record transfer: ${error.message}`, variant: "destructive" }); @@ -550,6 +545,7 @@ export default function RevenuePage() { categories={allCategories} tags={allTags} onTransactionAdded={handleUpdateTransaction} + onTransferAdded={handleTransferAdded} isLoading={isLoading} initialData={{ ...selectedTransaction, @@ -611,3 +607,4 @@ export default function RevenuePage() { } + diff --git a/src/app/transfers/page.tsx b/src/app/transfers/page.tsx index f3e014b..21d79fa 100644 --- a/src/app/transfers/page.tsx +++ b/src/app/transfers/page.tsx @@ -106,7 +106,8 @@ export default function TransfersPage() { if (typeof window !== 'undefined' && event.type === 'storage') { const isLikelyOurCustomEvent = event.key === null; const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'userCategories', 'userTags', 'transactions-']; - const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key.includes(k)); + const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key && event.key.includes(k)); + if (isLikelyOurCustomEvent || isRelevantExternalChange) { console.log("Storage changed, refetching transfer data..."); @@ -141,22 +142,37 @@ export default function TransfersPage() { potentialTransfers.forEach(txOut => { if (txOut.amount < 0 && !processedIds.has(txOut.id)) { + // For cross-currency, amount might not be exact opposite. + // We rely more on description, date and involved accounts. + // For same-currency, amount should be exact opposite. const matchingIncoming = potentialTransfers.filter(txIn => - txIn.amount === -txOut.amount && txIn.accountId !== txOut.accountId && !processedIds.has(txIn.id) && txIn.date === txOut.date && (txIn.description === txOut.description || - (txIn.description?.startsWith("Transfer") && txOut.description?.startsWith("Transfer"))) && - txIn.transactionCurrency === txOut.transactionCurrency + (txIn.description?.startsWith("Transfer from") && txOut.description?.startsWith("Transfer from"))) && // Common pattern + ( (txIn.transactionCurrency === txOut.transactionCurrency && txIn.amount === -txOut.amount) || // Same currency check + (txIn.transactionCurrency !== txOut.transactionCurrency) ) // Allow different currencies for cross-currency ); if (matchingIncoming.length > 0) { - matchingIncoming.sort((a,b) => a.id.localeCompare(b.id)); - const txIn = matchingIncoming[0]; - transfers.push({ from: txOut, to: txIn }); - processedIds.add(txOut.id); - processedIds.add(txIn.id); + // Prioritize exact amount match if currencies are same, otherwise take first plausible match + let txIn; + const sameCurrencyExactMatch = matchingIncoming.find(match => match.transactionCurrency === txOut.transactionCurrency && match.amount === -txOut.amount); + if (sameCurrencyExactMatch) { + txIn = sameCurrencyExactMatch; + } else { + // If no exact match for same currency, or if currencies are different, take the first one. + // This might need more sophisticated matching for multi-leg CSV imports if descriptions aren't identical. + matchingIncoming.sort((a,b) => a.id.localeCompare(b.id)); // Consistent sort for tie-breaking + txIn = matchingIncoming[0]; + } + + if(txIn) { + transfers.push({ from: txOut, to: txIn }); + processedIds.add(txOut.id); + processedIds.add(txIn.id); + } } } }); @@ -192,7 +208,6 @@ export default function TransfersPage() { description: `Transfer record removed successfully.`, }); window.dispatchEvent(new Event('storage')); - // fetchData(); // Let storage event handle refetch } catch (err: any) { console.error("Failed to delete transfer:", err); toast({ @@ -219,7 +234,6 @@ export default function TransfersPage() { setIsAddTransactionDialogOpen(false); setEditingTransferPair(null); window.dispatchEvent(new Event('storage')); - // fetchData(); // Let storage event handle refetch } catch (error: any) { console.error("Failed to add/update transaction:", error); toast({ title: "Error", description: `Could not add/update transaction: ${error.message}`, variant: "destructive" }); @@ -228,7 +242,7 @@ export default function TransfersPage() { } }; - const handleTransferAdded = async (data: { fromAccountId: string; toAccountId: string; amount: number; date: Date; description?: string; tags?: string[]; transactionCurrency: string; }) => { + const handleTransferAdded = async (data: { fromAccountId: string; toAccountId: string; amount: number; transactionCurrency: string; toAccountAmount: number; toAccountCurrency: string; date: Date; description?: string; tags?: string[];}) => { setIsLoading(true); try { if (editingTransferPair) { @@ -238,15 +252,16 @@ export default function TransfersPage() { console.log("Old transfer pair deleted."); } - const transferAmount = Math.abs(data.amount); const formattedDate = formatDateFns(data.date, 'yyyy-MM-dd'); - const fromAccountName = accounts.find(a=>a.id === data.fromAccountId)?.name || 'Unknown'; - const toAccountName = accounts.find(a=>a.id === data.toAccountId)?.name || 'Unknown'; + + const currentAccounts = await getAccounts(); + const fromAccountName = currentAccounts.find(a=>a.id === data.fromAccountId)?.name || 'Unknown Account'; + const toAccountName = currentAccounts.find(a=>a.id === data.toAccountId)?.name || 'Unknown Account'; const desc = data.description || `Transfer from ${fromAccountName} to ${toAccountName}`; await addTransaction({ accountId: data.fromAccountId, - amount: -transferAmount, + amount: -Math.abs(data.amount), transactionCurrency: data.transactionCurrency, date: formattedDate, description: desc, @@ -256,8 +271,8 @@ export default function TransfersPage() { await addTransaction({ accountId: data.toAccountId, - amount: transferAmount, - transactionCurrency: data.transactionCurrency, + amount: Math.abs(data.toAccountAmount), + transactionCurrency: data.toAccountCurrency, date: formattedDate, description: desc, category: 'Transfer', @@ -268,7 +283,6 @@ export default function TransfersPage() { setIsAddTransactionDialogOpen(false); setEditingTransferPair(null); window.dispatchEvent(new Event('storage')); - // fetchData(); // Let storage event handle refetch } catch (error: any) { console.error("Failed to add/update transfer:", error); toast({ title: "Error", description: `Could not record transfer: ${error.message}`, variant: "destructive" }); @@ -301,19 +315,34 @@ export default function TransfersPage() { const initialFormDataForEdit = useMemo(() => { if (editingTransferPair && transactionTypeToAdd === 'transfer') { + const fromAcc = accounts.find(a => a.id === editingTransferPair.from.accountId); + const toAcc = accounts.find(a => a.id === editingTransferPair.to.accountId); + + let toAmt = Math.abs(editingTransferPair.to.amount); + if (fromAcc && toAcc && fromAcc.currency !== toAcc.currency) { + // If currencies are different, toAccountAmount should be what was in the `to` leg. + toAmt = Math.abs(editingTransferPair.to.amount); + } else if (fromAcc && toAcc && fromAcc.currency === toAcc.currency) { + // If currencies are same, toAccountAmount should be same as fromAccountAmount (absolute) + toAmt = Math.abs(editingTransferPair.from.amount); + } + + return { type: 'transfer' as 'transfer', fromAccountId: editingTransferPair.from.accountId, toAccountId: editingTransferPair.to.accountId, - amount: Math.abs(editingTransferPair.from.amount), - transactionCurrency: editingTransferPair.from.transactionCurrency, + amount: Math.abs(editingTransferPair.from.amount), // Amount from source + transactionCurrency: editingTransferPair.from.transactionCurrency, // Currency of source + toAccountAmount: toAmt, // Amount for destination, pre-filled + toAccountCurrency: editingTransferPair.to.transactionCurrency, // Currency of destination date: parseISO(editingTransferPair.from.date.includes('T') ? editingTransferPair.from.date : editingTransferPair.from.date + 'T00:00:00Z'), description: editingTransferPair.from.description, tags: editingTransferPair.from.tags || [], }; } return {date: new Date()}; - }, [editingTransferPair, transactionTypeToAdd]); + }, [editingTransferPair, transactionTypeToAdd, accounts]); const dateRangeLabel = useMemo(() => { if (selectedDateRange.from && selectedDateRange.to) { diff --git a/src/components/layout/GlobalHeader.tsx b/src/components/layout/GlobalHeader.tsx index 9bf1ced..ef4ceda 100644 --- a/src/components/layout/GlobalHeader.tsx +++ b/src/components/layout/GlobalHeader.tsx @@ -6,7 +6,7 @@ import DateRangePicker from '@/components/dashboard/date-range-picker'; import { useDateRange } from '@/contexts/DateRangeContext'; import { Button } from '@/components/ui/button'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; -import { PlusCircle, ArrowDownCircle, ArrowUpCircle, ArrowLeftRight as TransferIcon, ChevronDown, PanelLeft } from 'lucide-react'; // Added PanelLeft for consistency if needed, but SidebarTrigger has its own +import { PlusCircle, ArrowDownCircle, ArrowUpCircle, ArrowLeftRight as TransferIcon, ChevronDown, PanelLeft } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import AddTransactionForm from '@/components/transactions/add-transaction-form'; import type { Transaction } from '@/services/transactions'; @@ -18,7 +18,7 @@ import { useState, useEffect } from 'react'; import { format as formatDateFns } from 'date-fns'; import { addTransaction } from '@/services/transactions'; import { Skeleton } from '@/components/ui/skeleton'; -import { SidebarTrigger } from '@/components/ui/sidebar'; // Import SidebarTrigger +import { SidebarTrigger } from '@/components/ui/sidebar'; const GlobalHeader: FC = () => { @@ -110,7 +110,7 @@ const GlobalHeader: FC = () => { await addTransaction({ accountId: data.fromAccountId, - amount: -Math.abs(data.amount), // Amount from source account's perspective + amount: -Math.abs(data.amount), transactionCurrency: data.transactionCurrency, date: formattedDate, description: desc, @@ -120,7 +120,7 @@ const GlobalHeader: FC = () => { await addTransaction({ accountId: data.toAccountId, - amount: Math.abs(data.toAccountAmount), // Amount for destination account + amount: Math.abs(data.toAccountAmount), transactionCurrency: data.toAccountCurrency, date: formattedDate, description: desc, @@ -140,10 +140,8 @@ const GlobalHeader: FC = () => { return (
- {/* Mobile Menu Trigger */} - {/* Existing content pushed to the right */}
{ onTransferAdded={handleTransferAdded} isLoading={false} initialType={transactionTypeToAdd} - initialData={{date: new Date()}} // Ensure initialData is always an object + initialData={{date: new Date()}} /> ) : (
@@ -217,3 +215,4 @@ const GlobalHeader: FC = () => { }; export default GlobalHeader; + diff --git a/src/components/transactions/add-transaction-form.tsx b/src/components/transactions/add-transaction-form.tsx index dfb39a5..130566c 100644 --- a/src/components/transactions/add-transaction-form.tsx +++ b/src/components/transactions/add-transaction-form.tsx @@ -1,7 +1,7 @@ 'use client'; -import { FC, useMemo, useEffect } from 'react'; +import { FC, useMemo, useEffect, useState } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; @@ -19,7 +19,7 @@ import type { Account } from '@/services/account-sync'; import type { Category } from '@/services/categories'; import type { Tag } from '@/services/tags'; import type { Transaction } from '@/services/transactions'; -import { getCurrencySymbol, supportedCurrencies } from '@/lib/currency'; +import { getCurrencySymbol, supportedCurrencies, convertCurrency } from '@/lib/currency'; import { toast } from "@/hooks/use-toast"; const transactionTypes = ['expense', 'income', 'transfer'] as const; @@ -50,8 +50,8 @@ const transferSchema = baseSchema.extend({ toAccountCurrency: z.string().min(3, "Destination currency is required for transfers").refine( (val) => supportedCurrencies.includes(val.toUpperCase()), { message: "Unsupported destination currency" } - ).optional(), - exchangeRate: z.coerce.number({ invalid_type_error: "Exchange rate must be a number" }).positive("Exchange rate must be positive").optional(), + ).optional(), // Made optional, will be derived or required conditionally + toAccountAmount: z.coerce.number({ invalid_type_error: "Destination amount must be a number" }).positive("Destination amount must be positive").optional(), }); const formSchema = z.discriminatedUnion('type', [ @@ -67,12 +67,12 @@ const formSchema = z.discriminatedUnion('type', [ path: ['toAccountId'], }).refine(data => { if (data.type === 'transfer' && data.transactionCurrency && data.toAccountCurrency && data.transactionCurrency.toUpperCase() !== data.toAccountCurrency.toUpperCase()) { - return data.exchangeRate !== undefined && data.exchangeRate > 0; + return data.toAccountAmount !== undefined && data.toAccountAmount > 0; } return true; }, { - message: "Exchange rate is required and must be positive for cross-currency transfers.", - path: ['exchangeRate'], + message: "Destination amount is required and must be positive for cross-currency transfers.", + path: ['toAccountAmount'], }); @@ -95,7 +95,7 @@ interface AddTransactionFormProps { tags?: string[]; }) => Promise | void; isLoading: boolean; - initialType?: typeof transactionTypes[number] | null; // Allow null from parent + initialType?: typeof transactionTypes[number] | null; initialData?: Partial; } @@ -115,6 +115,7 @@ const AddTransactionForm: FC = ({ initialData }) => { const resolvedInitialType = initialTypeFromParent ?? (initialData?.type || 'expense'); + const [calculatedRate, setCalculatedRate] = useState(null); const form = useForm({ resolver: zodResolver(formSchema), @@ -125,8 +126,8 @@ const AddTransactionForm: FC = ({ tags: initialData.tags || [], transactionCurrency: (initialData as Transaction)?.transactionCurrency || accounts.find(acc => acc.id === (initialData as any)?.accountId)?.currency || 'BRL', description: initialData.description || '', - toAccountCurrency: (initialData as any)?.toAccountCurrency || (initialData as any)?.transactionCurrency || (accounts.find(acc => acc.id === (initialData as any)?.toAccountId)?.currency), - exchangeRate: (initialData as any)?.exchangeRate, + toAccountCurrency: (initialData as any)?.toAccountCurrency || (accounts.find(acc => acc.id === (initialData as any)?.toAccountId)?.currency), + toAccountAmount: (initialData as any)?.toAccountAmount, // For editing existing transfers } : { type: resolvedInitialType, description: "", @@ -139,7 +140,7 @@ const AddTransactionForm: FC = ({ tags: [], transactionCurrency: accounts.length > 0 ? accounts[0].currency : 'BRL', toAccountCurrency: accounts.length > 1 ? accounts[1].currency : (accounts.length > 0 ? accounts[0].currency : 'BRL'), - exchangeRate: undefined, + toAccountAmount: undefined, }, }); @@ -147,8 +148,8 @@ const AddTransactionForm: FC = ({ const selectedAccountId = form.watch('accountId'); const selectedFromAccountId = form.watch('fromAccountId'); const selectedToAccountId = form.watch('toAccountId'); - // const formTransactionCurrency = form.watch('transactionCurrency'); // We'll derive this more directly for display - // const formToAccountCurrency = form.watch('toAccountCurrency'); // We'll derive this more directly for display + const sourceAmount = form.watch('amount'); + const destinationAmount = form.watch('toAccountAmount'); const isEditingExisting = !!(initialData && 'id' in initialData && initialData.id); @@ -163,6 +164,27 @@ const AddTransactionForm: FC = ({ return singleAccountForDisplay?.currency || form.getValues('transactionCurrency') || 'BRL'; }, [transactionType, fromAccountForDisplay, singleAccountForDisplay, form]); + const currentToAccountCurrencyForDisplay = useMemo(() => { + if (transactionType === 'transfer') { + return toAccountForDisplay?.currency || form.getValues('toAccountCurrency') || 'BRL'; + } + return 'BRL'; // Default or not applicable for non-transfers + }, [transactionType, toAccountForDisplay, form]); + + + useEffect(() => { + if (transactionType === 'transfer' && fromAccountForDisplay && toAccountForDisplay && sourceAmount && destinationAmount && sourceAmount > 0 && destinationAmount > 0) { + if (fromAccountForDisplay.currency !== toAccountForDisplay.currency) { + const rate = destinationAmount / sourceAmount; + setCalculatedRate(`1 ${fromAccountForDisplay.currency} = ${rate.toFixed(4)} ${toAccountForDisplay.currency}`); + } else { + setCalculatedRate(null); // Same currency, no rate needed + } + } else { + setCalculatedRate(null); + } + }, [fromAccountForDisplay, toAccountForDisplay, sourceAmount, destinationAmount, transactionType]); + async function onSubmit(values: AddTransactionFormData) { const finalTags = values.tags || []; @@ -179,24 +201,25 @@ const AddTransactionForm: FC = ({ const actualSourceCurrency = fromAccount.currency; const actualDestinationCurrency = toAccount.currency; - let finalToAccountAmount = values.amount; - - if (actualSourceCurrency.toUpperCase() !== actualDestinationCurrency.toUpperCase()) { - if (!values.exchangeRate || values.exchangeRate <= 0) { - form.setError("exchangeRate", { type: "manual", message: "Exchange rate is required and must be positive for cross-currency transfers." }); - toast({ title: "Error", description: "Exchange rate is required for cross-currency transfers.", variant: "destructive" }); + + let finalToAccountAmount: number; + if (actualSourceCurrency.toUpperCase() === actualDestinationCurrency.toUpperCase()) { + finalToAccountAmount = values.amount; // Same currency, amount is the same + } else { + if (!values.toAccountAmount || values.toAccountAmount <= 0) { + form.setError("toAccountAmount", { type: "manual", message: "Destination amount is required and must be positive for cross-currency transfers." }); + toast({ title: "Error", description: "Destination amount is required for cross-currency transfers.", variant: "destructive" }); return; } - finalToAccountAmount = values.amount * values.exchangeRate; + finalToAccountAmount = values.toAccountAmount; } - await onTransferAdded({ fromAccountId: values.fromAccountId, toAccountId: values.toAccountId, - amount: values.amount, + amount: values.amount, // This is the source amount transactionCurrency: actualSourceCurrency, - toAccountAmount: finalToAccountAmount, + toAccountAmount: finalToAccountAmount, // This is the user-provided or same-as-source amount toAccountCurrency: actualDestinationCurrency, date: values.date, description: values.description || `Transfer from ${fromAccount.name} to ${toAccount.name}`, @@ -210,16 +233,21 @@ const AddTransactionForm: FC = ({ variant: "destructive", }); } - } else { + } else { // Expense or Income + const account = accounts.find(acc => acc.id === values.accountId); + if (!account) { + toast({ title: "Error", description: "Selected account not found.", variant: "destructive"}); + return; + } const transactionAmount = values.type === 'expense' ? -Math.abs(values.amount) : Math.abs(values.amount); const transactionData: Omit | Transaction = { ...(initialData && (initialData as Transaction).id && { id: (initialData as Transaction).id }), - accountId: values.accountId!, // Assert accountId is present for expense/income + accountId: values.accountId!, amount: transactionAmount, - transactionCurrency: values.transactionCurrency, + transactionCurrency: account.currency, // Always use the selected account's currency for expense/income date: formatDateFns(values.date, 'yyyy-MM-dd'), description: values.description || values.category || 'Transaction', - category: values.category!, // Assert category is present for expense/income + category: values.category!, tags: finalTags, }; await onTransactionAdded(transactionData); @@ -241,7 +269,7 @@ const AddTransactionForm: FC = ({ form.setValue('transactionCurrency', fromAccount.currency); } } else { - form.setValue('transactionCurrency', undefined); // Clear if no fromAccount + form.setValue('transactionCurrency', undefined); } if (toAccount) { @@ -249,23 +277,23 @@ const AddTransactionForm: FC = ({ form.setValue('toAccountCurrency', toAccount.currency); } } else { - form.setValue('toAccountCurrency', undefined); // Clear if no toAccount + form.setValue('toAccountCurrency', undefined); } - if (fromAccount && toAccount) { - if (fromAccount.currency.toUpperCase() === toAccount.currency.toUpperCase()) { - if (form.getValues('exchangeRate') !== 1) { - form.setValue('exchangeRate', 1, { shouldValidate: true }); - } - } else { - // If currencies are different and rate is 1 (default for same currency) or undefined, clear it to prompt user or indicate it's needed - const currentRate = form.getValues('exchangeRate'); - if (currentRate === 1 || currentRate === undefined) { - form.setValue('exchangeRate', undefined, { shouldValidate: true }); - } + // Auto-fill destination amount if currencies are the same + if (fromAccount && toAccount && fromAccount.currency.toUpperCase() === toAccount.currency.toUpperCase()) { + const sourceAmt = form.getValues('amount'); + if (sourceAmt && form.getValues('toAccountAmount') !== sourceAmt) { + form.setValue('toAccountAmount', sourceAmt, { shouldValidate: true }); } - } else { // If one or both accounts are not selected, clear exchange rate - form.setValue('exchangeRate', undefined); + } else if (fromAccount && toAccount && fromAccount.currency.toUpperCase() !== toAccount.currency.toUpperCase()) { + // If currencies are different and toAccountAmount was set for same currency, clear it or leave as is for user input. + // Optionally, clear toAccountAmount if it was previously auto-filled to match sourceAmount + // if (form.getValues('toAccountAmount') === form.getValues('amount')) { + // form.setValue('toAccountAmount', undefined, { shouldValidate: true }); + // } + } else { // If one or both accounts are not selected, clear toAccountAmount + form.setValue('toAccountAmount', undefined); } } }, [selectedAccountId, selectedFromAccountId, selectedToAccountId, transactionType, accounts, form]); @@ -299,31 +327,28 @@ const AddTransactionForm: FC = ({ { - field.onChange(value); - if (transactionType === 'transfer') { - const toAccCurrency = form.getValues('toAccountCurrency'); - if (toAccCurrency && value.toUpperCase() === toAccCurrency.toUpperCase()) { - form.setValue('exchangeRate', 1); - } else if (toAccCurrency) { - form.setValue('exchangeRate', undefined); - } - } - }} - value={field.value || ''} // Ensure value is not undefined for Select - // Disable if currency is determined by a selected account - disabled={ - (transactionType !== 'transfer' && !!selectedAccountId && !!accounts.find(a=>a.id === selectedAccountId)) || - (transactionType === 'transfer' && !!selectedFromAccountId && !!accounts.find(a=>a.id === selectedFromAccountId)) - } - > + Amount in Destination Account ({getCurrencySymbol(currentToAccountCurrencyForDisplay)}) - - - + - - {supportedCurrencies.map((curr) => ( - - {curr} ({getCurrencySymbol(curr)}) - - ))} - - - - {transactionType === 'transfer' ? - "Usually determined by 'From Account'." : - "Usually determined by selected Account." - } - - + Enter the exact amount that arrived in the destination account. + - )} - /> - - {transactionType === 'transfer' && ( - <> - ( - - To Account Currency - - Usually determined by 'To Account'. - - - )} - /> - {/* Conditionally render Exchange Rate field */} - {fromAccountForDisplay?.currency && toAccountForDisplay?.currency && fromAccountForDisplay.currency.toUpperCase() !== toAccountForDisplay.currency.toUpperCase() && ( - ( - - Exchange Rate (1 {fromAccountForDisplay.currency} = ? {toAccountForDisplay.currency}) - - - - Required for cross-currency transfer. - - - )} - /> )} - + /> )} + {calculatedRate && transactionType === 'transfer' && ( +
+ Effective Rate: {calculatedRate} +
+ )} + {transactionType !== 'transfer' && ( = ({ {categories - .filter(cat => cat.name.toLowerCase() !== 'transfer') // Exclude 'Transfer' category for non-transfer types + .filter(cat => cat.name.toLowerCase() !== 'transfer') .sort((a, b) => a.name.localeCompare(b.name)) .map((cat) => ( From ffa239f1d5fdfd0f615641ffeb565311e75910ec Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 20 May 2025 00:54:13 +0000 Subject: [PATCH 007/156] Agora, preciso que meio que voce crie uma funcao de exportar os dados em um arquivo csv, de uma forma que ao importar o mesmo arquivo de volta o app relembre tudo, digo tudo mesmo, categorias, grupos, loans, budgets, transacoes, accounts, tudo tudo --- src/app/preferences/page.tsx | 67 ++++++++++---- src/services/export.ts | 170 +++++++++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+), 15 deletions(-) create mode 100644 src/services/export.ts diff --git a/src/app/preferences/page.tsx b/src/app/preferences/page.tsx index c67c4ac..c4b4081 100644 --- a/src/app/preferences/page.tsx +++ b/src/app/preferences/page.tsx @@ -2,22 +2,25 @@ 'use client'; import { useState, useEffect } from 'react'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Label } from "@/components/ui/label"; import type { UserPreferences } from '@/lib/preferences'; -import { saveUserPreferences, getUserPreferences as fetchUserPreferencesFromLib } from '@/lib/preferences'; // Import saveUserPreferences directly +import { saveUserPreferences } from '@/lib/preferences'; import { supportedCurrencies, getCurrencySymbol } from '@/lib/currency'; import { useToast } from "@/hooks/use-toast"; import { Skeleton } from '@/components/ui/skeleton'; import { useAuthContext } from '@/contexts/AuthContext'; +import { exportAllUserDataToCsvs } from '@/services/export'; // Import the export function +import { Download } from 'lucide-react'; export default function PreferencesPage() { - const { user, isLoadingAuth, userPreferences, refreshUserPreferences } = useAuthContext(); // Removed setAppTheme as we'll save directly + const { user, isLoadingAuth, userPreferences, refreshUserPreferences } = useAuthContext(); const [preferredCurrency, setPreferredCurrency] = useState(userPreferences?.preferredCurrency || 'BRL'); const [selectedTheme, setSelectedTheme] = useState(userPreferences?.theme || 'system'); const [isSaving, setIsSaving] = useState(false); + const [isExporting, setIsExporting] = useState(false); // New state for export loading const { toast } = useToast(); useEffect(() => { @@ -42,24 +45,17 @@ export default function PreferencesPage() { } setIsSaving(true); try { - // Construct the new preferences object with current selections from the page const newPreferencesToSave: UserPreferences = { - preferredCurrency: preferredCurrency, // This is from PreferencesPage's local state - theme: selectedTheme, // This is also from PreferencesPage's local state + preferredCurrency: preferredCurrency, + theme: selectedTheme, }; - - // Directly save the new preferences using the lib function await saveUserPreferences(newPreferencesToSave); - - // Refresh the AuthContext's state so the rest of the app sees the changes await refreshUserPreferences(); - toast({ title: "Preferences Saved", description: "Your preferences have been updated successfully.", }); - // Trigger a global event to notify other components (like AuthWrapper for theme) - window.dispatchEvent(new Event('storage')); + window.dispatchEvent(new Event('storage')); } catch (error) { console.error("Failed to save preferences:", error); toast({ @@ -72,6 +68,25 @@ export default function PreferencesPage() { } }; + const handleExportData = async () => { + if (!user) { + toast({ title: "Error", description: "User not authenticated.", variant: "destructive" }); + return; + } + setIsExporting(true); + toast({ title: "Exporting Data", description: "Preparing your data for download. This may take a moment..." }); + try { + await exportAllUserDataToCsvs(); + toast({ title: "Export Complete", description: "Your data files should be downloading now. Please check your browser's download folder." }); + } catch (error) { + console.error("Export failed:", error); + toast({ title: "Export Failed", description: "Could not export your data. Please try again.", variant: "destructive" }); + } finally { + setIsExporting(false); + } + }; + + if (isLoadingAuth || (!userPreferences && user)) { return (
@@ -98,8 +113,8 @@ export default function PreferencesPage() { } return ( -
-

Preferences

+
+

Preferences

@@ -163,6 +178,28 @@ export default function PreferencesPage() { )} + + + + Data Management + + Export all your application data to CSV files. + + + + + + +

+ This will download multiple CSV files, one for each data type (accounts, transactions, etc.). + These files can be used as a backup or for importing into other systems. +

+
+
+
); } diff --git a/src/services/export.ts b/src/services/export.ts new file mode 100644 index 0000000..0797241 --- /dev/null +++ b/src/services/export.ts @@ -0,0 +1,170 @@ + +'use client'; + +import Papa from 'papaparse'; +import { getAccounts, type Account } from './account-sync'; +import { getCategories, type Category } from './categories'; +import { getTags, type Tag } from './tags'; +import { getGroups, type Group } from './groups'; +import { getTransactions, type Transaction } from './transactions'; +import { getSubscriptions, type Subscription } from './subscriptions'; +import { getLoans, type Loan } from './loans'; +import { getCreditCards, type CreditCard } from './credit-cards'; +import { getBudgets, type Budget } from './budgets'; +import { getUserPreferences, type UserPreferences } from '@/lib/preferences'; + +function downloadCsv(csvString: string, filename: string) { + const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + if (link.download !== undefined) { // feature detection + const url = URL.createObjectURL(blob); + link.setAttribute('href', url); + link.setAttribute('download', filename); + link.style.visibility = 'hidden'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } else { + // Fallback for browsers that don't support HTML5 download attribute + alert('CSV download is not supported by your browser. Please try a different browser.'); + } +} + +interface ExportableTransaction extends Omit { + tags?: string; // Pipe-separated + originalImportData?: string; // JSON string + createdAt?: string; + updatedAt?: string; +} +interface ExportableSubscription extends Omit { + tags?: string; // Pipe-separated + createdAt?: string; + updatedAt?: string; +} + +interface ExportableGroup extends Omit { + categoryIds?: string; // Pipe-separated +} +interface ExportableBudget extends Omit { + selectedIds?: string; // Pipe-separated + createdAt?: string; + updatedAt?: string; +} + + +export async function exportAllUserDataToCsvs(): Promise { + try { + // 1. User Preferences + const preferences = await getUserPreferences(); + if (preferences) { + const preferencesCsv = Papa.unparse([preferences]); + downloadCsv(preferencesCsv, 'user_preferences.csv'); + } + + // 2. Categories + const categories = await getCategories(); + if (categories.length > 0) { + const categoriesCsv = Papa.unparse(categories); + downloadCsv(categoriesCsv, 'categories.csv'); + } + + // 3. Tags + const tags = await getTags(); + if (tags.length > 0) { + const tagsCsv = Papa.unparse(tags); + downloadCsv(tagsCsv, 'tags.csv'); + } + + // 4. Groups + const groups = await getGroups(); + if (groups.length > 0) { + const exportableGroups: ExportableGroup[] = groups.map(g => ({ + ...g, + categoryIds: g.categoryIds.join('|'), + })); + const groupsCsv = Papa.unparse(exportableGroups); + downloadCsv(groupsCsv, 'groups.csv'); + } + + // 5. Accounts + const accounts = await getAccounts(); + if (accounts.length > 0) { + const accountsCsv = Papa.unparse(accounts); + downloadCsv(accountsCsv, 'accounts.csv'); + + // 6. Transactions (fetch per account, then combine) + let allTransactions: Transaction[] = []; + for (const account of accounts) { + const accountTransactions = await getTransactions(account.id); + allTransactions = allTransactions.concat(accountTransactions); + } + if (allTransactions.length > 0) { + const exportableTransactions: ExportableTransaction[] = allTransactions.map(tx => ({ + ...tx, + tags: tx.tags?.join('|') || '', + originalImportData: tx.originalImportData ? JSON.stringify(tx.originalImportData) : '', + createdAt: typeof tx.createdAt === 'object' ? new Date().toISOString() : tx.createdAt, // Placeholder for serverTimestamp + updatedAt: typeof tx.updatedAt === 'object' ? new Date().toISOString() : tx.updatedAt, // Placeholder for serverTimestamp + })); + const transactionsCsv = Papa.unparse(exportableTransactions); + downloadCsv(transactionsCsv, 'transactions.csv'); + } + } + + // 7. Subscriptions + const subscriptions = await getSubscriptions(); + if (subscriptions.length > 0) { + const exportableSubscriptions: ExportableSubscription[] = subscriptions.map(sub => ({ + ...sub, + tags: sub.tags?.join('|') || '', + createdAt: typeof sub.createdAt === 'object' ? new Date().toISOString() : sub.createdAt, + updatedAt: typeof sub.updatedAt === 'object' ? new Date().toISOString() : sub.updatedAt, + })); + const subscriptionsCsv = Papa.unparse(exportableSubscriptions); + downloadCsv(subscriptionsCsv, 'subscriptions.csv'); + } + + // 8. Loans + const loans = await getLoans(); + if (loans.length > 0) { + const exportableLoans = loans.map(loan => ({ + ...loan, + createdAt: typeof loan.createdAt === 'object' ? new Date().toISOString() : loan.createdAt, + updatedAt: typeof loan.updatedAt === 'object' ? new Date().toISOString() : loan.updatedAt, + })); + const loansCsv = Papa.unparse(exportableLoans); + downloadCsv(loansCsv, 'loans.csv'); + } + + // 9. Credit Cards + const creditCards = await getCreditCards(); + if (creditCards.length > 0) { + const exportableCreditCards = creditCards.map(card => ({ + ...card, + createdAt: typeof card.createdAt === 'object' ? new Date().toISOString() : card.createdAt, + updatedAt: typeof card.updatedAt === 'object' ? new Date().toISOString() : card.updatedAt, + })); + const creditCardsCsv = Papa.unparse(exportableCreditCards); + downloadCsv(creditCardsCsv, 'credit_cards.csv'); + } + + // 10. Budgets + const budgets = await getBudgets(); + if (budgets.length > 0) { + const exportableBudgets: ExportableBudget[] = budgets.map(b => ({ + ...b, + selectedIds: b.selectedIds.join('|'), + createdAt: typeof b.createdAt === 'object' ? new Date().toISOString() : b.createdAt, + updatedAt: typeof b.updatedAt === 'object' ? new Date().toISOString() : b.updatedAt, + })); + const budgetsCsv = Papa.unparse(exportableBudgets); + downloadCsv(budgetsCsv, 'budgets.csv'); + } + + console.log('All user data prepared for download.'); + } catch (error) { + console.error("Error exporting user data:", error); + throw error; + } +} From 8819983f51d47f8c2f135a2eb2ad602af0af8439 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 20 May 2025 01:41:46 +0000 Subject: [PATCH 008/156] Fiz o rollback para depois de adicionar a funcao, vamos tentar novamente. Nessa versao a aba import data ja nao esta direcionando para o painel --- src/app/data-management/page.tsx | 1561 ++++++++++++++++++++++++++++++ 1 file changed, 1561 insertions(+) create mode 100644 src/app/data-management/page.tsx diff --git a/src/app/data-management/page.tsx b/src/app/data-management/page.tsx new file mode 100644 index 0000000..ee7d975 --- /dev/null +++ b/src/app/data-management/page.tsx @@ -0,0 +1,1561 @@ + +'use client'; + +import { useState, useEffect, useMemo, useCallback } from 'react'; +import Papa, { ParseResult } from 'papaparse'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { useToast } from "@/hooks/use-toast"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Progress } from "@/components/ui/progress"; +import { addTransaction, type Transaction, clearAllSessionTransactions } from '@/services/transactions'; +import { getAccounts, addAccount, type Account, type NewAccountData, updateAccount } from '@/services/account-sync'; +import { getCategories, addCategory as addCategoryToDb, type Category } from '@/services/categories'; +import { getTags, addTag as addTagToDb, type Tag } from '@/services/tags'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription } from "@/components/ui/dialog"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; + +import { format, parseISO, isValid, parse as parseDateFns } from 'date-fns'; +import { getCurrencySymbol, supportedCurrencies, formatCurrency, convertCurrency } from '@/lib/currency'; +import CsvMappingForm, { type ColumnMapping } from '@/components/import/csv-mapping-form'; +import { AlertCircle, Trash2, Download } from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useAuthContext } from '@/contexts/AuthContext'; +import Link from 'next/link'; +import { exportAllUserDataToCsvs } from '@/services/export'; // Import the export function + +type CsvRecord = { + [key: string]: string | undefined; +}; + +const APP_FIELDS_VALUES = [ + 'date', 'amount', 'foreign_amount', + 'description', + 'source_name', 'destination_name', 'source_type', 'destination_type', + 'category', 'currency_code', 'foreign_currency_code', + 'tags', 'notes', 'transaction_type', 'initialBalance' +] as const; + +type AppField = typeof APP_FIELDS_VALUES[number]; + +type MappedTransaction = { + csvRawSourceName?: string | null; + csvRawDestinationName?: string | null; + csvTransactionType?: string | null; + csvSourceType?: string | null; + csvDestinationType?: string | null; + + date: string; + amount: number; + description: string; + category: string; + currency: string; + foreignAmount?: number | null; + foreignCurrency?: string | null; + tags?: string[]; + originalRecord: Record; + importStatus: 'pending' | 'success' | 'error' | 'skipped'; + errorMessage?: string | null; + + appSourceAccountId?: string | null; + appDestinationAccountId?: string | null; + originalImportData?: { + foreignAmount?: number | null; + foreignCurrency?: string | null; + } +}; + + +interface AccountPreview { + name: string; + currency: string; + initialBalance: number; + action: 'create' | 'update' | 'no change'; + existingId?: string; + category: 'asset' | 'crypto'; +} + + +const findColumnName = (headers: string[], targetName: string): string | undefined => { + const normalizedTargetName = targetName.trim().toLowerCase(); + return headers.find(header => header?.trim().toLowerCase() === normalizedTargetName); +}; + + +const parseAmount = (amountStr: string | undefined): number => { + if (typeof amountStr !== 'string' || amountStr.trim() === '') return NaN; + let cleaned = amountStr.replace(/[^\d.,-]/g, '').trim(); + + const hasPeriod = cleaned.includes('.'); + const hasComma = cleaned.includes(','); + + if (hasComma && hasPeriod) { + if (cleaned.lastIndexOf(',') > cleaned.lastIndexOf('.')) { + cleaned = cleaned.replace(/\./g, '').replace(',', '.'); + } else { + cleaned = cleaned.replace(/,/g, ''); + } + } else if (hasComma) { + cleaned = cleaned.replace(',', '.'); + } + + const dotMatches = cleaned.match(/\./g); + if (dotMatches && dotMatches.length > 1) { + const lastDotIndex = cleaned.lastIndexOf('.'); + const partAfterLastDot = cleaned.substring(lastDotIndex + 1); + if (partAfterLastDot.length < 3 || partAfterLastDot.match(/^\d+$/) ) { + cleaned = cleaned.substring(0, lastDotIndex).replace(/\./g, '') + '.' + partAfterLastDot; + } else { + cleaned = cleaned.replace(/\./g, ''); + } + } + + if (cleaned.endsWith('.') || cleaned.endsWith(',')) { + cleaned += '0'; + } + cleaned = cleaned.replace(/^[,.]+|[,.]+$/g, ''); + + const parsed = parseFloat(cleaned); + return parsed; +}; + + +const parseDate = (dateStr: string | undefined): string => { + if (!dateStr) return format(new Date(), 'yyyy-MM-dd'); + try { + let parsedDate = parseISO(dateStr); + if (isValid(parsedDate)) { + return format(parsedDate, 'yyyy-MM-dd'); + } + + const commonFormats = [ + "yyyy-MM-dd'T'HH:mm:ssXXX", "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", "yyyy-MM-dd'T'HH:mm:ss", + 'dd/MM/yyyy HH:mm:ss', 'MM/dd/yyyy HH:mm:ss', 'yyyy-MM-dd HH:mm:ss', + 'dd/MM/yyyy', 'MM/dd/yyyy', 'yyyy-MM-dd', + 'dd.MM.yyyy', 'MM.dd.yyyy', + 'dd-MM-yyyy', 'MM-dd-yyyy', + 'yyyy/MM/dd', 'yyyy/dd/MM', + ]; + + for (const fmt of commonFormats) { + try { + parsedDate = parseDateFns(dateStr, fmt, new Date()); + if (isValid(parsedDate)) return format(parsedDate, 'yyyy-MM-dd'); + + const datePartOnly = dateStr.split('T')[0].split(' ')[0]; + const dateFormatOnly = fmt.split('T')[0].split(' ')[0]; + if (datePartOnly !== dateStr && dateFormatOnly !== fmt) { + parsedDate = parseDateFns(datePartOnly, dateFormatOnly, new Date()); + if (isValid(parsedDate)) return format(parsedDate, 'yyyy-MM-dd'); + } + } catch { /* ignore parse error for this format, try next */ } + } + + parsedDate = new Date(dateStr); + if (isValid(parsedDate)) { + return format(parsedDate, 'yyyy-MM-dd'); + } + + } catch (e) { + console.error("Error parsing date:", dateStr, e); + } + console.warn(`Could not parse date "${dateStr}", defaulting to today.`); + return format(new Date(), 'yyyy-MM-dd'); +}; + +const parseNameFromDescriptiveString = (text: string | undefined): string | undefined => { + if (!text) return undefined; + const match = text.match(/(?:Initial balance for |Saldo inicial para(?: d[aeo] conta)?)\s*["']?([^"':]+)(?:["']?|$)/i); + return match ? match[1]?.trim() : undefined; +}; + + +export default function DataManagementPage() { + const { user, isLoadingAuth } = useAuthContext(); + const [file, setFile] = useState(null); + const [csvHeaders, setCsvHeaders] = useState([]); + const [rawData, setRawData] = useState([]); + const [parsedData, setParsedData] = useState([]); + const [isLoading, setIsLoading] = useState(true); // Start with true for initial data load + const [importProgress, setImportProgress] = useState(0); + const [error, setError] = useState(null); + const [accounts, setAccounts] = useState([]); + const [categories, setCategories] = useState([]); + const [tags, setTags] = useState([]); + const [accountPreviewData, setAccountPreviewData] = useState([]); + const [finalAccountMapForImport, setFinalAccountMapForImport] = useState<{ [key: string]: string }>({}); + const [isMappingDialogOpen, setIsMappingDialogOpen] = useState(false); + const [columnMappings, setColumnMappings] = useState({}); + const [isClearing, setIsClearing] = useState(false); + const [isExporting, setIsExporting] = useState(false); + const { toast } = useToast(); + + + useEffect(() => { + if (isLoadingAuth || !user) { + setIsLoading(false); + return; + } + + let isMounted = true; + const fetchData = async () => { + if (!isMounted) return; + setIsLoading(true); // Set loading true at the start of data fetching + setError(null); + try { + const [fetchedAccounts, fetchedCategories, fetchedTagsList] = await Promise.all([ + getAccounts(), + getCategories(), + getTags() + ]); + + if (isMounted) { + setAccounts(fetchedAccounts); + setCategories(fetchedCategories); + setTags(fetchedTagsList); + } + } catch (err: any) { + console.error("Failed to fetch initial data for Data Management page:", err); + if (isMounted) { + setError("Could not load essential page data. Please try refreshing. Details: " + err.message); + toast({ title: "Page Load Error", description: "Failed to load initial data (accounts, categories, or tags). " + err.message, variant: "destructive" }); + } + } finally { + if (isMounted) { + setIsLoading(false); + } + } + }; + + fetchData(); + return () => { isMounted = false; }; + }, [user, isLoadingAuth]); // Only depend on user and isLoadingAuth + + const handleFileChange = (event: React.ChangeEvent) => { + if (event.target.files && event.target.files[0]) { + setFile(event.target.files[0]); + setError(null); + setParsedData([]); + setAccountPreviewData([]); + setRawData([]); + setCsvHeaders([]); + setImportProgress(0); + setColumnMappings({}); + setFinalAccountMapForImport({}); + } + }; + + const handleParseAndMap = () => { + if (!file) { + setError("Please select a CSV file first."); + return; + } + if (!user) { + setError("You must be logged in to import data."); + toast({ title: "Authentication Required", description: "Please log in to import data.", variant: "destructive"}); + return; + } + + setIsLoading(true); // For file parsing specifically + setError(null); + setParsedData([]); + setAccountPreviewData([]); + setRawData([]); + setCsvHeaders([]); + + Papa.parse(file, { + header: true, + skipEmptyLines: true, + complete: (results: ParseResult) => { + if (results.errors.length > 0 && !results.data.length) { + const criticalError = results.errors.find(e => e.code !== 'TooManyFields' && e.code !== 'TooFewFields') || results.errors[0]; + setError(`CSV Parsing Error: ${criticalError.message}. Code: ${criticalError.code}. Ensure headers are correct and file encoding is UTF-8.`); + setIsLoading(false); + return; + } + if (results.errors.length > 0) { + console.warn("Minor CSV parsing errors encountered:", results.errors); + toast({ title: "CSV Parsing Warning", description: `Some rows might have issues: ${results.errors.map(e=>e.message).slice(0,2).join('; ')}`, variant:"default", duration: 7000}); + } + + if (!results.data || results.data.length === 0) { + setError("CSV file is empty or doesn't contain valid data rows."); + setIsLoading(false); + return; + } + + const headers = results.meta.fields; + if (!headers || headers.length === 0) { + setError("Could not read CSV headers. Ensure the first row contains column names."); + setIsLoading(false); + return; + } + + setCsvHeaders(headers.filter(h => h != null) as string[]); + setRawData(results.data); + + const detectedHeaders = headers.filter(h => h != null) as string[]; + const initialMappings: ColumnMapping = {}; + + initialMappings.date = findColumnName(detectedHeaders, 'date'); + initialMappings.amount = findColumnName(detectedHeaders, 'amount'); + initialMappings.description = findColumnName(detectedHeaders, 'description'); + initialMappings.source_name = findColumnName(detectedHeaders, 'source_name'); + initialMappings.destination_name = findColumnName(detectedHeaders, 'destination_name'); + initialMappings.currency_code = findColumnName(detectedHeaders, 'currency_code') || findColumnName(detectedHeaders, 'currency'); + initialMappings.category = findColumnName(detectedHeaders, 'category'); + initialMappings.tags = findColumnName(detectedHeaders, 'tags'); + initialMappings.transaction_type = findColumnName(detectedHeaders, 'type'); + initialMappings.notes = findColumnName(detectedHeaders, 'notes'); + initialMappings.foreign_amount = findColumnName(detectedHeaders, 'foreign_amount'); + initialMappings.foreign_currency_code = findColumnName(detectedHeaders, 'foreign_currency_code'); + initialMappings.source_type = findColumnName(detectedHeaders, 'source_type'); + initialMappings.destination_type = findColumnName(detectedHeaders, 'destination_type'); + initialMappings.initialBalance = findColumnName(detectedHeaders, 'initial_balance') || findColumnName(detectedHeaders, 'opening_balance'); + + + setColumnMappings(initialMappings); + setIsMappingDialogOpen(true); + setIsLoading(false); + }, + error: (err: Error) => { + setError(`Failed to read or parse CSV file: ${err.message}.`); + setIsLoading(false); + } + }); + }; + + const processAndMapData = async (confirmedMappings: ColumnMapping) => { + setIsLoading(true); + setError(null); + setParsedData([]); + setAccountPreviewData([]); + setColumnMappings(confirmedMappings); + setFinalAccountMapForImport({}); + + const coreRequiredFields: AppField[] = ['date', 'amount', 'currency_code', 'transaction_type']; + let missingFieldLabels = coreRequiredFields + .filter(field => !confirmedMappings[field]) + .map(field => APP_FIELDS_VALUES.find(val => val === field) || field); + + if (confirmedMappings.transaction_type) { + if (!confirmedMappings.source_name) missingFieldLabels.push('source_name (e.g., Firefly \'source_name\')'); + if (!confirmedMappings.destination_name) missingFieldLabels.push('destination_name (e.g., Firefly \'destination_name\')'); + } else { + missingFieldLabels.push('transaction_type (e.g., Firefly \'type\')'); + } + + missingFieldLabels = [...new Set(missingFieldLabels)]; + + if (missingFieldLabels.length > 0) { + setError(`Missing required column mappings for import: ${missingFieldLabels.join(', ')}. Please map these fields.`); + setIsLoading(false); + setIsMappingDialogOpen(true); + return; + } + + const currentAccounts = await getAccounts(); + setAccounts(currentAccounts); + + const { preview } = await previewAccountChanges( + rawData, + confirmedMappings, + currentAccounts + ); + setAccountPreviewData(preview); + console.log("Account preview generated:", preview); + + const mapped: MappedTransaction[] = rawData.map((record, index) => { + const rowNumber = index + 2; + + const dateCol = confirmedMappings.date!; + const amountCol = confirmedMappings.amount!; + const currencyCol = confirmedMappings.currency_code!; + const descCol = confirmedMappings.description; + const categoryCol = confirmedMappings.category; + const tagsCol = confirmedMappings.tags; + const notesCol = confirmedMappings.notes; + const typeCol = confirmedMappings.transaction_type!; + const sourceNameCol = confirmedMappings.source_name!; + const destNameCol = confirmedMappings.destination_name!; + const foreignAmountCol = confirmedMappings.foreign_amount; + const foreignCurrencyCol = confirmedMappings.foreign_currency_code; + const sourceTypeCol = confirmedMappings.source_type; + const destTypeCol = confirmedMappings.destination_type; + const initialBalanceCol = confirmedMappings.initialBalance; + + const sanitizedRecord: Record = {}; + for (const key in record) { + if (Object.prototype.hasOwnProperty.call(record, key)) { + sanitizedRecord[key] = record[key] === undefined ? null : record[key]!; + } + } + + try { + const csvTypeRaw = record[typeCol]; + const csvType = csvTypeRaw?.trim().toLowerCase(); + + const dateValue = record[dateCol]; + let amountValue = record[amountCol]; + const currencyValue = record[currencyCol]; + const foreignAmountValue = foreignAmountCol ? record[foreignAmountCol] : undefined; + const foreignCurrencyValue = foreignCurrencyCol ? record[foreignCurrencyCol] : undefined; + + const descriptionValue = descCol ? record[descCol] || '' : ''; + const categoryValue = categoryCol ? record[categoryCol] || 'Uncategorized' : 'Uncategorized'; + const tagsValue = tagsCol ? record[tagsCol] || '' : ''; + const notesValue = notesCol ? record[notesCol] || '' : ''; + + let rawSourceName = record[sourceNameCol]?.trim(); + let rawDestName = record[destNameCol]?.trim(); + let rawSourceType = sourceTypeCol ? record[sourceTypeCol]?.trim().toLowerCase() : undefined; + let rawDestType = destTypeCol ? record[destTypeCol]?.trim().toLowerCase() : undefined; + + if (!dateValue) throw new Error(`Row ${rowNumber}: Missing mapped 'Date' data.`); + if (amountValue === undefined || amountValue.trim() === '') throw new Error(`Row ${rowNumber}: Missing or empty 'Amount' data.`); + if (!currencyValue || currencyValue.trim() === '') throw new Error(`Row ${rowNumber}: Missing or empty 'Currency Code' data.`); + if (!csvType) throw new Error(`Row ${rowNumber}: Missing or empty 'Transaction Type' (Firefly 'type') data.`); + + if (csvType === 'withdrawal' || csvType === 'transfer') { + if (!rawSourceName) throw new Error(`Row ${rowNumber}: Missing 'Source Name' for type '${csvType}'.`); + } + if (csvType === 'deposit' || csvType === 'transfer') { + if (!rawDestName) throw new Error(`Row ${rowNumber}: Missing 'Destination Name' for type '${csvType}'.`); + } + if (csvType === 'transfer' && rawSourceName && rawDestName && rawSourceName.toLowerCase() === rawDestName.toLowerCase()) { + if (rawSourceType?.includes('asset') && rawDestType?.includes('asset')) { + throw new Error(`Row ${rowNumber}: Transfer source and destination asset accounts are the same ('${rawSourceName}').`); + } + } + + const parsedAmount = parseAmount(amountValue); + if (isNaN(parsedAmount)) throw new Error(`Row ${rowNumber}: Could not parse amount "${amountValue}".`); + + let tempParsedForeignAmount: number | null = null; + if (foreignAmountValue !== undefined && foreignAmountValue.trim() !== "") { + const tempAmount = parseAmount(foreignAmountValue); + if (!Number.isNaN(tempAmount)) { + tempParsedForeignAmount = tempAmount; + } else if (foreignAmountValue.trim() !== '') { + console.warn(`Row ${rowNumber}: Could not parse foreign amount "${foreignAmountValue}". It will be ignored.`); + } + } + const finalParsedForeignAmount = tempParsedForeignAmount; + + let finalParsedForeignCurrency: string | null = null; + if (foreignCurrencyCol && record[foreignCurrencyCol] && record[foreignCurrencyCol]!.trim() !== '') { + finalParsedForeignCurrency = record[foreignCurrencyCol]!.trim().toUpperCase() || null; + } + if (finalParsedForeignCurrency === "") finalParsedForeignCurrency = null; + + const parsedDate = parseDate(dateValue); + const parsedTags = tagsValue.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0); + + let finalDescription = descriptionValue.trim(); + if (notesValue.trim()) { + finalDescription = finalDescription ? `${finalDescription} (Notes: ${notesValue.trim()})` : `Notes: ${notesValue.trim()}`; + } + + if (!finalDescription && csvType === 'withdrawal' && rawDestName) finalDescription = `To: ${rawDestName}`; + if (!finalDescription && csvType === 'deposit' && rawSourceName) finalDescription = `From: ${rawSourceName}`; + if (!finalDescription && csvType === 'transfer') finalDescription = `Transfer: ${rawSourceName} to ${rawDestName}`; + if (!finalDescription) finalDescription = 'Imported Transaction'; + + if (csvType === 'opening balance') { + let actualAccountNameForOB: string | undefined; + let initialBalanceValue = initialBalanceCol ? record[initialBalanceCol] : amountValue; + let parsedInitialBalance = parseAmount(initialBalanceValue); + + if(isNaN(parsedInitialBalance)) { + throw new Error(`Row ${rowNumber}: Could not parse initial balance for 'opening balance'. Value was: '${initialBalanceValue}'.`) + } + + if (rawDestType === "asset account" && rawDestName) { + actualAccountNameForOB = rawDestName; + } else if (rawSourceType === "asset account" && rawSourceName) { + actualAccountNameForOB = rawSourceName; + } else { + const parsedFromNameInDest = parseNameFromDescriptiveString(rawDestName); + const parsedFromNameInSource = parseNameFromDescriptiveString(rawSourceName); + + if (parsedFromNameInDest) actualAccountNameForOB = parsedFromNameInDest; + else if (parsedFromNameInSource) actualAccountNameForOB = parsedFromNameInSource; + else actualAccountNameForOB = rawDestName || rawSourceName; + } + + if (!actualAccountNameForOB) { + throw new Error(`Row ${rowNumber}: Could not determine account name for 'opening balance'. Source: ${rawSourceName}, Dest: ${rawDestName}, SourceType: ${rawSourceType}, DestType: ${rawDestType}`); + } + + return { + csvRawSourceName: rawSourceName ?? null, + csvRawDestinationName: actualAccountNameForOB ?? null, + csvTransactionType: csvType, + csvSourceType: rawSourceType, + csvDestinationType: rawDestType, + date: parsedDate, + amount: parsedInitialBalance, + currency: currencyValue.trim().toUpperCase(), + foreignAmount: finalParsedForeignAmount, + foreignCurrency: finalParsedForeignCurrency, + description: `Opening Balance: ${actualAccountNameForOB}`, + category: 'Opening Balance', + tags: [], + originalRecord: sanitizedRecord, + importStatus: 'skipped', + errorMessage: `Opening Balance for ${actualAccountNameForOB} (${formatCurrency(parsedInitialBalance, currencyValue.trim().toUpperCase(), undefined, false)}) - Will be set as initial balance during account creation/update.`, + appSourceAccountId: null, + appDestinationAccountId: null, + originalImportData: { foreignAmount: finalParsedForeignAmount, foreignCurrency: finalParsedForeignCurrency }, + }; + } + + return { + csvRawSourceName: rawSourceName ?? null, + csvRawDestinationName: rawDestName ?? null, + csvTransactionType: csvType ?? null, + csvSourceType: rawSourceType, + csvDestinationType: rawDestType, + date: parsedDate, + amount: parsedAmount, + currency: currencyValue.trim().toUpperCase(), + foreignAmount: finalParsedForeignAmount, + foreignCurrency: finalParsedForeignCurrency, + description: finalDescription, + category: categoryValue.trim(), + tags: parsedTags, + originalRecord: sanitizedRecord, + importStatus: 'pending', + errorMessage: null, + appSourceAccountId: null, + appDestinationAccountId: null, + originalImportData: { foreignAmount: finalParsedForeignAmount, foreignCurrency: finalParsedForeignCurrency }, + }; + + } catch (rowError: any) { + console.error(`Error processing row ${index + 2} with mappings:`, confirmedMappings, `and record:`, record, `Error:`, rowError); + const errorSanitizedRecord: Record = {}; + for (const key in record) { + if (Object.prototype.hasOwnProperty.call(record, key)) { + errorSanitizedRecord[key] = record[key] === undefined ? null : record[key]!; + } + } + return { + date: parseDate(record[dateCol!]), + amount: 0, + currency: record[currencyCol!]?.trim().toUpperCase() || 'N/A', + description: `Error Processing Row ${index + 2}`, + category: 'Uncategorized', + tags: [], + originalRecord: errorSanitizedRecord, + importStatus: 'error', + errorMessage: rowError.message || 'Failed to process row.', + csvRawSourceName: (sourceNameCol && record[sourceNameCol] ? record[sourceNameCol]?.trim() : undefined) ?? null, + csvRawDestinationName: (destNameCol && record[destNameCol] ? record[destNameCol]?.trim() : undefined) ?? null, + csvTransactionType: (typeCol && record[typeCol] ? record[typeCol]?.trim().toLowerCase() : undefined) ?? null, + csvSourceType: (sourceTypeCol && record[sourceTypeCol] ? record[sourceTypeCol]?.trim().toLowerCase() : undefined) ?? null, + csvDestinationType: (destTypeCol && record[destTypeCol] ? record[destTypeCol]?.trim().toLowerCase() : undefined) ?? null, + foreignAmount: null, + foreignCurrency: null, + appSourceAccountId: null, + appDestinationAccountId: null, + originalImportData: { foreignAmount: null, foreignCurrency: null }, + }; + } + }); + + const errorMappedData = mapped.filter(item => item.importStatus === 'error'); + if (errorMappedData.length > 0) { + setError(`${errorMappedData.length} row(s) had processing errors. Review the tables below.`); + } else { + setError(null); + } + + setParsedData(mapped); + setIsLoading(false); + setIsMappingDialogOpen(false); + toast({ title: "Mapping Applied", description: `Previewing ${mapped.filter(m => m.importStatus === 'pending' || m.csvTransactionType === 'opening balance').length} data points and account changes. Review before importing.` }); + } + + + const previewAccountChanges = async ( + csvData: CsvRecord[], + mappings: ColumnMapping, + existingAccountsParam: Account[] + ): Promise<{ preview: AccountPreview[] }> => { + + const mappedTransactions = csvData.map(record => { + const type = record[mappings.transaction_type!]?.trim().toLowerCase(); + const sourceName = record[mappings.source_name!]?.trim(); + const destName = record[mappings.destination_name!]?.trim(); + const sourceType = record[mappings.source_type!]?.trim().toLowerCase(); + const destType = record[mappings.destination_type!]?.trim().toLowerCase(); + const currency = record[mappings.currency_code!]?.trim().toUpperCase(); + const amount = parseAmount(record[mappings.amount!]); + const initialBalance = parseAmount(record[mappings.initialBalance!] || record[mappings.amount!]); + + return { + csvTransactionType: type, + csvRawSourceName: sourceName, + csvRawDestinationName: destName, + csvSourceType: sourceType, + csvDestinationType: destType, + currency: currency, + amount: type === 'opening balance' ? initialBalance : amount, + }; + }) as Partial[]; + + const accountDetailsMap = await buildAccountUpdateMap(mappedTransactions, existingAccountsParam); + + const preview: AccountPreview[] = []; + const processedAccountNames = new Set(); + + accountDetailsMap.forEach((details, normalizedName) => { + const existingAccount = existingAccountsParam.find(acc => acc.name.toLowerCase() === normalizedName); + let action: AccountPreview['action'] = 'no change'; + let finalBalance = details.initialBalance !== undefined ? details.initialBalance : (existingAccount?.balance ?? 0); + + if (existingAccount) { + if (details.currency !== existingAccount.currency || (details.initialBalance !== undefined && details.initialBalance !== existingAccount.balance)) { + action = 'update'; + } + preview.push({ + name: details.name, + currency: details.currency, + initialBalance: finalBalance, + action: action, + existingId: existingAccount.id, + category: existingAccount.category, + }); + } else { + preview.push({ + name: details.name, + currency: details.currency, + initialBalance: finalBalance, + action: 'create', + category: details.category || 'asset', + }); + } + processedAccountNames.add(normalizedName); + }); + + existingAccountsParam.forEach(acc => { + if (!processedAccountNames.has(acc.name.toLowerCase())) { + preview.push({ + name: acc.name, + currency: acc.currency, + initialBalance: acc.balance, + action: 'no change', + existingId: acc.id, + category: acc.category, + }); + } + }); + return { preview }; + }; + + + const buildAccountUpdateMap = async ( + mappedCsvData: Partial[], + existingAccountsParam: Account[] + ): Promise> => { + const accountMap = new Map(); + + for (const item of mappedCsvData) { + if (item.csvTransactionType === 'opening balance') { + let accountNameForOB: string | undefined; + const recordCurrency = item.currency; + const recordAmount = item.amount; + + const descriptiveDestName = item.csvRawDestinationName; + const descriptiveSourceName = item.csvRawSourceName; + + const parsedNameFromDest = parseNameFromDescriptiveString(descriptiveDestName); + const parsedNameFromSource = parseNameFromDescriptiveString(descriptiveSourceName); + + if (item.csvDestinationType === "asset account" && descriptiveDestName && !parseNameFromDescriptiveString(descriptiveDestName) ) { + accountNameForOB = descriptiveDestName; + } else if (item.csvSourceType === "asset account" && descriptiveSourceName && !parseNameFromDescriptiveString(descriptiveSourceName)) { + accountNameForOB = descriptiveSourceName; + } else if (parsedNameFromDest) { + accountNameForOB = parsedNameFromDest; + } else if (parsedNameFromSource) { + accountNameForOB = parsedNameFromSource; + } else { + accountNameForOB = descriptiveDestName || descriptiveSourceName; + } + + if (accountNameForOB && recordCurrency && recordAmount !== undefined && !isNaN(recordAmount)) { + const normalizedName = accountNameForOB.toLowerCase().trim(); + const existingDetailsInMap = accountMap.get(normalizedName); + let accountCategory: 'asset' | 'crypto' = 'asset'; + + const sourceIsDescriptive = item.csvRawSourceName && parseNameFromDescriptiveString(item.csvRawSourceName); + const destIsDescriptive = item.csvRawDestinationName && parseNameFromDescriptiveString(item.csvRawDestinationName); + + if (item.csvDestinationType === "asset account" && !destIsDescriptive && item.csvRawDestinationName?.toLowerCase().includes('crypto')) { + accountCategory = 'crypto'; + } else if (item.csvSourceType === "asset account" && !sourceIsDescriptive && item.csvRawSourceName?.toLowerCase().includes('crypto')) { + accountCategory = 'crypto'; + } else if (item.csvDestinationType?.includes('crypto') || item.csvSourceType?.includes('crypto') || accountNameForOB.toLowerCase().includes('crypto') || accountNameForOB.toLowerCase().includes('wallet')) { + accountCategory = 'crypto'; + } + + accountMap.set(normalizedName, { + name: accountNameForOB, + currency: recordCurrency, + initialBalance: recordAmount, + category: existingDetailsInMap?.category || accountCategory, + }); + } + } else if (item.csvTransactionType === 'withdrawal' || item.csvTransactionType === 'deposit' || item.csvTransactionType === 'transfer') { + const accountsToConsiderRaw: {name?: string | null, type?: string | null, currency?: string}[] = []; + + if (item.csvRawSourceName && (item.csvSourceType === 'asset account' || item.csvSourceType === 'default asset account')) { + accountsToConsiderRaw.push({name: item.csvRawSourceName, type: item.csvSourceType, currency: item.currency}); + } + if (item.csvRawDestinationName && (item.csvDestinationType === 'asset account' || item.csvDestinationType === 'default asset account')) { + const destCurrency = (item.csvTransactionType === 'transfer' && item.foreignCurrency) ? item.foreignCurrency : item.currency; + accountsToConsiderRaw.push({name: item.csvRawDestinationName, type: item.csvDestinationType, currency: destCurrency}); + } + + const uniqueAccountNamesAndCurrencies = accountsToConsiderRaw.filter( + (value, index, self) => value.name && self.findIndex(t => t.name?.toLowerCase().trim() === value.name?.toLowerCase().trim()) === index + ); + + for (const accInfo of uniqueAccountNamesAndCurrencies) { + if (accInfo.name && accInfo.currency) { + const normalizedName = accInfo.name.toLowerCase().trim(); + let category: 'asset' | 'crypto' = 'asset'; + if (accInfo.name.toLowerCase().includes('crypto') || + accInfo.name.toLowerCase().includes('wallet') || + (accInfo.type && accInfo.type.includes('crypto')) ) { + category = 'crypto'; + } + + if (!accountMap.has(normalizedName)) { + const existingAppAccount = existingAccountsParam.find(a => a.name.toLowerCase() === normalizedName); + accountMap.set(normalizedName, { + name: accInfo.name, + currency: existingAppAccount?.currency || accInfo.currency, + initialBalance: existingAppAccount?.balance, + category: existingAppAccount?.category || category, + }); + } else { + const currentDetails = accountMap.get(normalizedName)!; + if (!currentDetails.currency && accInfo.currency) currentDetails.currency = accInfo.currency; + if (currentDetails.category === 'asset' && category === 'crypto') { + currentDetails.category = 'crypto'; + } + } + } + } + } + } + return accountMap; + }; + + const createOrUpdateAccountsAndGetMap = async ( + isPreviewOnly: boolean = false + ): Promise<{ success: boolean; map: { [key: string]: string }, updatedAccountsList: Account[] }> => { + let success = true; + let currentAppAccounts = [...accounts]; + + const workingMap = currentAppAccounts.reduce((map, acc) => { + map[acc.name.toLowerCase().trim()] = acc.id; + return map; + }, {} as { [key: string]: string }); + + if (accountPreviewData.length === 0 && !isPreviewOnly) { + return { success: true, map: workingMap, updatedAccountsList: currentAppAccounts }; + } + + let accountsProcessedCount = 0; + + for (const accPreview of accountPreviewData) { + const normalizedName = accPreview.name.toLowerCase().trim(); + try { + if (isPreviewOnly) { + if (accPreview.existingId) { + workingMap[normalizedName] = accPreview.existingId; + } else if (accPreview.action === 'create') { + workingMap[normalizedName] = `preview_create_${normalizedName.replace(/\s+/g, '_')}`; + } + continue; + } + + if (accPreview.action === 'create') { + const newAccountData: NewAccountData = { + name: accPreview.name, + type: (accPreview.category === 'crypto' ? 'wallet' : 'checking'), + balance: accPreview.initialBalance, + currency: accPreview.currency, + providerName: 'Imported - ' + accPreview.name, + category: accPreview.category, + isActive: true, + lastActivity: new Date().toISOString(), + balanceDifference: 0, + includeInNetWorth: true, + }; + const createdAccount = await addAccount(newAccountData); + workingMap[normalizedName] = createdAccount.id; + currentAppAccounts.push(createdAccount); + accountsProcessedCount++; + } else if (accPreview.action === 'update' && accPreview.existingId) { + const existingAccountForUpdate = currentAppAccounts.find(a => a.id === accPreview.existingId); + if (existingAccountForUpdate) { + const updatedAccountData: Account = { + ...existingAccountForUpdate, + balance: accPreview.initialBalance, + currency: accPreview.currency, + lastActivity: new Date().toISOString(), + category: accPreview.category, + includeInNetWorth: existingAccountForUpdate.includeInNetWorth ?? true, + }; + const savedUpdatedAccount = await updateAccount(updatedAccountData); + accountsProcessedCount++; + const idx = currentAppAccounts.findIndex(a => a.id === savedUpdatedAccount.id); + if (idx !== -1) currentAppAccounts[idx] = savedUpdatedAccount; + else currentAppAccounts.push(savedUpdatedAccount); + + workingMap[normalizedName] = savedUpdatedAccount.id; + } + } else if (accPreview.existingId) { + workingMap[normalizedName] = accPreview.existingId; + } + + } catch (err: any) { + console.error(`Failed to process account "${accPreview.name}":`, err); + toast({ title: "Account Processing Error", description: `Could not process account "${accPreview.name}". Error: ${err.message}`, variant: "destructive", duration: 7000 }); + success = false; + } + } + + if (accountsProcessedCount > 0 && !isPreviewOnly) { + toast({ title: "Accounts Processed", description: `Created or updated ${accountsProcessedCount} accounts based on CSV data.` }); + } + + if (!isPreviewOnly && accountsProcessedCount > 0) { + const finalFetchedAccounts = await getAccounts(); + setAccounts(finalFetchedAccounts); + const finalMap = finalFetchedAccounts.reduce((map, acc) => { + map[acc.name.toLowerCase().trim()] = acc.id; + return map; + }, {} as { [key: string]: string }); + return { success, map: finalMap, updatedAccountsList: finalFetchedAccounts }; + } + + return { success, map: workingMap, updatedAccountsList: currentAppAccounts }; + }; + + + const addMissingCategoriesAndTags = async (transactionsToProcess: MappedTransaction[]): Promise => { + const currentCategoriesList = await getCategories(); + const existingCategoryNames = new Set(currentCategoriesList.map(cat => cat.name.toLowerCase())); + const categoriesToAdd = new Set(); + + const currentTagsList = await getTags(); + const existingTagNames = new Set(currentTagsList.map(tag => tag.name.toLowerCase())); + const tagsToAdd = new Set(); + + let success = true; + + transactionsToProcess.forEach(tx => { + if (tx.importStatus === 'pending') { + if (tx.category && !['Uncategorized', 'Initial Balance', 'Transfer', 'Skipped', 'Opening Balance'].includes(tx.category)) { + const categoryName = tx.category.trim(); + if (categoryName && !existingCategoryNames.has(categoryName.toLowerCase())) { + categoriesToAdd.add(categoryName); + } + } + + if (tx.tags && tx.tags.length > 0) { + tx.tags.forEach(tagName => { + const trimmedTag = tagName.trim(); + if (trimmedTag && !existingTagNames.has(trimmedTag.toLowerCase())) { + tagsToAdd.add(trimmedTag); + } + }); + } + } + }); + + if (categoriesToAdd.size > 0) { + let categoriesAddedCount = 0; + const addCatPromises = Array.from(categoriesToAdd).map(async (catName) => { + try { + await addCategoryToDb(catName); + categoriesAddedCount++; + } catch (err: any) { + if (!err.message?.includes('already exists')) { + console.error(`Failed to add category "${catName}":`, err); + toast({ title: "Category Add Error", description: `Could not add category "${catName}". Error: ${err.message}`, variant: "destructive" }); + success = false; + } + } + }); + await Promise.all(addCatPromises); + if (categoriesAddedCount > 0) { + toast({ title: "Categories Added", description: `Added ${categoriesAddedCount} new categories.` }); + try { setCategories(await getCategories()); } catch { console.error("Failed to refetch categories after add."); } + } + } + + if (tagsToAdd.size > 0) { + let tagsAddedCount = 0; + const addTagPromises = Array.from(tagsToAdd).map(async (tagName) => { + try { + await addTagToDb(tagName); + tagsAddedCount++; + } catch (err: any) { + if (!err.message?.includes('already exists')) { + console.error(`Failed to add tag "${tagName}":`, err); + toast({ title: "Tag Add Error", description: `Could not add tag "${tagName}". Error: ${err.message}`, variant: "destructive" }); + success = false; + } + } + }); + await Promise.all(addTagPromises); + if (tagsAddedCount > 0) { + toast({ title: "Tags Added", description: `Added ${tagsAddedCount} new tags.` }); + try { setTags(await getTags()); } catch { console.error("Failed to refetch tags after add."); } + } + } + return success; + }; + + + const handleImport = async () => { + if (!user) { + toast({ title: "Authentication Required", description: "Please log in to import.", variant: "destructive" }); + return; + } + const recordsToImport = parsedData.filter(item => item.importStatus === 'pending'); + if (recordsToImport.length === 0) { + setError(parsedData.some(d => d.importStatus === 'error' || d.importStatus === 'skipped') ? "No pending records to import. Check rows marked as 'Error' or 'Skipped'." : "No data parsed or mapped correctly for import."); + toast({ title: "Import Info", description: "No pending transactions to import.", variant: "default" }); + return; + } + + setIsLoading(true); + setImportProgress(0); + setError(null); + let overallError = false; + + let finalMapForTxImport: { [key: string]: string }; + let latestAccountsList: Account[]; + try { + const accountMapResult = await createOrUpdateAccountsAndGetMap(false); + if (!accountMapResult.success) { + setError("Error processing some accounts during import. Some accounts might not have been created/updated correctly. Review account preview and transaction statuses."); + } + finalMapForTxImport = accountMapResult.map; + latestAccountsList = accountMapResult.updatedAccountsList; + setAccounts(latestAccountsList); + setFinalAccountMapForImport(finalMapForTxImport); + } catch (finalAccountMapError) { + console.error("Critical error during account finalization before import.", finalAccountMapError); + toast({ title: "Account Sync Error", description: "Could not synchronize accounts with the database before starting transaction import. Please try again.", variant: "destructive"}); + setIsLoading(false); + return; + } + + const metadataSuccess = await addMissingCategoriesAndTags(recordsToImport); + if (!metadataSuccess) { + setError("Error adding new categories or tags from CSV. Some transactions might use 'Uncategorized' or miss tags. Import halted to ensure data integrity."); + setIsLoading(false); + return; + } + + const currentCategoriesList = await getCategories(); + const currentTagsList = await getTags(); + + const totalToImport = recordsToImport.length; + let importedCount = 0; + let errorCount = 0; + const updatedDataForDisplay = [...parsedData]; + + const transactionPayloads: (Omit & { originalMappedTx: MappedTransaction })[] = []; + + for (const item of recordsToImport) { + const rowNumber = rawData.indexOf(item.originalRecord) + 2; + const itemIndexInDisplay = updatedDataForDisplay.findIndex(d => d.originalRecord === item.originalRecord && d.description === item.description && d.amount === item.amount && d.date === item.date); + + if (item.csvTransactionType === 'opening balance') { + if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'skipped', errorMessage: 'Opening Balance (handled via account balance creation/update)' }; + continue; + } + + const transactionCategory = currentCategoriesList.find(c => c.name.toLowerCase() === item.category.toLowerCase())?.name || 'Uncategorized'; + const transactionTags = item.tags?.map(tName => currentTagsList.find(t => t.name.toLowerCase() === tName.toLowerCase())?.name || tName).filter(Boolean) || []; + + if (item.csvTransactionType === 'transfer') { + const csvAmount = item.amount; + const csvCurrency = item.currency; + const csvForeignAmount = item.originalImportData?.foreignAmount; + const csvForeignCurrency = item.originalImportData?.foreignCurrency; + + const csvSourceName = item.csvRawSourceName; + const csvDestName = item.csvRawDestinationName; + + if (!csvSourceName || !csvDestName) { + if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Firefly 'transfer' type row missing source or destination name in CSV.` }; + errorCount++; overallError = true; continue; + } + + const fromAccountId = finalMapForTxImport[csvSourceName.toLowerCase().trim()]; + const toAccountId = finalMapForTxImport[csvDestName.toLowerCase().trim()]; + + if (!fromAccountId || fromAccountId.startsWith('preview_') || fromAccountId.startsWith('error_') || fromAccountId.startsWith('skipped_')) { + if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Invalid source account ID for transfer leg account "${csvSourceName}". Mapped ID: ${fromAccountId}.` }; + errorCount++; overallError = true; continue; + } + if (!toAccountId || toAccountId.startsWith('preview_') || toAccountId.startsWith('error_') || toAccountId.startsWith('skipped_')) { + if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Invalid destination account ID for transfer leg account "${csvDestName}". Mapped ID: ${toAccountId}.` }; + errorCount++; overallError = true; continue; + } + + const fromAccountDetails = latestAccountsList.find(a => a.id === fromAccountId); + const toAccountDetails = latestAccountsList.find(a => a.id === toAccountId); + if (!fromAccountDetails || !toAccountDetails) { + if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Could not find account details for transfer.` }; + errorCount++; overallError = true; continue; + } + + const transferDesc = item.description || `Transfer from ${fromAccountDetails.name} to ${toAccountDetails.name}`; + + let debitAmount = -Math.abs(csvAmount); + let debitCurrency = csvCurrency; + let creditAmount = Math.abs(csvAmount); + let creditCurrency = csvCurrency; + + if (csvForeignAmount != null && csvForeignCurrency && csvForeignCurrency.trim() !== '') { + creditAmount = Math.abs(csvForeignAmount); + creditCurrency = csvForeignCurrency; + } + + transactionPayloads.push({ + accountId: fromAccountId, + date: item.date, + amount: debitAmount, + transactionCurrency: debitCurrency, + description: transferDesc, + category: 'Transfer', + tags: transactionTags, + originalMappedTx: item, + originalImportData: item.originalImportData, + }); + transactionPayloads.push({ + accountId: toAccountId, + date: item.date, + amount: creditAmount, + transactionCurrency: creditCurrency, + description: transferDesc, + category: 'Transfer', + tags: transactionTags, + originalMappedTx: item, + originalImportData: item.originalImportData, + }); + + } else if (item.csvTransactionType === 'withdrawal' || item.csvTransactionType === 'deposit') { + let accountNameForTx: string | undefined | null; + let accountIdForTx: string | undefined; + let payloadAmount = item.amount; + let payloadCurrency = item.currency; + + if (item.csvTransactionType === 'withdrawal') { + accountNameForTx = item.csvRawSourceName; + if (item.csvSourceType !== 'asset account' && item.csvSourceType !== 'default asset account') { + if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Withdrawal from non-asset account type '${item.csvSourceType}' for source '${accountNameForTx}'. Skipping.` }; + errorCount++; overallError = true; continue; + } + } else { + accountNameForTx = item.csvRawDestinationName; + if (item.csvDestinationType !== 'asset account' && item.csvDestinationType !== 'default asset account') { + if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Deposit to non-asset account type '${item.csvDestinationType}' for destination '${accountNameForTx}'. Skipping.` }; + errorCount++; overallError = true; continue; + } + } + + if (!accountNameForTx) { + if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Could not determine asset account for ${item.csvTransactionType}. Source: ${item.csvRawSourceName}, Dest: ${item.csvRawDestinationName}` }; + errorCount++; overallError = true; continue; + } + + accountIdForTx = finalMapForTxImport[accountNameForTx.toLowerCase().trim()]; + if (!accountIdForTx || accountIdForTx.startsWith('preview_') || accountIdForTx.startsWith('error_') || accountIdForTx.startsWith('skipped_')) { + if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Could not find valid account ID for "${accountNameForTx}". Mapped ID: ${accountIdForTx}.` }; + errorCount++; overallError = true; continue; + } + + if (Number.isNaN(payloadAmount)) { + if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Invalid amount for import.` }; + errorCount++; overallError = true; continue; + } + + transactionPayloads.push({ + accountId: accountIdForTx, + date: item.date, + amount: payloadAmount, + transactionCurrency: payloadCurrency, + description: item.description, + category: transactionCategory, + tags: transactionTags, + originalMappedTx: item, + originalImportData: item.originalImportData, + }); + } else { + if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Unknown Firefly transaction type "${item.csvTransactionType}". Supported: withdrawal, deposit, transfer, opening balance.` }; + errorCount++; overallError = true; + } + } + + transactionPayloads.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + + for (const payload of transactionPayloads) { + const itemIndexInDisplay = updatedDataForDisplay.findIndex(d => + d.originalRecord === payload.originalMappedTx.originalRecord && + d.description === payload.originalMappedTx.description && + d.amount === payload.originalMappedTx.amount && + d.date === payload.originalMappedTx.date && + d.importStatus !== 'success' + ); + try { + await addTransaction({ + accountId: payload.accountId, + date: payload.date, + amount: payload.amount, + transactionCurrency: payload.transactionCurrency, + description: payload.description, + category: payload.category, + tags: payload.tags, + originalImportData: payload.originalImportData, + }); + if(itemIndexInDisplay !== -1) { + updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'success', errorMessage: undefined }; + } + importedCount++; + } catch (err: any) { + console.error(`Failed to import transaction for original row:`, payload.originalMappedTx.originalRecord, err); + if(itemIndexInDisplay !== -1 && updatedDataForDisplay[itemIndexInDisplay].importStatus !== 'success') { + updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: err.message || 'Unknown import error' }; + } + errorCount++; + overallError = true; + } + setImportProgress(calculateProgress(importedCount + errorCount, transactionPayloads.length )); + setParsedData([...updatedDataForDisplay]); + } + + setIsLoading(false); + const finalMessage = `Import finished. Successfully processed transaction entries: ${importedCount}. Failed/Skipped rows (from preview): ${errorCount + parsedData.filter(d => d.importStatus === 'skipped').length}.`; + toast({ + title: overallError ? "Import Complete with Issues" : "Import Complete", + description: finalMessage, + variant: overallError ? "destructive" : "default", + duration: 7000, + }); + + if (overallError) { + setError(`Import finished with ${errorCount} transaction errors. Please review the table for details.`); + } else { + setError(null); + window.dispatchEvent(new Event('storage')); + } + setAccounts(await getAccounts()); + }; + + + const calculateProgress = (processed: number, total: number): number => { + if (total === 0) return 0; + return Math.round((processed / total) * 100); + } + + const handleClearData = async () => { + if (!user) { + toast({ title: "Not Authenticated", description: "Please log in to clear data.", variant: "destructive" }); + return; + } + setIsClearing(true); + try { + await clearAllSessionTransactions(); + + setAccounts([]); + setCategories([]); + setTags([]); + setParsedData([]); + setAccountPreviewData([]); + setError(null); + setRawData([]); + setFile(null); + setColumnMappings({}); + setImportProgress(0); + setFinalAccountMapForImport({}); + + const fileInput = document.getElementById('csv-file') as HTMLInputElement; + if (fileInput) fileInput.value = ''; + + toast({ title: "Data Cleared", description: "All user data (accounts, categories, tags, groups, subscriptions, transactions) has been removed." }); + window.dispatchEvent(new Event('storage')); + } catch (err) { + console.error("Failed to clear data:", err); + toast({ title: "Error", description: "Could not clear stored data.", variant: "destructive" }); + } finally { + setIsClearing(false); + } + }; + + + const handleTransactionFieldChange = ( + originalIndexInData: number, + field: 'description' | 'category' | 'tags' | 'amount' | 'date' | 'currency', + value: string + ) => { + setParsedData(prevData => { + const newData = [...prevData]; + let transactionToUpdate = { ...newData[originalIndexInData] }; + + if (transactionToUpdate.importStatus !== 'pending') { + toast({ title: "Edit Blocked", description: "Cannot edit transactions that are not pending import.", variant: "destructive" }); + return prevData; + } + + switch (field) { + case 'description': + transactionToUpdate.description = value; + break; + case 'category': + transactionToUpdate.category = value; + break; + case 'tags': + transactionToUpdate.tags = value.split(',').map(tag => tag.trim()).filter(Boolean); + break; + case 'amount': + const parsedAmount = parseFloat(value); + if (!isNaN(parsedAmount)) { + transactionToUpdate.amount = parsedAmount; + } else { + toast({ title: "Invalid Amount", description: "Amount not updated. Please enter a valid number.", variant: "destructive" }); + return prevData; + } + break; + case 'date': + if (value && /^\d{4}-\d{2}-\d{2}$/.test(value)) { + transactionToUpdate.date = value; + } else if (value) { + try { + const d = new Date(value); + if (!isNaN(d.getTime())) { + transactionToUpdate.date = format(d, 'yyyy-MM-dd'); + } else { throw new Error("Invalid date object"); } + } catch { + toast({ title: "Invalid Date", description: "Date not updated. Please use YYYY-MM-DD format or select a valid date.", variant: "destructive" }); + return prevData; + } + } else { + toast({ title: "Invalid Date", description: "Date not updated. Please select a valid date.", variant: "destructive" }); + return prevData; + } + break; + case 'currency': + if (supportedCurrencies.includes(value.toUpperCase())) { + transactionToUpdate.currency = value.toUpperCase(); + } else { + toast({ title: "Invalid Currency", description: `Currency ${value} not supported.`, variant: "destructive"}); + return prevData; + } + break; + default: + return prevData; + } + newData[originalIndexInData] = transactionToUpdate; + return newData; + }); + }; + + + const groupedTransactionsForPreview = useMemo(() => { + if (!parsedData || parsedData.length === 0) return {}; + const grouped: { [accountDisplayName: string]: MappedTransaction[] } = {}; + + const getDisplayableAccountName = (csvName?: string | null, csvType?: string | null): string => { + if (!csvName) return "Unknown / External"; + const lowerCsvName = csvName.toLowerCase().trim(); + const lowerCsvType = csvType?.toLowerCase().trim(); + + if (lowerCsvType && (lowerCsvType.includes('revenue account') || lowerCsvType.includes('expense account'))) { + return `${csvName} (External)`; + } + + const accountId = finalAccountMapForImport[lowerCsvName]; + if (accountId) { + const appAccount = accounts.find(acc => acc.id === accountId); + if (appAccount) return appAccount.name; + } + const previewAccount = accountPreviewData.find(ap => ap.name.toLowerCase().trim() === lowerCsvName); + if (previewAccount) return previewAccount.name; + + return csvName; + }; + + parsedData.forEach(item => { + let accountKeyForGrouping = "Unknown / Skipped / Error"; + let accountDisplayName = "Unknown / Skipped / Error"; + + if (item.importStatus === 'error' || item.importStatus === 'skipped') { + accountDisplayName = item.errorMessage?.includes("Opening Balance") + ? `Account Balance Update: ${getDisplayableAccountName(item.csvRawDestinationName || item.csvRawSourceName, item.csvDestinationType || item.csvSourceType)}` + : `Errors / Skipped Transactions`; + accountKeyForGrouping = `system-${item.importStatus}-${item.errorMessage?.substring(0,20) || 'general'}`; + } else if (item.csvTransactionType === 'transfer') { + const sourceName = getDisplayableAccountName(item.csvRawSourceName, item.csvSourceType); + const destName = getDisplayableAccountName(item.csvRawDestinationName, item.csvDestinationType); + accountDisplayName = `Transfer: ${sourceName} -> ${destName}`; + accountKeyForGrouping = `transfer-${sourceName}-${destName}`; + + } else if (item.csvTransactionType === 'withdrawal') { + accountDisplayName = getDisplayableAccountName(item.csvRawSourceName, item.csvSourceType); + accountKeyForGrouping = `account-${accountDisplayName}-withdrawal`; + } else if (item.csvTransactionType === 'deposit') { + accountDisplayName = getDisplayableAccountName(item.csvRawDestinationName, item.csvDestinationType); + accountKeyForGrouping = `account-${accountDisplayName}-deposit`; + } + + if (!grouped[accountKeyForGrouping]) { + grouped[accountKeyForGrouping] = []; + } + (item as any)._accountDisplayNameForGroupHeader = accountDisplayName; + grouped[accountKeyForGrouping].push(item); + }); + + return Object.entries(grouped) + .sort(([keyA], [keyB]) => { + const nameA = (grouped[keyA][0] as any)._accountDisplayNameForGroupHeader || keyA; + const nameB = (grouped[keyB][0] as any)._accountDisplayNameForGroupHeader || keyB; + if (nameA.startsWith("Errors") || nameA.startsWith("Account Balance Update")) return 1; + if (nameB.startsWith("Errors") || nameB.startsWith("Account Balance Update")) return -1; + return nameA.localeCompare(nameB); + }) + .reduce((obj, [key, value]) => { + obj[key] = value; + return obj; + }, {} as typeof grouped); + }, [parsedData, accountPreviewData, finalAccountMapForImport, accounts]); + + + if (isLoadingAuth) { + return

Loading authentication...

; + } + if (!user && !isLoadingAuth) { + return

Please login to manage data.

; + } + + return ( +
+

Data Management

+ + + + Step 1: Upload CSV File + + Select your CSV file. Firefly III export format is best supported. Map columns carefully in the next step. Ensure file is UTF-8 encoded. + + + +
+ + +
+ + {error && ( + + + {error.includes("Issues") || error.includes("Error") || error.includes("Failed") || error.includes("Missing") || error.includes("Critical") ? "Import Problem" : "Info"} + {error} + + )} + +
+ + + + + + + + + + Are you absolutely sure? + + This action cannot be undone. This will permanently delete ALL your accounts, categories, tags, groups, subscriptions and transactions from the database. This is intended for testing or resetting your data. + + + + Cancel + + {isClearing ? "Clearing..." : "Yes, Clear All My Data"} + + + + +
+ + {isLoading && importProgress > 0 && ( + + )} +
+
+ + + + + Step 2: Map CSV Columns + + Match CSV columns (right) to application fields (left). For Firefly III CSVs, ensure 'type', 'amount', 'currency_code', 'date', 'source_name', and 'destination_name' are correctly mapped. + + + setIsMappingDialogOpen(false)} + /> + + + + {accountPreviewData.length > 0 && !isLoading && ( + + + Account Changes Preview + Review accounts to be created or updated. Initial balances are primarily from 'Opening Balance' rows in Firefly III CSVs. + + +
+ + + + Account Name + Action + Currency + Initial/Updated Balance + + + + {accountPreviewData.map((acc, index) => ( + + {acc.name} + {acc.action} + {acc.currency} + + {formatCurrency(acc.initialBalance, acc.currency, undefined, false)} + + + ))} + +
+
+
+
+ )} + + {parsedData.length > 0 && ( + + + Review & Import ({parsedData.filter(i => i.importStatus === 'pending').length} Pending Rows) + Review transactions. Rows marked 'Error' or 'Skipped' (like Opening Balances) won't be imported as transactions. Edit fields if needed. Click "Import Transactions" above when ready. + + + {Object.entries(groupedTransactionsForPreview).map(([accountGroupKey, transactionsInGroup]) => { + const firstTransactionInGroup = transactionsInGroup[0]; + const accountDisplayName = (firstTransactionInGroup as any)._accountDisplayNameForGroupHeader || accountGroupKey; + + return ( +
+

+ {accountDisplayName} +

+
+ + + + Date + CSV Type + Description + Category + Tags + Amount + Currency + Foreign Amt. + Foreign Curr. + Status + Message / Info + + + + {transactionsInGroup.map((item, index) => { + const originalIndex = parsedData.findIndex(pItem => pItem.originalRecord === item.originalRecord && pItem.date === item.date && pItem.description === item.description && pItem.amount === item.amount); + + return ( + + + handleTransactionFieldChange(originalIndex, 'date', e.target.value)} className="h-8 text-xs p-1" /> + + {item.csvTransactionType} + + handleTransactionFieldChange(originalIndex, 'description', e.target.value)} className="h-8 text-xs p-1" /> + + + handleTransactionFieldChange(originalIndex, 'category', e.target.value)} className="h-8 text-xs p-1" /> + + + handleTransactionFieldChange(originalIndex, 'tags', e.target.value)} placeholder="tag1, tag2" className="h-8 text-xs p-1" /> + + + handleTransactionFieldChange(originalIndex, 'amount', e.target.value)} className="h-8 text-xs p-1 text-right" /> + + + handleTransactionFieldChange(originalIndex, 'currency', e.target.value)} className="h-8 text-xs p-1" /> + + + {item.foreignAmount != null ? formatCurrency(item.foreignAmount, item.foreignCurrency || '', item.foreignCurrency || '', false) : '-'} + {item.foreignCurrency || '-'} + {item.importStatus} + {item.errorMessage} + + ); + })} + +
+
+
+ ); + })} +
+
+ )} +
+ ); +} + From 7a9e912a8fe86863e31dfa3a8102905cbd8d562c Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 20 May 2025 10:47:12 +0000 Subject: [PATCH 009/156] Acho que voce nao percebeu que fiz o rollback. Analise o codigo --- src/app/data-management/page.tsx | 11 +- src/app/import/page.tsx | 281 ++++++++++++------------------- 2 files changed, 115 insertions(+), 177 deletions(-) diff --git a/src/app/data-management/page.tsx b/src/app/data-management/page.tsx index ee7d975..70217cc 100644 --- a/src/app/data-management/page.tsx +++ b/src/app/data-management/page.tsx @@ -26,7 +26,7 @@ import { AlertCircle, Trash2, Download } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAuthContext } from '@/contexts/AuthContext'; import Link from 'next/link'; -import { exportAllUserDataToCsvs } from '@/services/export'; // Import the export function +import { exportAllUserDataToCsvs } from '@/services/export'; type CsvRecord = { [key: string]: string | undefined; @@ -180,7 +180,7 @@ export default function DataManagementPage() { const [csvHeaders, setCsvHeaders] = useState([]); const [rawData, setRawData] = useState([]); const [parsedData, setParsedData] = useState([]); - const [isLoading, setIsLoading] = useState(true); // Start with true for initial data load + const [isLoading, setIsLoading] = useState(true); const [importProgress, setImportProgress] = useState(0); const [error, setError] = useState(null); const [accounts, setAccounts] = useState([]); @@ -204,7 +204,7 @@ export default function DataManagementPage() { let isMounted = true; const fetchData = async () => { if (!isMounted) return; - setIsLoading(true); // Set loading true at the start of data fetching + setIsLoading(true); setError(null); try { const [fetchedAccounts, fetchedCategories, fetchedTagsList] = await Promise.all([ @@ -233,7 +233,7 @@ export default function DataManagementPage() { fetchData(); return () => { isMounted = false; }; - }, [user, isLoadingAuth]); // Only depend on user and isLoadingAuth + }, [user, isLoadingAuth]); const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files[0]) { @@ -260,7 +260,7 @@ export default function DataManagementPage() { return; } - setIsLoading(true); // For file parsing specifically + setIsLoading(true); setError(null); setParsedData([]); setAccountPreviewData([]); @@ -1558,4 +1558,3 @@ export default function DataManagementPage() {
); } - diff --git a/src/app/import/page.tsx b/src/app/import/page.tsx index 8e56a77..70217cc 100644 --- a/src/app/import/page.tsx +++ b/src/app/import/page.tsx @@ -1,6 +1,7 @@ + 'use client'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import Papa, { ParseResult } from 'papaparse'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; @@ -21,10 +22,11 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { format, parseISO, isValid, parse as parseDateFns } from 'date-fns'; import { getCurrencySymbol, supportedCurrencies, formatCurrency, convertCurrency } from '@/lib/currency'; import CsvMappingForm, { type ColumnMapping } from '@/components/import/csv-mapping-form'; -import { AlertCircle, Trash2 } from 'lucide-react'; +import { AlertCircle, Trash2, Download } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAuthContext } from '@/contexts/AuthContext'; import Link from 'next/link'; +import { exportAllUserDataToCsvs } from '@/services/export'; type CsvRecord = { [key: string]: string | undefined; @@ -48,19 +50,19 @@ type MappedTransaction = { csvDestinationType?: string | null; date: string; - amount: number; + amount: number; description: string; category: string; - currency: string; - foreignAmount?: number | null; - foreignCurrency?: string | null; + currency: string; + foreignAmount?: number | null; + foreignCurrency?: string | null; tags?: string[]; originalRecord: Record; importStatus: 'pending' | 'success' | 'error' | 'skipped'; errorMessage?: string | null; - appSourceAccountId?: string | null; - appDestinationAccountId?: string | null; + appSourceAccountId?: string | null; + appDestinationAccountId?: string | null; originalImportData?: { foreignAmount?: number | null; foreignCurrency?: string | null; @@ -74,7 +76,7 @@ interface AccountPreview { initialBalance: number; action: 'create' | 'update' | 'no change'; existingId?: string; - category: 'asset' | 'crypto'; + category: 'asset' | 'crypto'; } @@ -101,25 +103,22 @@ const parseAmount = (amountStr: string | undefined): number => { cleaned = cleaned.replace(',', '.'); } - // Handle cases like "1.234.56" by removing all but last dot if it's a decimal separator const dotMatches = cleaned.match(/\./g); if (dotMatches && dotMatches.length > 1) { const lastDotIndex = cleaned.lastIndexOf('.'); const partAfterLastDot = cleaned.substring(lastDotIndex + 1); - if (partAfterLastDot.length < 3 || partAfterLastDot.match(/^\d+$/) ) { // Assume last dot is decimal if few digits follow + if (partAfterLastDot.length < 3 || partAfterLastDot.match(/^\d+$/) ) { cleaned = cleaned.substring(0, lastDotIndex).replace(/\./g, '') + '.' + partAfterLastDot; - } else { // Assume all dots are thousand separators + } else { cleaned = cleaned.replace(/\./g, ''); } } - if (cleaned.endsWith('.') || cleaned.endsWith(',')) { cleaned += '0'; } cleaned = cleaned.replace(/^[,.]+|[,.]+$/g, ''); - const parsed = parseFloat(cleaned); return parsed; }; @@ -128,7 +127,6 @@ const parseAmount = (amountStr: string | undefined): number => { const parseDate = (dateStr: string | undefined): string => { if (!dateStr) return format(new Date(), 'yyyy-MM-dd'); try { - // Prioritize ISO with or without time/timezone let parsedDate = parseISO(dateStr); if (isValid(parsedDate)) { return format(parsedDate, 'yyyy-MM-dd'); @@ -145,11 +143,9 @@ const parseDate = (dateStr: string | undefined): string => { for (const fmt of commonFormats) { try { - // Try parsing with the full format first parsedDate = parseDateFns(dateStr, fmt, new Date()); if (isValid(parsedDate)) return format(parsedDate, 'yyyy-MM-dd'); - // If it has a time component, try parsing just the date part const datePartOnly = dateStr.split('T')[0].split(' ')[0]; const dateFormatOnly = fmt.split('T')[0].split(' ')[0]; if (datePartOnly !== dateStr && dateFormatOnly !== fmt) { @@ -159,7 +155,6 @@ const parseDate = (dateStr: string | undefined): string => { } catch { /* ignore parse error for this format, try next */ } } - // Final attempt with Date constructor as a broad fallback parsedDate = new Date(dateStr); if (isValid(parsedDate)) { return format(parsedDate, 'yyyy-MM-dd'); @@ -174,19 +169,18 @@ const parseDate = (dateStr: string | undefined): string => { const parseNameFromDescriptiveString = (text: string | undefined): string | undefined => { if (!text) return undefined; - // Matches "Account Name" from 'Initial balance for "Account Name"' or 'Saldo inicial para "Account Name"' or 'Saldo inicial da conta Account Name' const match = text.match(/(?:Initial balance for |Saldo inicial para(?: d[aeo] conta)?)\s*["']?([^"':]+)(?:["']?|$)/i); return match ? match[1]?.trim() : undefined; }; -export default function ImportDataPage() { +export default function DataManagementPage() { const { user, isLoadingAuth } = useAuthContext(); const [file, setFile] = useState(null); const [csvHeaders, setCsvHeaders] = useState([]); const [rawData, setRawData] = useState([]); const [parsedData, setParsedData] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [importProgress, setImportProgress] = useState(0); const [error, setError] = useState(null); const [accounts, setAccounts] = useState([]); @@ -197,18 +191,23 @@ export default function ImportDataPage() { const [isMappingDialogOpen, setIsMappingDialogOpen] = useState(false); const [columnMappings, setColumnMappings] = useState({}); const [isClearing, setIsClearing] = useState(false); + const [isExporting, setIsExporting] = useState(false); const { toast } = useToast(); useEffect(() => { - if (isLoadingAuth || !user) return; + if (isLoadingAuth || !user) { + setIsLoading(false); + return; + } let isMounted = true; const fetchData = async () => { - if (isMounted) setIsLoading(true); - if (isMounted) setError(null); + if (!isMounted) return; + setIsLoading(true); + setError(null); try { - const [fetchedAccounts, fetchedCategories, fetchedTags] = await Promise.all([ + const [fetchedAccounts, fetchedCategories, fetchedTagsList] = await Promise.all([ getAccounts(), getCategories(), getTags() @@ -217,22 +216,24 @@ export default function ImportDataPage() { if (isMounted) { setAccounts(fetchedAccounts); setCategories(fetchedCategories); - setTags(fetchedTags); + setTags(fetchedTagsList); } - } catch (err) { - console.error("Failed to fetch initial data for import:", err); + } catch (err: any) { + console.error("Failed to fetch initial data for Data Management page:", err); if (isMounted) { - setError("Could not load accounts, categories, or tags from database."); - toast({ title: "Initialization Error", description: "Failed to load data from database.", variant: "destructive" }); + setError("Could not load essential page data. Please try refreshing. Details: " + err.message); + toast({ title: "Page Load Error", description: "Failed to load initial data (accounts, categories, or tags). " + err.message, variant: "destructive" }); } } finally { - if (isMounted) setIsLoading(false); + if (isMounted) { + setIsLoading(false); + } } }; fetchData(); return () => { isMounted = false; }; - }, [toast, user, isLoadingAuth]); + }, [user, isLoadingAuth]); const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files[0]) { @@ -259,7 +260,7 @@ export default function ImportDataPage() { return; } - setIsLoading(true); + setIsLoading(true); setError(null); setParsedData([]); setAccountPreviewData([]); @@ -281,7 +282,6 @@ export default function ImportDataPage() { toast({ title: "CSV Parsing Warning", description: `Some rows might have issues: ${results.errors.map(e=>e.message).slice(0,2).join('; ')}`, variant:"default", duration: 7000}); } - if (!results.data || results.data.length === 0) { setError("CSV file is empty or doesn't contain valid data rows."); setIsLoading(false); @@ -337,13 +337,11 @@ export default function ImportDataPage() { setColumnMappings(confirmedMappings); setFinalAccountMapForImport({}); - const coreRequiredFields: AppField[] = ['date', 'amount', 'currency_code', 'transaction_type']; let missingFieldLabels = coreRequiredFields .filter(field => !confirmedMappings[field]) .map(field => APP_FIELDS_VALUES.find(val => val === field) || field); - if (confirmedMappings.transaction_type) { if (!confirmedMappings.source_name) missingFieldLabels.push('source_name (e.g., Firefly \'source_name\')'); if (!confirmedMappings.destination_name) missingFieldLabels.push('destination_name (e.g., Firefly \'destination_name\')'); @@ -360,22 +358,19 @@ export default function ImportDataPage() { return; } - const currentAccounts = await getAccounts(); setAccounts(currentAccounts); - const { preview } = await previewAccountChanges( rawData, - confirmedMappings, // Pass all mappings - currentAccounts // Pass current accounts + confirmedMappings, + currentAccounts ); setAccountPreviewData(preview); console.log("Account preview generated:", preview); - const mapped: MappedTransaction[] = rawData.map((record, index) => { - const rowNumber = index + 2; + const rowNumber = index + 2; const dateCol = confirmedMappings.date!; const amountCol = confirmedMappings.amount!; @@ -384,7 +379,7 @@ export default function ImportDataPage() { const categoryCol = confirmedMappings.category; const tagsCol = confirmedMappings.tags; const notesCol = confirmedMappings.notes; - const typeCol = confirmedMappings.transaction_type!; + const typeCol = confirmedMappings.transaction_type!; const sourceNameCol = confirmedMappings.source_name!; const destNameCol = confirmedMappings.destination_name!; const foreignAmountCol = confirmedMappings.foreign_amount; @@ -393,7 +388,6 @@ export default function ImportDataPage() { const destTypeCol = confirmedMappings.destination_type; const initialBalanceCol = confirmedMappings.initialBalance; - const sanitizedRecord: Record = {}; for (const key in record) { if (Object.prototype.hasOwnProperty.call(record, key)) { @@ -401,13 +395,12 @@ export default function ImportDataPage() { } } - try { const csvTypeRaw = record[typeCol]; const csvType = csvTypeRaw?.trim().toLowerCase(); const dateValue = record[dateCol]; - let amountValue = record[amountCol]; + let amountValue = record[amountCol]; const currencyValue = record[currencyCol]; const foreignAmountValue = foreignAmountCol ? record[foreignAmountCol] : undefined; const foreignCurrencyValue = foreignCurrencyCol ? record[foreignCurrencyCol] : undefined; @@ -422,37 +415,32 @@ export default function ImportDataPage() { let rawSourceType = sourceTypeCol ? record[sourceTypeCol]?.trim().toLowerCase() : undefined; let rawDestType = destTypeCol ? record[destTypeCol]?.trim().toLowerCase() : undefined; - if (!dateValue) throw new Error(`Row ${rowNumber}: Missing mapped 'Date' data.`); if (amountValue === undefined || amountValue.trim() === '') throw new Error(`Row ${rowNumber}: Missing or empty 'Amount' data.`); if (!currencyValue || currencyValue.trim() === '') throw new Error(`Row ${rowNumber}: Missing or empty 'Currency Code' data.`); if (!csvType) throw new Error(`Row ${rowNumber}: Missing or empty 'Transaction Type' (Firefly 'type') data.`); - if (csvType === 'withdrawal' || csvType === 'transfer') { - if (!rawSourceName) throw new Error(`Row ${rowNumber}: Missing 'Source Name' for type '${csvType}'. Source name column mapped to: '${sourceNameCol}', value: '${record[sourceNameCol!]}'.`); + if (!rawSourceName) throw new Error(`Row ${rowNumber}: Missing 'Source Name' for type '${csvType}'.`); } if (csvType === 'deposit' || csvType === 'transfer') { - if (!rawDestName) throw new Error(`Row ${rowNumber}: Missing 'Destination Name' for type '${csvType}'. Destination name column mapped to: '${destNameCol}', value: '${record[destNameCol!]}'.`); + if (!rawDestName) throw new Error(`Row ${rowNumber}: Missing 'Destination Name' for type '${csvType}'.`); } if (csvType === 'transfer' && rawSourceName && rawDestName && rawSourceName.toLowerCase() === rawDestName.toLowerCase()) { - if (rawSourceType?.includes('asset') && rawDestType?.includes('asset')) { // Both are asset accounts + if (rawSourceType?.includes('asset') && rawDestType?.includes('asset')) { throw new Error(`Row ${rowNumber}: Transfer source and destination asset accounts are the same ('${rawSourceName}').`); - } else { - console.warn(`Row ${rowNumber}: Firefly 'transfer' type row with same source and destination name ('${rawSourceName}') but different account types (Source: ${rawSourceType}, Dest: ${rawDestType}). This is likely an internal adjustment or error in data that will be skipped as a specific transfer.`); } } - - const parsedAmount = parseAmount(amountValue); + const parsedAmount = parseAmount(amountValue); if (isNaN(parsedAmount)) throw new Error(`Row ${rowNumber}: Could not parse amount "${amountValue}".`); let tempParsedForeignAmount: number | null = null; if (foreignAmountValue !== undefined && foreignAmountValue.trim() !== "") { const tempAmount = parseAmount(foreignAmountValue); if (!Number.isNaN(tempAmount)) { - tempParsedForeignAmount = tempAmount; - } else if (foreignAmountValue.trim() !== '') { + tempParsedForeignAmount = tempAmount; + } else if (foreignAmountValue.trim() !== '') { console.warn(`Row ${rowNumber}: Could not parse foreign amount "${foreignAmountValue}". It will be ignored.`); } } @@ -464,11 +452,9 @@ export default function ImportDataPage() { } if (finalParsedForeignCurrency === "") finalParsedForeignCurrency = null; - const parsedDate = parseDate(dateValue); const parsedTags = tagsValue.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0); - let finalDescription = descriptionValue.trim(); if (notesValue.trim()) { finalDescription = finalDescription ? `${finalDescription} (Notes: ${notesValue.trim()})` : `Notes: ${notesValue.trim()}`; @@ -479,7 +465,6 @@ export default function ImportDataPage() { if (!finalDescription && csvType === 'transfer') finalDescription = `Transfer: ${rawSourceName} to ${rawDestName}`; if (!finalDescription) finalDescription = 'Imported Transaction'; - if (csvType === 'opening balance') { let actualAccountNameForOB: string | undefined; let initialBalanceValue = initialBalanceCol ? record[initialBalanceCol] : amountValue; @@ -489,35 +474,31 @@ export default function ImportDataPage() { throw new Error(`Row ${rowNumber}: Could not parse initial balance for 'opening balance'. Value was: '${initialBalanceValue}'.`) } - // Prefer name from the side that IS an asset account, if types are clear if (rawDestType === "asset account" && rawDestName) { actualAccountNameForOB = rawDestName; } else if (rawSourceType === "asset account" && rawSourceName) { actualAccountNameForOB = rawSourceName; } else { - // Fallback: try parsing from descriptive strings if types aren't "asset account" - // Firefly stores the "destination_name" for opening balances as something like: 'Initial balance for "My Checking Account"' const parsedFromNameInDest = parseNameFromDescriptiveString(rawDestName); const parsedFromNameInSource = parseNameFromDescriptiveString(rawSourceName); if (parsedFromNameInDest) actualAccountNameForOB = parsedFromNameInDest; else if (parsedFromNameInSource) actualAccountNameForOB = parsedFromNameInSource; - else actualAccountNameForOB = rawDestName || rawSourceName; // Broadest fallback + else actualAccountNameForOB = rawDestName || rawSourceName; } - if (!actualAccountNameForOB) { throw new Error(`Row ${rowNumber}: Could not determine account name for 'opening balance'. Source: ${rawSourceName}, Dest: ${rawDestName}, SourceType: ${rawSourceType}, DestType: ${rawDestType}`); } return { csvRawSourceName: rawSourceName ?? null, - csvRawDestinationName: actualAccountNameForOB ?? null, + csvRawDestinationName: actualAccountNameForOB ?? null, csvTransactionType: csvType, csvSourceType: rawSourceType, csvDestinationType: rawDestType, date: parsedDate, - amount: parsedInitialBalance, // Use the correctly parsed initial balance + amount: parsedInitialBalance, currency: currencyValue.trim().toUpperCase(), foreignAmount: finalParsedForeignAmount, foreignCurrency: finalParsedForeignCurrency, @@ -533,7 +514,6 @@ export default function ImportDataPage() { }; } - // For regular transactions (withdrawal, deposit, transfer from Firefly CSV) return { csvRawSourceName: rawSourceName ?? null, csvRawDestinationName: rawDestName ?? null, @@ -541,9 +521,9 @@ export default function ImportDataPage() { csvSourceType: rawSourceType, csvDestinationType: rawDestType, date: parsedDate, - amount: parsedAmount, + amount: parsedAmount, currency: currencyValue.trim().toUpperCase(), - foreignAmount: finalParsedForeignAmount, + foreignAmount: finalParsedForeignAmount, foreignCurrency: finalParsedForeignCurrency, description: finalDescription, category: categoryValue.trim(), @@ -565,9 +545,9 @@ export default function ImportDataPage() { } } return { - date: parseDate(record[dateCol!]), + date: parseDate(record[dateCol!]), amount: 0, - currency: record[currencyCol!]?.trim().toUpperCase() || 'N/A', + currency: record[currencyCol!]?.trim().toUpperCase() || 'N/A', description: `Error Processing Row ${index + 2}`, category: 'Uncategorized', tags: [], @@ -609,7 +589,6 @@ export default function ImportDataPage() { ): Promise<{ preview: AccountPreview[] }> => { const mappedTransactions = csvData.map(record => { - // Simplified mapping just for account detection for preview const type = record[mappings.transaction_type!]?.trim().toLowerCase(); const sourceName = record[mappings.source_name!]?.trim(); const destName = record[mappings.destination_name!]?.trim(); @@ -619,7 +598,6 @@ export default function ImportDataPage() { const amount = parseAmount(record[mappings.amount!]); const initialBalance = parseAmount(record[mappings.initialBalance!] || record[mappings.amount!]); - return { csvTransactionType: type, csvRawSourceName: sourceName, @@ -636,7 +614,6 @@ export default function ImportDataPage() { const preview: AccountPreview[] = []; const processedAccountNames = new Set(); - accountDetailsMap.forEach((details, normalizedName) => { const existingAccount = existingAccountsParam.find(acc => acc.name.toLowerCase() === normalizedName); let action: AccountPreview['action'] = 'no change'; @@ -647,18 +624,18 @@ export default function ImportDataPage() { action = 'update'; } preview.push({ - name: details.name, + name: details.name, currency: details.currency, initialBalance: finalBalance, action: action, existingId: existingAccount.id, - category: existingAccount.category, + category: existingAccount.category, }); } else { preview.push({ - name: details.name, + name: details.name, currency: details.currency, - initialBalance: finalBalance, + initialBalance: finalBalance, action: 'create', category: details.category || 'asset', }); @@ -666,7 +643,6 @@ export default function ImportDataPage() { processedAccountNames.add(normalizedName); }); - existingAccountsParam.forEach(acc => { if (!processedAccountNames.has(acc.name.toLowerCase())) { preview.push({ @@ -683,7 +659,6 @@ export default function ImportDataPage() { }; - const buildAccountUpdateMap = async ( mappedCsvData: Partial[], existingAccountsParam: Account[] @@ -696,7 +671,6 @@ export default function ImportDataPage() { const recordCurrency = item.currency; const recordAmount = item.amount; - // Determine the actual account name from the descriptive "destination_name" or "source_name" const descriptiveDestName = item.csvRawDestinationName; const descriptiveSourceName = item.csvRawSourceName; @@ -704,19 +678,17 @@ export default function ImportDataPage() { const parsedNameFromSource = parseNameFromDescriptiveString(descriptiveSourceName); if (item.csvDestinationType === "asset account" && descriptiveDestName && !parseNameFromDescriptiveString(descriptiveDestName) ) { - accountNameForOB = descriptiveDestName; // It's already a direct account name + accountNameForOB = descriptiveDestName; } else if (item.csvSourceType === "asset account" && descriptiveSourceName && !parseNameFromDescriptiveString(descriptiveSourceName)) { - accountNameForOB = descriptiveSourceName; // It's already a direct account name + accountNameForOB = descriptiveSourceName; } else if (parsedNameFromDest) { accountNameForOB = parsedNameFromDest; } else if (parsedNameFromSource) { accountNameForOB = parsedNameFromSource; } else { - // Fallback if names are not descriptive as expected for OB accountNameForOB = descriptiveDestName || descriptiveSourceName; } - if (accountNameForOB && recordCurrency && recordAmount !== undefined && !isNaN(recordAmount)) { const normalizedName = accountNameForOB.toLowerCase().trim(); const existingDetailsInMap = accountMap.get(normalizedName); @@ -725,7 +697,6 @@ export default function ImportDataPage() { const sourceIsDescriptive = item.csvRawSourceName && parseNameFromDescriptiveString(item.csvRawSourceName); const destIsDescriptive = item.csvRawDestinationName && parseNameFromDescriptiveString(item.csvRawDestinationName); - if (item.csvDestinationType === "asset account" && !destIsDescriptive && item.csvRawDestinationName?.toLowerCase().includes('crypto')) { accountCategory = 'crypto'; } else if (item.csvSourceType === "asset account" && !sourceIsDescriptive && item.csvRawSourceName?.toLowerCase().includes('crypto')) { @@ -734,7 +705,6 @@ export default function ImportDataPage() { accountCategory = 'crypto'; } - accountMap.set(normalizedName, { name: accountNameForOB, currency: recordCurrency, @@ -757,9 +727,8 @@ export default function ImportDataPage() { (value, index, self) => value.name && self.findIndex(t => t.name?.toLowerCase().trim() === value.name?.toLowerCase().trim()) === index ); - for (const accInfo of uniqueAccountNamesAndCurrencies) { - if (accInfo.name && accInfo.currency) { + if (accInfo.name && accInfo.currency) { const normalizedName = accInfo.name.toLowerCase().trim(); let category: 'asset' | 'crypto' = 'asset'; if (accInfo.name.toLowerCase().includes('crypto') || @@ -772,11 +741,11 @@ export default function ImportDataPage() { const existingAppAccount = existingAccountsParam.find(a => a.name.toLowerCase() === normalizedName); accountMap.set(normalizedName, { name: accInfo.name, - currency: existingAppAccount?.currency || accInfo.currency, + currency: existingAppAccount?.currency || accInfo.currency, initialBalance: existingAppAccount?.balance, category: existingAppAccount?.category || category, }); - } else { + } else { const currentDetails = accountMap.get(normalizedName)!; if (!currentDetails.currency && accInfo.currency) currentDetails.currency = accInfo.currency; if (currentDetails.category === 'asset' && category === 'crypto') { @@ -790,19 +759,17 @@ export default function ImportDataPage() { return accountMap; }; - const createOrUpdateAccountsAndGetMap = async ( isPreviewOnly: boolean = false ): Promise<{ success: boolean; map: { [key: string]: string }, updatedAccountsList: Account[] }> => { let success = true; - let currentAppAccounts = [...accounts]; + let currentAppAccounts = [...accounts]; const workingMap = currentAppAccounts.reduce((map, acc) => { map[acc.name.toLowerCase().trim()] = acc.id; return map; }, {} as { [key: string]: string }); - if (accountPreviewData.length === 0 && !isPreviewOnly) { return { success: true, map: workingMap, updatedAccountsList: currentAppAccounts }; } @@ -818,42 +785,42 @@ export default function ImportDataPage() { } else if (accPreview.action === 'create') { workingMap[normalizedName] = `preview_create_${normalizedName.replace(/\s+/g, '_')}`; } - continue; + continue; } if (accPreview.action === 'create') { const newAccountData: NewAccountData = { name: accPreview.name, - type: (accPreview.category === 'crypto' ? 'wallet' : 'checking'), - balance: accPreview.initialBalance, + type: (accPreview.category === 'crypto' ? 'wallet' : 'checking'), + balance: accPreview.initialBalance, currency: accPreview.currency, - providerName: 'Imported - ' + accPreview.name, - category: accPreview.category, + providerName: 'Imported - ' + accPreview.name, + category: accPreview.category, isActive: true, lastActivity: new Date().toISOString(), balanceDifference: 0, - includeInNetWorth: true, + includeInNetWorth: true, }; const createdAccount = await addAccount(newAccountData); - workingMap[normalizedName] = createdAccount.id; - currentAppAccounts.push(createdAccount); + workingMap[normalizedName] = createdAccount.id; + currentAppAccounts.push(createdAccount); accountsProcessedCount++; } else if (accPreview.action === 'update' && accPreview.existingId) { const existingAccountForUpdate = currentAppAccounts.find(a => a.id === accPreview.existingId); if (existingAccountForUpdate) { const updatedAccountData: Account = { ...existingAccountForUpdate, - balance: accPreview.initialBalance, - currency: accPreview.currency, - lastActivity: new Date().toISOString(), - category: accPreview.category, - includeInNetWorth: existingAccountForUpdate.includeInNetWorth ?? true, + balance: accPreview.initialBalance, + currency: accPreview.currency, + lastActivity: new Date().toISOString(), + category: accPreview.category, + includeInNetWorth: existingAccountForUpdate.includeInNetWorth ?? true, }; const savedUpdatedAccount = await updateAccount(updatedAccountData); accountsProcessedCount++; const idx = currentAppAccounts.findIndex(a => a.id === savedUpdatedAccount.id); if (idx !== -1) currentAppAccounts[idx] = savedUpdatedAccount; - else currentAppAccounts.push(savedUpdatedAccount); + else currentAppAccounts.push(savedUpdatedAccount); workingMap[normalizedName] = savedUpdatedAccount.id; } @@ -864,7 +831,7 @@ export default function ImportDataPage() { } catch (err: any) { console.error(`Failed to process account "${accPreview.name}":`, err); toast({ title: "Account Processing Error", description: `Could not process account "${accPreview.name}". Error: ${err.message}`, variant: "destructive", duration: 7000 }); - success = false; + success = false; } } @@ -874,7 +841,7 @@ export default function ImportDataPage() { if (!isPreviewOnly && accountsProcessedCount > 0) { const finalFetchedAccounts = await getAccounts(); - setAccounts(finalFetchedAccounts); + setAccounts(finalFetchedAccounts); const finalMap = finalFetchedAccounts.reduce((map, acc) => { map[acc.name.toLowerCase().trim()] = acc.id; return map; @@ -898,7 +865,7 @@ export default function ImportDataPage() { let success = true; transactionsToProcess.forEach(tx => { - if (tx.importStatus === 'pending') { + if (tx.importStatus === 'pending') { if (tx.category && !['Uncategorized', 'Initial Balance', 'Transfer', 'Skipped', 'Opening Balance'].includes(tx.category)) { const categoryName = tx.category.trim(); if (categoryName && !existingCategoryNames.has(categoryName.toLowerCase())) { @@ -921,10 +888,10 @@ export default function ImportDataPage() { let categoriesAddedCount = 0; const addCatPromises = Array.from(categoriesToAdd).map(async (catName) => { try { - await addCategoryToDb(catName); + await addCategoryToDb(catName); categoriesAddedCount++; } catch (err: any) { - if (!err.message?.includes('already exists')) { + if (!err.message?.includes('already exists')) { console.error(`Failed to add category "${catName}":`, err); toast({ title: "Category Add Error", description: `Could not add category "${catName}". Error: ${err.message}`, variant: "destructive" }); success = false; @@ -979,18 +946,17 @@ export default function ImportDataPage() { setError(null); let overallError = false; - let finalMapForTxImport: { [key: string]: string }; let latestAccountsList: Account[]; try { - const accountMapResult = await createOrUpdateAccountsAndGetMap(false); + const accountMapResult = await createOrUpdateAccountsAndGetMap(false); if (!accountMapResult.success) { setError("Error processing some accounts during import. Some accounts might not have been created/updated correctly. Review account preview and transaction statuses."); } finalMapForTxImport = accountMapResult.map; - latestAccountsList = accountMapResult.updatedAccountsList; - setAccounts(latestAccountsList); - setFinalAccountMapForImport(finalMapForTxImport); + latestAccountsList = accountMapResult.updatedAccountsList; + setAccounts(latestAccountsList); + setFinalAccountMapForImport(finalMapForTxImport); } catch (finalAccountMapError) { console.error("Critical error during account finalization before import.", finalAccountMapError); toast({ title: "Account Sync Error", description: "Could not synchronize accounts with the database before starting transaction import. Please try again.", variant: "destructive"}); @@ -998,7 +964,6 @@ export default function ImportDataPage() { return; } - const metadataSuccess = await addMissingCategoriesAndTags(recordsToImport); if (!metadataSuccess) { setError("Error adding new categories or tags from CSV. Some transactions might use 'Uncategorized' or miss tags. Import halted to ensure data integrity."); @@ -1006,36 +971,31 @@ export default function ImportDataPage() { return; } - const currentCategoriesList = await getCategories(); - const currentTagsList = await getTags(); - + const currentCategoriesList = await getCategories(); + const currentTagsList = await getTags(); const totalToImport = recordsToImport.length; let importedCount = 0; let errorCount = 0; - const updatedDataForDisplay = [...parsedData]; - + const updatedDataForDisplay = [...parsedData]; const transactionPayloads: (Omit & { originalMappedTx: MappedTransaction })[] = []; - for (const item of recordsToImport) { - const rowNumber = rawData.indexOf(item.originalRecord) + 2; + const rowNumber = rawData.indexOf(item.originalRecord) + 2; const itemIndexInDisplay = updatedDataForDisplay.findIndex(d => d.originalRecord === item.originalRecord && d.description === item.description && d.amount === item.amount && d.date === item.date); - if (item.csvTransactionType === 'opening balance') { if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'skipped', errorMessage: 'Opening Balance (handled via account balance creation/update)' }; - continue; + continue; } const transactionCategory = currentCategoriesList.find(c => c.name.toLowerCase() === item.category.toLowerCase())?.name || 'Uncategorized'; const transactionTags = item.tags?.map(tName => currentTagsList.find(t => t.name.toLowerCase() === tName.toLowerCase())?.name || tName).filter(Boolean) || []; - if (item.csvTransactionType === 'transfer') { - const csvAmount = item.amount; // Primary amount from CSV 'amount' column - const csvCurrency = item.currency; // Primary currency from CSV 'currency_code' + const csvAmount = item.amount; + const csvCurrency = item.currency; const csvForeignAmount = item.originalImportData?.foreignAmount; const csvForeignCurrency = item.originalImportData?.foreignCurrency; @@ -1068,20 +1028,16 @@ export default function ImportDataPage() { const transferDesc = item.description || `Transfer from ${fromAccountDetails.name} to ${toAccountDetails.name}`; - let debitAmount = -Math.abs(csvAmount); // Amount leaving source_name - let debitCurrency = csvCurrency; // Currency of source_name transaction - let creditAmount = Math.abs(csvAmount); // Amount arriving at destination_name - let creditCurrency = csvCurrency; // Currency of destination_name transaction + let debitAmount = -Math.abs(csvAmount); + let debitCurrency = csvCurrency; + let creditAmount = Math.abs(csvAmount); + let creditCurrency = csvCurrency; - // If foreign values are present, it signals a cross-currency transfer if (csvForeignAmount != null && csvForeignCurrency && csvForeignCurrency.trim() !== '') { - // Assume Firefly's primary 'amount' & 'currency_code' are for the source, - // and 'foreign_amount' & 'foreign_currency_code' are for the destination. creditAmount = Math.abs(csvForeignAmount); creditCurrency = csvForeignCurrency; } - // Ensure debitAmount and creditAmount are numbers and signed correctly. transactionPayloads.push({ accountId: fromAccountId, date: item.date, @@ -1108,7 +1064,7 @@ export default function ImportDataPage() { } else if (item.csvTransactionType === 'withdrawal' || item.csvTransactionType === 'deposit') { let accountNameForTx: string | undefined | null; let accountIdForTx: string | undefined; - let payloadAmount = item.amount; // Already signed from Firefly CSV + let payloadAmount = item.amount; let payloadCurrency = item.currency; if (item.csvTransactionType === 'withdrawal') { @@ -1117,7 +1073,7 @@ export default function ImportDataPage() { if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Withdrawal from non-asset account type '${item.csvSourceType}' for source '${accountNameForTx}'. Skipping.` }; errorCount++; overallError = true; continue; } - } else { // deposit + } else { accountNameForTx = item.csvRawDestinationName; if (item.csvDestinationType !== 'asset account' && item.csvDestinationType !== 'default asset account') { if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Deposit to non-asset account type '${item.csvDestinationType}' for destination '${accountNameForTx}'. Skipping.` }; @@ -1141,10 +1097,6 @@ export default function ImportDataPage() { errorCount++; overallError = true; continue; } - // For withdrawals/deposits with foreign currency, Firefly's 'amount' is in 'currency_code'. - // 'foreign_amount' in 'foreign_currency_code' is informational. - // We will store the primary amount and currency. The foreign details are in originalImportData. - transactionPayloads.push({ accountId: accountIdForTx, date: item.date, @@ -1162,7 +1114,6 @@ export default function ImportDataPage() { } } - transactionPayloads.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); for (const payload of transactionPayloads) { @@ -1200,7 +1151,6 @@ export default function ImportDataPage() { setParsedData([...updatedDataForDisplay]); } - setIsLoading(false); const finalMessage = `Import finished. Successfully processed transaction entries: ${importedCount}. Failed/Skipped rows (from preview): ${errorCount + parsedData.filter(d => d.importStatus === 'skipped').length}.`; toast({ @@ -1234,7 +1184,6 @@ export default function ImportDataPage() { try { await clearAllSessionTransactions(); - setAccounts([]); setCategories([]); setTags([]); @@ -1247,7 +1196,6 @@ export default function ImportDataPage() { setImportProgress(0); setFinalAccountMapForImport({}); - const fileInput = document.getElementById('csv-file') as HTMLInputElement; if (fileInput) fileInput.value = ''; @@ -1271,7 +1219,6 @@ export default function ImportDataPage() { const newData = [...prevData]; let transactionToUpdate = { ...newData[originalIndexInData] }; - if (transactionToUpdate.importStatus !== 'pending') { toast({ title: "Edit Blocked", description: "Cannot edit transactions that are not pending import.", variant: "destructive" }); return prevData; @@ -1335,7 +1282,6 @@ export default function ImportDataPage() { if (!parsedData || parsedData.length === 0) return {}; const grouped: { [accountDisplayName: string]: MappedTransaction[] } = {}; - const getDisplayableAccountName = (csvName?: string | null, csvType?: string | null): string => { if (!csvName) return "Unknown / External"; const lowerCsvName = csvName.toLowerCase().trim(); @@ -1353,10 +1299,9 @@ export default function ImportDataPage() { const previewAccount = accountPreviewData.find(ap => ap.name.toLowerCase().trim() === lowerCsvName); if (previewAccount) return previewAccount.name; - return csvName; // Fallback to raw CSV name if no match found + return csvName; }; - parsedData.forEach(item => { let accountKeyForGrouping = "Unknown / Skipped / Error"; let accountDisplayName = "Unknown / Skipped / Error"; @@ -1380,7 +1325,6 @@ export default function ImportDataPage() { accountKeyForGrouping = `account-${accountDisplayName}-deposit`; } - if (!grouped[accountKeyForGrouping]) { grouped[accountKeyForGrouping] = []; } @@ -1388,7 +1332,6 @@ export default function ImportDataPage() { grouped[accountKeyForGrouping].push(item); }); - return Object.entries(grouped) .sort(([keyA], [keyB]) => { const nameA = (grouped[keyA][0] as any)._accountDisplayNameForGroupHeader || keyA; @@ -1404,17 +1347,16 @@ export default function ImportDataPage() { }, [parsedData, accountPreviewData, finalAccountMapForImport, accounts]); - if (isLoadingAuth) { return

Loading authentication...

; } if (!user && !isLoadingAuth) { - return

Please login to import data.

; + return

Please login to manage data.

; } return (
-

Import Data

+

Data Management

@@ -1426,7 +1368,7 @@ export default function ImportDataPage() {
- +
{error && ( @@ -1437,18 +1379,20 @@ export default function ImportDataPage() { )} -
- - - + - @@ -1476,7 +1420,6 @@ export default function ImportDataPage() { - @@ -1494,8 +1437,6 @@ export default function ImportDataPage() { - - {accountPreviewData.length > 0 && !isLoading && ( @@ -1534,8 +1475,6 @@ export default function ImportDataPage() { )} - - {parsedData.length > 0 && ( From 9743c0f609669ff3df8ca967353b70d86fd4c5bd Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 20 May 2025 10:49:58 +0000 Subject: [PATCH 010/156] I see this error with the app, reported by NextJS, please fix it. The error is reported as HTML but presented visually to the user). A > before the line number in the error source usually indicates the line of interest: > Unhandled Runtime Error: Error: handleExportData is not defined. Error source: src/app/import/page.tsx (1389:32) @ DataManagementPage > > 1387 | {isLoading && importProgress > 0 ? `Importing... (${importProgress}%)` : "Import Transactions"} > 1388 | > > 1389 | > > Call Stack > 2 > > Show 1 ignore-listed frame(s) > DataManagementPage > src/app/import/page.tsx (1389:32) --- src/app/import/page.tsx | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/src/app/import/page.tsx b/src/app/import/page.tsx index 70217cc..a415a3d 100644 --- a/src/app/import/page.tsx +++ b/src/app/import/page.tsx @@ -16,7 +16,7 @@ import { getAccounts, addAccount, type Account, type NewAccountData, updateAccou import { getCategories, addCategory as addCategoryToDb, type Category } from '@/services/categories'; import { getTags, addTag as addTagToDb, type Tag } from '@/services/tags'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { format, parseISO, isValid, parse as parseDateFns } from 'date-fns'; @@ -1209,6 +1209,24 @@ export default function DataManagementPage() { } }; + const handleExportData = async () => { + if (!user) { + toast({ title: "Error", description: "User not authenticated.", variant: "destructive" }); + return; + } + setIsExporting(true); + toast({ title: "Exporting Data", description: "Preparing your data for download. This may take a moment..." }); + try { + await exportAllUserDataToCsvs(); + toast({ title: "Export Complete", description: "Your data files should be downloading now. Please check your browser's download folder." }); + } catch (error) { + console.error("Export failed:", error); + toast({ title: "Export Failed", description: "Could not export your data. Please try again.", variant: "destructive" }); + } finally { + setIsExporting(false); + } + }; + const handleTransactionFieldChange = ( originalIndexInData: number, @@ -1405,7 +1423,7 @@ export default function DataManagementPage() { - Cancel + setIsClearing(false)} disabled={isClearing}>Cancel {isClearing ? "Clearing..." : "Yes, Clear All My Data"} @@ -1558,3 +1576,4 @@ export default function DataManagementPage() {
); } + From ac63dee89f27d6b32daf89cdcf50b0f5bfea4fc5 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 20 May 2025 22:31:44 +0000 Subject: [PATCH 011/156] Ele esta exportando em multiplos arquivos separados, nao tem como colocar no mesmo aequivo, mesmo que seja numa segunda aba --- src/services/export.ts | 189 +++++++++++++++++++++++++---------------- 1 file changed, 114 insertions(+), 75 deletions(-) diff --git a/src/services/export.ts b/src/services/export.ts index 0797241..ff96679 100644 --- a/src/services/export.ts +++ b/src/services/export.ts @@ -12,11 +12,12 @@ import { getLoans, type Loan } from './loans'; import { getCreditCards, type CreditCard } from './credit-cards'; import { getBudgets, type Budget } from './budgets'; import { getUserPreferences, type UserPreferences } from '@/lib/preferences'; +import { format as formatDateFns, isValid, parseISO } from 'date-fns'; function downloadCsv(csvString: string, filename: string) { const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); - if (link.download !== undefined) { // feature detection + if (link.download !== undefined) { const url = URL.createObjectURL(blob); link.setAttribute('href', url); link.setAttribute('download', filename); @@ -26,11 +27,29 @@ function downloadCsv(csvString: string, filename: string) { document.body.removeChild(link); URL.revokeObjectURL(url); } else { - // Fallback for browsers that don't support HTML5 download attribute alert('CSV download is not supported by your browser. Please try a different browser.'); } } +const formatDateForExport = (dateInput: object | string | undefined | null): string => { + if (!dateInput) return ''; + if (typeof dateInput === 'string') { + const parsed = parseISO(dateInput); + return isValid(parsed) ? formatDateFns(parsed, "yyyy-MM-dd'T'HH:mm:ssXXX") : dateInput; + } + // Assuming serverTimestamp object will be a number (timestamp) when fetched, + // but Firebase often returns it as an object initially. If it's already a string, use it. + // For actual numeric timestamps from Firebase, you'd convert new Date(timestamp) + // This simplistic approach assumes it's either a parsable string or we return a placeholder. + // A more robust solution would handle Firebase serverTimestamps correctly after they resolve. + if (typeof dateInput === 'object') { + // Placeholder for unresolved serverTimestamp. Real value needs server-side conversion or client-side handling post-fetch. + return new Date().toISOString(); // Fallback for unresolved server timestamps + } + return String(dateInput); // Fallback for other types +}; + + interface ExportableTransaction extends Omit { tags?: string; // Pipe-separated originalImportData?: string; // JSON string @@ -54,117 +73,137 @@ interface ExportableBudget extends Omit { + let combinedCsvString = ""; + const sectionSeparator = "\n\n"; // Add a couple of newlines between sections + try { + const appendToCombinedCsv = (header: string, data: any[]) => { + if (data && data.length > 0) { + combinedCsvString += `### ${header} ###\n`; + combinedCsvString += Papa.unparse(data); + combinedCsvString += sectionSeparator; + console.log(`Appended ${header} to CSV export.`); + } else { + combinedCsvString += `### ${header} ###\n(No data)\n`; + combinedCsvString += sectionSeparator; + console.log(`No data to append for ${header}.`); + } + }; + // 1. User Preferences + console.log("Exporting: Fetching User Preferences..."); const preferences = await getUserPreferences(); - if (preferences) { - const preferencesCsv = Papa.unparse([preferences]); - downloadCsv(preferencesCsv, 'user_preferences.csv'); + if (preferences) { // Ensure preferences is not null/undefined + appendToCombinedCsv("USER PREFERENCES", [preferences]); + } else { + appendToCombinedCsv("USER PREFERENCES", []); } // 2. Categories + console.log("Exporting: Fetching Categories..."); const categories = await getCategories(); - if (categories.length > 0) { - const categoriesCsv = Papa.unparse(categories); - downloadCsv(categoriesCsv, 'categories.csv'); - } + appendToCombinedCsv("CATEGORIES", categories); // 3. Tags + console.log("Exporting: Fetching Tags..."); const tags = await getTags(); - if (tags.length > 0) { - const tagsCsv = Papa.unparse(tags); - downloadCsv(tagsCsv, 'tags.csv'); - } + appendToCombinedCsv("TAGS", tags); // 4. Groups + console.log("Exporting: Fetching Groups..."); const groups = await getGroups(); - if (groups.length > 0) { - const exportableGroups: ExportableGroup[] = groups.map(g => ({ + const exportableGroups: ExportableGroup[] = groups.map(g => ({ ...g, - categoryIds: g.categoryIds.join('|'), - })); - const groupsCsv = Papa.unparse(exportableGroups); - downloadCsv(groupsCsv, 'groups.csv'); - } + categoryIds: g.categoryIds ? g.categoryIds.join('|') : '', + })); + appendToCombinedCsv("GROUPS", exportableGroups); // 5. Accounts + console.log("Exporting: Fetching Accounts..."); const accounts = await getAccounts(); + appendToCombinedCsv("ACCOUNTS", accounts); + + // 6. Transactions (fetch per account, then combine) if (accounts.length > 0) { - const accountsCsv = Papa.unparse(accounts); - downloadCsv(accountsCsv, 'accounts.csv'); - - // 6. Transactions (fetch per account, then combine) - let allTransactions: Transaction[] = []; - for (const account of accounts) { - const accountTransactions = await getTransactions(account.id); - allTransactions = allTransactions.concat(accountTransactions); - } - if (allTransactions.length > 0) { - const exportableTransactions: ExportableTransaction[] = allTransactions.map(tx => ({ - ...tx, - tags: tx.tags?.join('|') || '', - originalImportData: tx.originalImportData ? JSON.stringify(tx.originalImportData) : '', - createdAt: typeof tx.createdAt === 'object' ? new Date().toISOString() : tx.createdAt, // Placeholder for serverTimestamp - updatedAt: typeof tx.updatedAt === 'object' ? new Date().toISOString() : tx.updatedAt, // Placeholder for serverTimestamp - })); - const transactionsCsv = Papa.unparse(exportableTransactions); - downloadCsv(transactionsCsv, 'transactions.csv'); - } + console.log("Exporting: Fetching Transactions..."); + let allTransactions: Transaction[] = []; + for (const account of accounts) { + try { + const accountTransactions = await getTransactions(account.id); + allTransactions = allTransactions.concat(accountTransactions); + } catch (accTxError) { + console.error(`Error fetching transactions for account ${account.id}:`, accTxError); + } + } + if (allTransactions.length > 0) { + const exportableTransactions: ExportableTransaction[] = allTransactions.map(tx => ({ + ...tx, + tags: tx.tags ? tx.tags.join('|') : '', + originalImportData: tx.originalImportData ? JSON.stringify(tx.originalImportData) : '', + createdAt: formatDateForExport(tx.createdAt), + updatedAt: formatDateForExport(tx.updatedAt), + })); + appendToCombinedCsv("TRANSACTIONS", exportableTransactions); + } else { + appendToCombinedCsv("TRANSACTIONS", []); + } + } else { + appendToCombinedCsv("TRANSACTIONS", []); } + // 7. Subscriptions + console.log("Exporting: Fetching Subscriptions..."); const subscriptions = await getSubscriptions(); - if (subscriptions.length > 0) { - const exportableSubscriptions: ExportableSubscription[] = subscriptions.map(sub => ({ + const exportableSubscriptions: ExportableSubscription[] = subscriptions.map(sub => ({ ...sub, - tags: sub.tags?.join('|') || '', - createdAt: typeof sub.createdAt === 'object' ? new Date().toISOString() : sub.createdAt, - updatedAt: typeof sub.updatedAt === 'object' ? new Date().toISOString() : sub.updatedAt, - })); - const subscriptionsCsv = Papa.unparse(exportableSubscriptions); - downloadCsv(subscriptionsCsv, 'subscriptions.csv'); - } + tags: sub.tags ? sub.tags.join('|') : '', + createdAt: formatDateForExport(sub.createdAt), + updatedAt: formatDateForExport(sub.updatedAt), + })); + appendToCombinedCsv("SUBSCRIPTIONS", exportableSubscriptions); // 8. Loans + console.log("Exporting: Fetching Loans..."); const loans = await getLoans(); - if (loans.length > 0) { - const exportableLoans = loans.map(loan => ({ + const exportableLoans = loans.map(loan => ({ ...loan, - createdAt: typeof loan.createdAt === 'object' ? new Date().toISOString() : loan.createdAt, - updatedAt: typeof loan.updatedAt === 'object' ? new Date().toISOString() : loan.updatedAt, - })); - const loansCsv = Papa.unparse(exportableLoans); - downloadCsv(loansCsv, 'loans.csv'); - } + createdAt: formatDateForExport(loan.createdAt), + updatedAt: formatDateForExport(loan.updatedAt), + })); + appendToCombinedCsv("LOANS", exportableLoans); // 9. Credit Cards + console.log("Exporting: Fetching Credit Cards..."); const creditCards = await getCreditCards(); - if (creditCards.length > 0) { - const exportableCreditCards = creditCards.map(card => ({ + const exportableCreditCards = creditCards.map(card => ({ ...card, - createdAt: typeof card.createdAt === 'object' ? new Date().toISOString() : card.createdAt, - updatedAt: typeof card.updatedAt === 'object' ? new Date().toISOString() : card.updatedAt, - })); - const creditCardsCsv = Papa.unparse(exportableCreditCards); - downloadCsv(creditCardsCsv, 'credit_cards.csv'); - } + createdAt: formatDateForExport(card.createdAt), + updatedAt: formatDateForExport(card.updatedAt), + })); + appendToCombinedCsv("CREDIT CARDS", exportableCreditCards); // 10. Budgets + console.log("Exporting: Fetching Budgets..."); const budgets = await getBudgets(); - if (budgets.length > 0) { - const exportableBudgets: ExportableBudget[] = budgets.map(b => ({ + const exportableBudgets: ExportableBudget[] = budgets.map(b => ({ ...b, - selectedIds: b.selectedIds.join('|'), - createdAt: typeof b.createdAt === 'object' ? new Date().toISOString() : b.createdAt, - updatedAt: typeof b.updatedAt === 'object' ? new Date().toISOString() : b.updatedAt, - })); - const budgetsCsv = Papa.unparse(exportableBudgets); - downloadCsv(budgetsCsv, 'budgets.csv'); + selectedIds: b.selectedIds ? b.selectedIds.join('|') : '', + createdAt: formatDateForExport(b.createdAt), + updatedAt: formatDateForExport(b.updatedAt), + })); + appendToCombinedCsv("BUDGETS", exportableBudgets); + + // Download the combined CSV + if (combinedCsvString.trim() !== "") { + downloadCsv(combinedCsvString, 'goldquest_full_backup.csv'); + console.log('Combined CSV data prepared for download.'); + } else { + console.log('No data found to export.'); } - console.log('All user data prepared for download.'); } catch (error) { - console.error("Error exporting user data:", error); + console.error("Error exporting all user data:", error); throw error; } } From ae49f304dcdfef944069e34a9d480aa06d563de0 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 20 May 2025 22:36:39 +0000 Subject: [PATCH 012/156] Nao, vai ter que ser em abas, pois precisa ter os cabecalhos para a importacao ser certinha tambem --- src/services/export.ts | 166 +++++++++++++++++++++++------------------ 1 file changed, 94 insertions(+), 72 deletions(-) diff --git a/src/services/export.ts b/src/services/export.ts index ff96679..016d3ab 100644 --- a/src/services/export.ts +++ b/src/services/export.ts @@ -34,19 +34,28 @@ function downloadCsv(csvString: string, filename: string) { const formatDateForExport = (dateInput: object | string | undefined | null): string => { if (!dateInput) return ''; if (typeof dateInput === 'string') { - const parsed = parseISO(dateInput); + // Check if it's already a fully qualified ISO string (e.g., from serverTimestamp or new Date().toISOString()) + // or a simple YYYY-MM-DD date. + const parsed = parseISO(dateInput); // parseISO is robust return isValid(parsed) ? formatDateFns(parsed, "yyyy-MM-dd'T'HH:mm:ssXXX") : dateInput; } - // Assuming serverTimestamp object will be a number (timestamp) when fetched, - // but Firebase often returns it as an object initially. If it's already a string, use it. - // For actual numeric timestamps from Firebase, you'd convert new Date(timestamp) - // This simplistic approach assumes it's either a parsable string or we return a placeholder. - // A more robust solution would handle Firebase serverTimestamps correctly after they resolve. - if (typeof dateInput === 'object') { - // Placeholder for unresolved serverTimestamp. Real value needs server-side conversion or client-side handling post-fetch. - return new Date().toISOString(); // Fallback for unresolved server timestamps + // This case is tricky for Firebase serverTimestamp, which is an object initially. + // For simplicity, if it's an object here, it means it wasn't resolved to a number/string. + // A proper solution would handle the Firebase ServerValue.TIMESTAMP object correctly, + // or ensure data is fetched *after* timestamps are resolved. + // For now, returning an empty string or a placeholder for unresolved objects. + if (typeof dateInput === 'object' && dateInput !== null) { + // If it has a toDate method (like a Firebase Timestamp object *after* conversion client-side) + if ('toDate' in dateInput && typeof (dateInput as any).toDate === 'function') { + return formatDateFns((dateInput as any).toDate(), "yyyy-MM-dd'T'HH:mm:ssXXX"); + } + // Fallback for other objects or unresolved serverTimestamps + return new Date().toISOString(); // Or consider '' or a placeholder + } + if (typeof dateInput === 'number') { // Assuming numeric timestamp + return formatDateFns(new Date(dateInput), "yyyy-MM-dd'T'HH:mm:ssXXX"); } - return String(dateInput); // Fallback for other types + return String(dateInput); }; @@ -73,55 +82,56 @@ interface ExportableBudget extends Omit { - let combinedCsvString = ""; - const sectionSeparator = "\n\n"; // Add a couple of newlines between sections - try { - const appendToCombinedCsv = (header: string, data: any[]) => { - if (data && data.length > 0) { - combinedCsvString += `### ${header} ###\n`; - combinedCsvString += Papa.unparse(data); - combinedCsvString += sectionSeparator; - console.log(`Appended ${header} to CSV export.`); - } else { - combinedCsvString += `### ${header} ###\n(No data)\n`; - combinedCsvString += sectionSeparator; - console.log(`No data to append for ${header}.`); - } - }; - // 1. User Preferences console.log("Exporting: Fetching User Preferences..."); const preferences = await getUserPreferences(); - if (preferences) { // Ensure preferences is not null/undefined - appendToCombinedCsv("USER PREFERENCES", [preferences]); + if (preferences) { + downloadCsv(Papa.unparse([preferences]), 'goldquest_preferences.csv'); } else { - appendToCombinedCsv("USER PREFERENCES", []); + console.log("No preferences data to export."); } // 2. Categories console.log("Exporting: Fetching Categories..."); const categories = await getCategories(); - appendToCombinedCsv("CATEGORIES", categories); + if (categories.length > 0) { + downloadCsv(Papa.unparse(categories), 'goldquest_categories.csv'); + } else { + console.log("No categories data to export."); + } // 3. Tags console.log("Exporting: Fetching Tags..."); const tags = await getTags(); - appendToCombinedCsv("TAGS", tags); + if (tags.length > 0) { + downloadCsv(Papa.unparse(tags), 'goldquest_tags.csv'); + } else { + console.log("No tags data to export."); + } // 4. Groups console.log("Exporting: Fetching Groups..."); const groups = await getGroups(); - const exportableGroups: ExportableGroup[] = groups.map(g => ({ - ...g, - categoryIds: g.categoryIds ? g.categoryIds.join('|') : '', - })); - appendToCombinedCsv("GROUPS", exportableGroups); + if (groups.length > 0) { + const exportableGroups: ExportableGroup[] = groups.map(g => ({ + ...g, + categoryIds: g.categoryIds ? g.categoryIds.join('|') : '', + })); + downloadCsv(Papa.unparse(exportableGroups), 'goldquest_groups.csv'); + } else { + console.log("No groups data to export."); + } + // 5. Accounts console.log("Exporting: Fetching Accounts..."); const accounts = await getAccounts(); - appendToCombinedCsv("ACCOUNTS", accounts); + if (accounts.length > 0) { + downloadCsv(Papa.unparse(accounts), 'goldquest_accounts.csv'); + } else { + console.log("No accounts data to export."); + } // 6. Transactions (fetch per account, then combine) if (accounts.length > 0) { @@ -143,65 +153,77 @@ export async function exportAllUserDataToCsvs(): Promise { createdAt: formatDateForExport(tx.createdAt), updatedAt: formatDateForExport(tx.updatedAt), })); - appendToCombinedCsv("TRANSACTIONS", exportableTransactions); + downloadCsv(Papa.unparse(exportableTransactions), 'goldquest_transactions.csv'); } else { - appendToCombinedCsv("TRANSACTIONS", []); + console.log("No transactions data to export."); } } else { - appendToCombinedCsv("TRANSACTIONS", []); + console.log("No accounts found, skipping transaction export."); } // 7. Subscriptions console.log("Exporting: Fetching Subscriptions..."); const subscriptions = await getSubscriptions(); - const exportableSubscriptions: ExportableSubscription[] = subscriptions.map(sub => ({ - ...sub, - tags: sub.tags ? sub.tags.join('|') : '', - createdAt: formatDateForExport(sub.createdAt), - updatedAt: formatDateForExport(sub.updatedAt), - })); - appendToCombinedCsv("SUBSCRIPTIONS", exportableSubscriptions); + if (subscriptions.length > 0) { + const exportableSubscriptions: ExportableSubscription[] = subscriptions.map(sub => ({ + ...sub, + tags: sub.tags ? sub.tags.join('|') : '', + createdAt: formatDateForExport(sub.createdAt), + updatedAt: formatDateForExport(sub.updatedAt), + })); + downloadCsv(Papa.unparse(exportableSubscriptions), 'goldquest_subscriptions.csv'); + } else { + console.log("No subscriptions data to export."); + } + // 8. Loans console.log("Exporting: Fetching Loans..."); const loans = await getLoans(); - const exportableLoans = loans.map(loan => ({ - ...loan, - createdAt: formatDateForExport(loan.createdAt), - updatedAt: formatDateForExport(loan.updatedAt), - })); - appendToCombinedCsv("LOANS", exportableLoans); + if (loans.length > 0) { + const exportableLoans = loans.map(loan => ({ + ...loan, + createdAt: formatDateForExport(loan.createdAt), + updatedAt: formatDateForExport(loan.updatedAt), + })); + downloadCsv(Papa.unparse(exportableLoans), 'goldquest_loans.csv'); + } else { + console.log("No loans data to export."); + } // 9. Credit Cards console.log("Exporting: Fetching Credit Cards..."); const creditCards = await getCreditCards(); - const exportableCreditCards = creditCards.map(card => ({ - ...card, - createdAt: formatDateForExport(card.createdAt), - updatedAt: formatDateForExport(card.updatedAt), - })); - appendToCombinedCsv("CREDIT CARDS", exportableCreditCards); + if (creditCards.length > 0) { + const exportableCreditCards = creditCards.map(card => ({ + ...card, + createdAt: formatDateForExport(card.createdAt), + updatedAt: formatDateForExport(card.updatedAt), + })); + downloadCsv(Papa.unparse(exportableCreditCards), 'goldquest_credit_cards.csv'); + } else { + console.log("No credit cards data to export."); + } + // 10. Budgets console.log("Exporting: Fetching Budgets..."); const budgets = await getBudgets(); - const exportableBudgets: ExportableBudget[] = budgets.map(b => ({ - ...b, - selectedIds: b.selectedIds ? b.selectedIds.join('|') : '', - createdAt: formatDateForExport(b.createdAt), - updatedAt: formatDateForExport(b.updatedAt), - })); - appendToCombinedCsv("BUDGETS", exportableBudgets); - - // Download the combined CSV - if (combinedCsvString.trim() !== "") { - downloadCsv(combinedCsvString, 'goldquest_full_backup.csv'); - console.log('Combined CSV data prepared for download.'); + if (budgets.length > 0) { + const exportableBudgets: ExportableBudget[] = budgets.map(b => ({ + ...b, + selectedIds: b.selectedIds ? b.selectedIds.join('|') : '', + createdAt: formatDateForExport(b.createdAt), + updatedAt: formatDateForExport(b.updatedAt), + })); + downloadCsv(Papa.unparse(exportableBudgets), 'goldquest_budgets.csv'); } else { - console.log('No data found to export.'); + console.log("No budgets data to export."); } + console.log('Individual CSV data files prepared for download.'); + } catch (error) { console.error("Error exporting all user data:", error); throw error; From 86b3d735d6a6beda2898387f76f8a788effb04c6 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 20 May 2025 22:40:28 +0000 Subject: [PATCH 013/156] Mude o nome da aba para Data Management --- src/app/data-management/page.tsx | 24 +- src/app/import/page.tsx | 1576 +----------------------- src/components/layout/auth-wrapper.tsx | 9 +- 3 files changed, 34 insertions(+), 1575 deletions(-) diff --git a/src/app/data-management/page.tsx b/src/app/data-management/page.tsx index 70217cc..1af3fff 100644 --- a/src/app/data-management/page.tsx +++ b/src/app/data-management/page.tsx @@ -16,7 +16,7 @@ import { getAccounts, addAccount, type Account, type NewAccountData, updateAccou import { getCategories, addCategory as addCategoryToDb, type Category } from '@/services/categories'; import { getTags, addTag as addTagToDb, type Tag } from '@/services/tags'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import { format, parseISO, isValid, parse as parseDateFns } from 'date-fns'; @@ -719,7 +719,7 @@ export default function DataManagementPage() { accountsToConsiderRaw.push({name: item.csvRawSourceName, type: item.csvSourceType, currency: item.currency}); } if (item.csvRawDestinationName && (item.csvDestinationType === 'asset account' || item.csvDestinationType === 'default asset account')) { - const destCurrency = (item.csvTransactionType === 'transfer' && item.foreignCurrency) ? item.foreignCurrency : item.currency; + const destCurrency = (item.csvTransactionType === 'transfer' && item.originalImportData?.foreignCurrency) ? item.originalImportData.foreignCurrency : item.currency; accountsToConsiderRaw.push({name: item.csvRawDestinationName, type: item.csvDestinationType, currency: destCurrency}); } @@ -1209,6 +1209,24 @@ export default function DataManagementPage() { } }; + const handleExportData = async () => { + if (!user) { + toast({ title: "Error", description: "User not authenticated.", variant: "destructive" }); + return; + } + setIsExporting(true); + toast({ title: "Exporting Data", description: "Preparing your data for download. This may take a moment..." }); + try { + await exportAllUserDataToCsvs(); + toast({ title: "Export Complete", description: "Your data files should be downloading now. Please check your browser's download folder." }); + } catch (error) { + console.error("Export failed:", error); + toast({ title: "Export Failed", description: "Could not export your data. Please try again.", variant: "destructive" }); + } finally { + setIsExporting(false); + } + }; + const handleTransactionFieldChange = ( originalIndexInData: number, @@ -1405,7 +1423,7 @@ export default function DataManagementPage() { - Cancel + setIsClearing(false)} disabled={isClearing}>Cancel {isClearing ? "Clearing..." : "Yes, Clear All My Data"} diff --git a/src/app/import/page.tsx b/src/app/import/page.tsx index a415a3d..939abe2 100644 --- a/src/app/import/page.tsx +++ b/src/app/import/page.tsx @@ -1,1579 +1,19 @@ 'use client'; -import { useState, useEffect, useMemo, useCallback } from 'react'; -import Papa, { ParseResult } from 'papaparse'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { useToast } from "@/hooks/use-toast"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Progress } from "@/components/ui/progress"; -import { addTransaction, type Transaction, clearAllSessionTransactions } from '@/services/transactions'; -import { getAccounts, addAccount, type Account, type NewAccountData, updateAccount } from '@/services/account-sync'; -import { getCategories, addCategory as addCategoryToDb, type Category } from '@/services/categories'; -import { getTags, addTag as addTagToDb, type Tag } from '@/services/tags'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from "@/components/ui/dialog"; -import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; - -import { format, parseISO, isValid, parse as parseDateFns } from 'date-fns'; -import { getCurrencySymbol, supportedCurrencies, formatCurrency, convertCurrency } from '@/lib/currency'; -import CsvMappingForm, { type ColumnMapping } from '@/components/import/csv-mapping-form'; -import { AlertCircle, Trash2, Download } from 'lucide-react'; -import { cn } from '@/lib/utils'; -import { useAuthContext } from '@/contexts/AuthContext'; -import Link from 'next/link'; -import { exportAllUserDataToCsvs } from '@/services/export'; - -type CsvRecord = { - [key: string]: string | undefined; -}; - -const APP_FIELDS_VALUES = [ - 'date', 'amount', 'foreign_amount', - 'description', - 'source_name', 'destination_name', 'source_type', 'destination_type', - 'category', 'currency_code', 'foreign_currency_code', - 'tags', 'notes', 'transaction_type', 'initialBalance' -] as const; - -type AppField = typeof APP_FIELDS_VALUES[number]; - -type MappedTransaction = { - csvRawSourceName?: string | null; - csvRawDestinationName?: string | null; - csvTransactionType?: string | null; - csvSourceType?: string | null; - csvDestinationType?: string | null; - - date: string; - amount: number; - description: string; - category: string; - currency: string; - foreignAmount?: number | null; - foreignCurrency?: string | null; - tags?: string[]; - originalRecord: Record; - importStatus: 'pending' | 'success' | 'error' | 'skipped'; - errorMessage?: string | null; - - appSourceAccountId?: string | null; - appDestinationAccountId?: string | null; - originalImportData?: { - foreignAmount?: number | null; - foreignCurrency?: string | null; - } -}; - - -interface AccountPreview { - name: string; - currency: string; - initialBalance: number; - action: 'create' | 'update' | 'no change'; - existingId?: string; - category: 'asset' | 'crypto'; -} - - -const findColumnName = (headers: string[], targetName: string): string | undefined => { - const normalizedTargetName = targetName.trim().toLowerCase(); - return headers.find(header => header?.trim().toLowerCase() === normalizedTargetName); -}; - - -const parseAmount = (amountStr: string | undefined): number => { - if (typeof amountStr !== 'string' || amountStr.trim() === '') return NaN; - let cleaned = amountStr.replace(/[^\d.,-]/g, '').trim(); - - const hasPeriod = cleaned.includes('.'); - const hasComma = cleaned.includes(','); - - if (hasComma && hasPeriod) { - if (cleaned.lastIndexOf(',') > cleaned.lastIndexOf('.')) { - cleaned = cleaned.replace(/\./g, '').replace(',', '.'); - } else { - cleaned = cleaned.replace(/,/g, ''); - } - } else if (hasComma) { - cleaned = cleaned.replace(',', '.'); - } - - const dotMatches = cleaned.match(/\./g); - if (dotMatches && dotMatches.length > 1) { - const lastDotIndex = cleaned.lastIndexOf('.'); - const partAfterLastDot = cleaned.substring(lastDotIndex + 1); - if (partAfterLastDot.length < 3 || partAfterLastDot.match(/^\d+$/) ) { - cleaned = cleaned.substring(0, lastDotIndex).replace(/\./g, '') + '.' + partAfterLastDot; - } else { - cleaned = cleaned.replace(/\./g, ''); - } - } - - if (cleaned.endsWith('.') || cleaned.endsWith(',')) { - cleaned += '0'; - } - cleaned = cleaned.replace(/^[,.]+|[,.]+$/g, ''); - - const parsed = parseFloat(cleaned); - return parsed; -}; - - -const parseDate = (dateStr: string | undefined): string => { - if (!dateStr) return format(new Date(), 'yyyy-MM-dd'); - try { - let parsedDate = parseISO(dateStr); - if (isValid(parsedDate)) { - return format(parsedDate, 'yyyy-MM-dd'); - } - - const commonFormats = [ - "yyyy-MM-dd'T'HH:mm:ssXXX", "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", "yyyy-MM-dd'T'HH:mm:ss", - 'dd/MM/yyyy HH:mm:ss', 'MM/dd/yyyy HH:mm:ss', 'yyyy-MM-dd HH:mm:ss', - 'dd/MM/yyyy', 'MM/dd/yyyy', 'yyyy-MM-dd', - 'dd.MM.yyyy', 'MM.dd.yyyy', - 'dd-MM-yyyy', 'MM-dd-yyyy', - 'yyyy/MM/dd', 'yyyy/dd/MM', - ]; - - for (const fmt of commonFormats) { - try { - parsedDate = parseDateFns(dateStr, fmt, new Date()); - if (isValid(parsedDate)) return format(parsedDate, 'yyyy-MM-dd'); - - const datePartOnly = dateStr.split('T')[0].split(' ')[0]; - const dateFormatOnly = fmt.split('T')[0].split(' ')[0]; - if (datePartOnly !== dateStr && dateFormatOnly !== fmt) { - parsedDate = parseDateFns(datePartOnly, dateFormatOnly, new Date()); - if (isValid(parsedDate)) return format(parsedDate, 'yyyy-MM-dd'); - } - } catch { /* ignore parse error for this format, try next */ } - } - - parsedDate = new Date(dateStr); - if (isValid(parsedDate)) { - return format(parsedDate, 'yyyy-MM-dd'); - } - - } catch (e) { - console.error("Error parsing date:", dateStr, e); - } - console.warn(`Could not parse date "${dateStr}", defaulting to today.`); - return format(new Date(), 'yyyy-MM-dd'); -}; - -const parseNameFromDescriptiveString = (text: string | undefined): string | undefined => { - if (!text) return undefined; - const match = text.match(/(?:Initial balance for |Saldo inicial para(?: d[aeo] conta)?)\s*["']?([^"':]+)(?:["']?|$)/i); - return match ? match[1]?.trim() : undefined; -}; - - -export default function DataManagementPage() { - const { user, isLoadingAuth } = useAuthContext(); - const [file, setFile] = useState(null); - const [csvHeaders, setCsvHeaders] = useState([]); - const [rawData, setRawData] = useState([]); - const [parsedData, setParsedData] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [importProgress, setImportProgress] = useState(0); - const [error, setError] = useState(null); - const [accounts, setAccounts] = useState([]); - const [categories, setCategories] = useState([]); - const [tags, setTags] = useState([]); - const [accountPreviewData, setAccountPreviewData] = useState([]); - const [finalAccountMapForImport, setFinalAccountMapForImport] = useState<{ [key: string]: string }>({}); - const [isMappingDialogOpen, setIsMappingDialogOpen] = useState(false); - const [columnMappings, setColumnMappings] = useState({}); - const [isClearing, setIsClearing] = useState(false); - const [isExporting, setIsExporting] = useState(false); - const { toast } = useToast(); +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; +export default function OldImportPageRedirect() { + const router = useRouter(); useEffect(() => { - if (isLoadingAuth || !user) { - setIsLoading(false); - return; - } - - let isMounted = true; - const fetchData = async () => { - if (!isMounted) return; - setIsLoading(true); - setError(null); - try { - const [fetchedAccounts, fetchedCategories, fetchedTagsList] = await Promise.all([ - getAccounts(), - getCategories(), - getTags() - ]); - - if (isMounted) { - setAccounts(fetchedAccounts); - setCategories(fetchedCategories); - setTags(fetchedTagsList); - } - } catch (err: any) { - console.error("Failed to fetch initial data for Data Management page:", err); - if (isMounted) { - setError("Could not load essential page data. Please try refreshing. Details: " + err.message); - toast({ title: "Page Load Error", description: "Failed to load initial data (accounts, categories, or tags). " + err.message, variant: "destructive" }); - } - } finally { - if (isMounted) { - setIsLoading(false); - } - } - }; - - fetchData(); - return () => { isMounted = false; }; - }, [user, isLoadingAuth]); - - const handleFileChange = (event: React.ChangeEvent) => { - if (event.target.files && event.target.files[0]) { - setFile(event.target.files[0]); - setError(null); - setParsedData([]); - setAccountPreviewData([]); - setRawData([]); - setCsvHeaders([]); - setImportProgress(0); - setColumnMappings({}); - setFinalAccountMapForImport({}); - } - }; - - const handleParseAndMap = () => { - if (!file) { - setError("Please select a CSV file first."); - return; - } - if (!user) { - setError("You must be logged in to import data."); - toast({ title: "Authentication Required", description: "Please log in to import data.", variant: "destructive"}); - return; - } - - setIsLoading(true); - setError(null); - setParsedData([]); - setAccountPreviewData([]); - setRawData([]); - setCsvHeaders([]); - - Papa.parse(file, { - header: true, - skipEmptyLines: true, - complete: (results: ParseResult) => { - if (results.errors.length > 0 && !results.data.length) { - const criticalError = results.errors.find(e => e.code !== 'TooManyFields' && e.code !== 'TooFewFields') || results.errors[0]; - setError(`CSV Parsing Error: ${criticalError.message}. Code: ${criticalError.code}. Ensure headers are correct and file encoding is UTF-8.`); - setIsLoading(false); - return; - } - if (results.errors.length > 0) { - console.warn("Minor CSV parsing errors encountered:", results.errors); - toast({ title: "CSV Parsing Warning", description: `Some rows might have issues: ${results.errors.map(e=>e.message).slice(0,2).join('; ')}`, variant:"default", duration: 7000}); - } - - if (!results.data || results.data.length === 0) { - setError("CSV file is empty or doesn't contain valid data rows."); - setIsLoading(false); - return; - } - - const headers = results.meta.fields; - if (!headers || headers.length === 0) { - setError("Could not read CSV headers. Ensure the first row contains column names."); - setIsLoading(false); - return; - } - - setCsvHeaders(headers.filter(h => h != null) as string[]); - setRawData(results.data); - - const detectedHeaders = headers.filter(h => h != null) as string[]; - const initialMappings: ColumnMapping = {}; - - initialMappings.date = findColumnName(detectedHeaders, 'date'); - initialMappings.amount = findColumnName(detectedHeaders, 'amount'); - initialMappings.description = findColumnName(detectedHeaders, 'description'); - initialMappings.source_name = findColumnName(detectedHeaders, 'source_name'); - initialMappings.destination_name = findColumnName(detectedHeaders, 'destination_name'); - initialMappings.currency_code = findColumnName(detectedHeaders, 'currency_code') || findColumnName(detectedHeaders, 'currency'); - initialMappings.category = findColumnName(detectedHeaders, 'category'); - initialMappings.tags = findColumnName(detectedHeaders, 'tags'); - initialMappings.transaction_type = findColumnName(detectedHeaders, 'type'); - initialMappings.notes = findColumnName(detectedHeaders, 'notes'); - initialMappings.foreign_amount = findColumnName(detectedHeaders, 'foreign_amount'); - initialMappings.foreign_currency_code = findColumnName(detectedHeaders, 'foreign_currency_code'); - initialMappings.source_type = findColumnName(detectedHeaders, 'source_type'); - initialMappings.destination_type = findColumnName(detectedHeaders, 'destination_type'); - initialMappings.initialBalance = findColumnName(detectedHeaders, 'initial_balance') || findColumnName(detectedHeaders, 'opening_balance'); - - - setColumnMappings(initialMappings); - setIsMappingDialogOpen(true); - setIsLoading(false); - }, - error: (err: Error) => { - setError(`Failed to read or parse CSV file: ${err.message}.`); - setIsLoading(false); - } - }); - }; - - const processAndMapData = async (confirmedMappings: ColumnMapping) => { - setIsLoading(true); - setError(null); - setParsedData([]); - setAccountPreviewData([]); - setColumnMappings(confirmedMappings); - setFinalAccountMapForImport({}); - - const coreRequiredFields: AppField[] = ['date', 'amount', 'currency_code', 'transaction_type']; - let missingFieldLabels = coreRequiredFields - .filter(field => !confirmedMappings[field]) - .map(field => APP_FIELDS_VALUES.find(val => val === field) || field); - - if (confirmedMappings.transaction_type) { - if (!confirmedMappings.source_name) missingFieldLabels.push('source_name (e.g., Firefly \'source_name\')'); - if (!confirmedMappings.destination_name) missingFieldLabels.push('destination_name (e.g., Firefly \'destination_name\')'); - } else { - missingFieldLabels.push('transaction_type (e.g., Firefly \'type\')'); - } - - missingFieldLabels = [...new Set(missingFieldLabels)]; - - if (missingFieldLabels.length > 0) { - setError(`Missing required column mappings for import: ${missingFieldLabels.join(', ')}. Please map these fields.`); - setIsLoading(false); - setIsMappingDialogOpen(true); - return; - } - - const currentAccounts = await getAccounts(); - setAccounts(currentAccounts); - - const { preview } = await previewAccountChanges( - rawData, - confirmedMappings, - currentAccounts - ); - setAccountPreviewData(preview); - console.log("Account preview generated:", preview); - - const mapped: MappedTransaction[] = rawData.map((record, index) => { - const rowNumber = index + 2; - - const dateCol = confirmedMappings.date!; - const amountCol = confirmedMappings.amount!; - const currencyCol = confirmedMappings.currency_code!; - const descCol = confirmedMappings.description; - const categoryCol = confirmedMappings.category; - const tagsCol = confirmedMappings.tags; - const notesCol = confirmedMappings.notes; - const typeCol = confirmedMappings.transaction_type!; - const sourceNameCol = confirmedMappings.source_name!; - const destNameCol = confirmedMappings.destination_name!; - const foreignAmountCol = confirmedMappings.foreign_amount; - const foreignCurrencyCol = confirmedMappings.foreign_currency_code; - const sourceTypeCol = confirmedMappings.source_type; - const destTypeCol = confirmedMappings.destination_type; - const initialBalanceCol = confirmedMappings.initialBalance; - - const sanitizedRecord: Record = {}; - for (const key in record) { - if (Object.prototype.hasOwnProperty.call(record, key)) { - sanitizedRecord[key] = record[key] === undefined ? null : record[key]!; - } - } - - try { - const csvTypeRaw = record[typeCol]; - const csvType = csvTypeRaw?.trim().toLowerCase(); - - const dateValue = record[dateCol]; - let amountValue = record[amountCol]; - const currencyValue = record[currencyCol]; - const foreignAmountValue = foreignAmountCol ? record[foreignAmountCol] : undefined; - const foreignCurrencyValue = foreignCurrencyCol ? record[foreignCurrencyCol] : undefined; - - const descriptionValue = descCol ? record[descCol] || '' : ''; - const categoryValue = categoryCol ? record[categoryCol] || 'Uncategorized' : 'Uncategorized'; - const tagsValue = tagsCol ? record[tagsCol] || '' : ''; - const notesValue = notesCol ? record[notesCol] || '' : ''; - - let rawSourceName = record[sourceNameCol]?.trim(); - let rawDestName = record[destNameCol]?.trim(); - let rawSourceType = sourceTypeCol ? record[sourceTypeCol]?.trim().toLowerCase() : undefined; - let rawDestType = destTypeCol ? record[destTypeCol]?.trim().toLowerCase() : undefined; - - if (!dateValue) throw new Error(`Row ${rowNumber}: Missing mapped 'Date' data.`); - if (amountValue === undefined || amountValue.trim() === '') throw new Error(`Row ${rowNumber}: Missing or empty 'Amount' data.`); - if (!currencyValue || currencyValue.trim() === '') throw new Error(`Row ${rowNumber}: Missing or empty 'Currency Code' data.`); - if (!csvType) throw new Error(`Row ${rowNumber}: Missing or empty 'Transaction Type' (Firefly 'type') data.`); - - if (csvType === 'withdrawal' || csvType === 'transfer') { - if (!rawSourceName) throw new Error(`Row ${rowNumber}: Missing 'Source Name' for type '${csvType}'.`); - } - if (csvType === 'deposit' || csvType === 'transfer') { - if (!rawDestName) throw new Error(`Row ${rowNumber}: Missing 'Destination Name' for type '${csvType}'.`); - } - if (csvType === 'transfer' && rawSourceName && rawDestName && rawSourceName.toLowerCase() === rawDestName.toLowerCase()) { - if (rawSourceType?.includes('asset') && rawDestType?.includes('asset')) { - throw new Error(`Row ${rowNumber}: Transfer source and destination asset accounts are the same ('${rawSourceName}').`); - } - } - - const parsedAmount = parseAmount(amountValue); - if (isNaN(parsedAmount)) throw new Error(`Row ${rowNumber}: Could not parse amount "${amountValue}".`); - - let tempParsedForeignAmount: number | null = null; - if (foreignAmountValue !== undefined && foreignAmountValue.trim() !== "") { - const tempAmount = parseAmount(foreignAmountValue); - if (!Number.isNaN(tempAmount)) { - tempParsedForeignAmount = tempAmount; - } else if (foreignAmountValue.trim() !== '') { - console.warn(`Row ${rowNumber}: Could not parse foreign amount "${foreignAmountValue}". It will be ignored.`); - } - } - const finalParsedForeignAmount = tempParsedForeignAmount; - - let finalParsedForeignCurrency: string | null = null; - if (foreignCurrencyCol && record[foreignCurrencyCol] && record[foreignCurrencyCol]!.trim() !== '') { - finalParsedForeignCurrency = record[foreignCurrencyCol]!.trim().toUpperCase() || null; - } - if (finalParsedForeignCurrency === "") finalParsedForeignCurrency = null; - - const parsedDate = parseDate(dateValue); - const parsedTags = tagsValue.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0); - - let finalDescription = descriptionValue.trim(); - if (notesValue.trim()) { - finalDescription = finalDescription ? `${finalDescription} (Notes: ${notesValue.trim()})` : `Notes: ${notesValue.trim()}`; - } - - if (!finalDescription && csvType === 'withdrawal' && rawDestName) finalDescription = `To: ${rawDestName}`; - if (!finalDescription && csvType === 'deposit' && rawSourceName) finalDescription = `From: ${rawSourceName}`; - if (!finalDescription && csvType === 'transfer') finalDescription = `Transfer: ${rawSourceName} to ${rawDestName}`; - if (!finalDescription) finalDescription = 'Imported Transaction'; - - if (csvType === 'opening balance') { - let actualAccountNameForOB: string | undefined; - let initialBalanceValue = initialBalanceCol ? record[initialBalanceCol] : amountValue; - let parsedInitialBalance = parseAmount(initialBalanceValue); - - if(isNaN(parsedInitialBalance)) { - throw new Error(`Row ${rowNumber}: Could not parse initial balance for 'opening balance'. Value was: '${initialBalanceValue}'.`) - } - - if (rawDestType === "asset account" && rawDestName) { - actualAccountNameForOB = rawDestName; - } else if (rawSourceType === "asset account" && rawSourceName) { - actualAccountNameForOB = rawSourceName; - } else { - const parsedFromNameInDest = parseNameFromDescriptiveString(rawDestName); - const parsedFromNameInSource = parseNameFromDescriptiveString(rawSourceName); - - if (parsedFromNameInDest) actualAccountNameForOB = parsedFromNameInDest; - else if (parsedFromNameInSource) actualAccountNameForOB = parsedFromNameInSource; - else actualAccountNameForOB = rawDestName || rawSourceName; - } - - if (!actualAccountNameForOB) { - throw new Error(`Row ${rowNumber}: Could not determine account name for 'opening balance'. Source: ${rawSourceName}, Dest: ${rawDestName}, SourceType: ${rawSourceType}, DestType: ${rawDestType}`); - } - - return { - csvRawSourceName: rawSourceName ?? null, - csvRawDestinationName: actualAccountNameForOB ?? null, - csvTransactionType: csvType, - csvSourceType: rawSourceType, - csvDestinationType: rawDestType, - date: parsedDate, - amount: parsedInitialBalance, - currency: currencyValue.trim().toUpperCase(), - foreignAmount: finalParsedForeignAmount, - foreignCurrency: finalParsedForeignCurrency, - description: `Opening Balance: ${actualAccountNameForOB}`, - category: 'Opening Balance', - tags: [], - originalRecord: sanitizedRecord, - importStatus: 'skipped', - errorMessage: `Opening Balance for ${actualAccountNameForOB} (${formatCurrency(parsedInitialBalance, currencyValue.trim().toUpperCase(), undefined, false)}) - Will be set as initial balance during account creation/update.`, - appSourceAccountId: null, - appDestinationAccountId: null, - originalImportData: { foreignAmount: finalParsedForeignAmount, foreignCurrency: finalParsedForeignCurrency }, - }; - } - - return { - csvRawSourceName: rawSourceName ?? null, - csvRawDestinationName: rawDestName ?? null, - csvTransactionType: csvType ?? null, - csvSourceType: rawSourceType, - csvDestinationType: rawDestType, - date: parsedDate, - amount: parsedAmount, - currency: currencyValue.trim().toUpperCase(), - foreignAmount: finalParsedForeignAmount, - foreignCurrency: finalParsedForeignCurrency, - description: finalDescription, - category: categoryValue.trim(), - tags: parsedTags, - originalRecord: sanitizedRecord, - importStatus: 'pending', - errorMessage: null, - appSourceAccountId: null, - appDestinationAccountId: null, - originalImportData: { foreignAmount: finalParsedForeignAmount, foreignCurrency: finalParsedForeignCurrency }, - }; - - } catch (rowError: any) { - console.error(`Error processing row ${index + 2} with mappings:`, confirmedMappings, `and record:`, record, `Error:`, rowError); - const errorSanitizedRecord: Record = {}; - for (const key in record) { - if (Object.prototype.hasOwnProperty.call(record, key)) { - errorSanitizedRecord[key] = record[key] === undefined ? null : record[key]!; - } - } - return { - date: parseDate(record[dateCol!]), - amount: 0, - currency: record[currencyCol!]?.trim().toUpperCase() || 'N/A', - description: `Error Processing Row ${index + 2}`, - category: 'Uncategorized', - tags: [], - originalRecord: errorSanitizedRecord, - importStatus: 'error', - errorMessage: rowError.message || 'Failed to process row.', - csvRawSourceName: (sourceNameCol && record[sourceNameCol] ? record[sourceNameCol]?.trim() : undefined) ?? null, - csvRawDestinationName: (destNameCol && record[destNameCol] ? record[destNameCol]?.trim() : undefined) ?? null, - csvTransactionType: (typeCol && record[typeCol] ? record[typeCol]?.trim().toLowerCase() : undefined) ?? null, - csvSourceType: (sourceTypeCol && record[sourceTypeCol] ? record[sourceTypeCol]?.trim().toLowerCase() : undefined) ?? null, - csvDestinationType: (destTypeCol && record[destTypeCol] ? record[destTypeCol]?.trim().toLowerCase() : undefined) ?? null, - foreignAmount: null, - foreignCurrency: null, - appSourceAccountId: null, - appDestinationAccountId: null, - originalImportData: { foreignAmount: null, foreignCurrency: null }, - }; - } - }); - - const errorMappedData = mapped.filter(item => item.importStatus === 'error'); - if (errorMappedData.length > 0) { - setError(`${errorMappedData.length} row(s) had processing errors. Review the tables below.`); - } else { - setError(null); - } - - setParsedData(mapped); - setIsLoading(false); - setIsMappingDialogOpen(false); - toast({ title: "Mapping Applied", description: `Previewing ${mapped.filter(m => m.importStatus === 'pending' || m.csvTransactionType === 'opening balance').length} data points and account changes. Review before importing.` }); - } - - - const previewAccountChanges = async ( - csvData: CsvRecord[], - mappings: ColumnMapping, - existingAccountsParam: Account[] - ): Promise<{ preview: AccountPreview[] }> => { - - const mappedTransactions = csvData.map(record => { - const type = record[mappings.transaction_type!]?.trim().toLowerCase(); - const sourceName = record[mappings.source_name!]?.trim(); - const destName = record[mappings.destination_name!]?.trim(); - const sourceType = record[mappings.source_type!]?.trim().toLowerCase(); - const destType = record[mappings.destination_type!]?.trim().toLowerCase(); - const currency = record[mappings.currency_code!]?.trim().toUpperCase(); - const amount = parseAmount(record[mappings.amount!]); - const initialBalance = parseAmount(record[mappings.initialBalance!] || record[mappings.amount!]); - - return { - csvTransactionType: type, - csvRawSourceName: sourceName, - csvRawDestinationName: destName, - csvSourceType: sourceType, - csvDestinationType: destType, - currency: currency, - amount: type === 'opening balance' ? initialBalance : amount, - }; - }) as Partial[]; - - const accountDetailsMap = await buildAccountUpdateMap(mappedTransactions, existingAccountsParam); - - const preview: AccountPreview[] = []; - const processedAccountNames = new Set(); - - accountDetailsMap.forEach((details, normalizedName) => { - const existingAccount = existingAccountsParam.find(acc => acc.name.toLowerCase() === normalizedName); - let action: AccountPreview['action'] = 'no change'; - let finalBalance = details.initialBalance !== undefined ? details.initialBalance : (existingAccount?.balance ?? 0); - - if (existingAccount) { - if (details.currency !== existingAccount.currency || (details.initialBalance !== undefined && details.initialBalance !== existingAccount.balance)) { - action = 'update'; - } - preview.push({ - name: details.name, - currency: details.currency, - initialBalance: finalBalance, - action: action, - existingId: existingAccount.id, - category: existingAccount.category, - }); - } else { - preview.push({ - name: details.name, - currency: details.currency, - initialBalance: finalBalance, - action: 'create', - category: details.category || 'asset', - }); - } - processedAccountNames.add(normalizedName); - }); - - existingAccountsParam.forEach(acc => { - if (!processedAccountNames.has(acc.name.toLowerCase())) { - preview.push({ - name: acc.name, - currency: acc.currency, - initialBalance: acc.balance, - action: 'no change', - existingId: acc.id, - category: acc.category, - }); - } - }); - return { preview }; - }; - - - const buildAccountUpdateMap = async ( - mappedCsvData: Partial[], - existingAccountsParam: Account[] - ): Promise> => { - const accountMap = new Map(); - - for (const item of mappedCsvData) { - if (item.csvTransactionType === 'opening balance') { - let accountNameForOB: string | undefined; - const recordCurrency = item.currency; - const recordAmount = item.amount; - - const descriptiveDestName = item.csvRawDestinationName; - const descriptiveSourceName = item.csvRawSourceName; - - const parsedNameFromDest = parseNameFromDescriptiveString(descriptiveDestName); - const parsedNameFromSource = parseNameFromDescriptiveString(descriptiveSourceName); - - if (item.csvDestinationType === "asset account" && descriptiveDestName && !parseNameFromDescriptiveString(descriptiveDestName) ) { - accountNameForOB = descriptiveDestName; - } else if (item.csvSourceType === "asset account" && descriptiveSourceName && !parseNameFromDescriptiveString(descriptiveSourceName)) { - accountNameForOB = descriptiveSourceName; - } else if (parsedNameFromDest) { - accountNameForOB = parsedNameFromDest; - } else if (parsedNameFromSource) { - accountNameForOB = parsedNameFromSource; - } else { - accountNameForOB = descriptiveDestName || descriptiveSourceName; - } - - if (accountNameForOB && recordCurrency && recordAmount !== undefined && !isNaN(recordAmount)) { - const normalizedName = accountNameForOB.toLowerCase().trim(); - const existingDetailsInMap = accountMap.get(normalizedName); - let accountCategory: 'asset' | 'crypto' = 'asset'; - - const sourceIsDescriptive = item.csvRawSourceName && parseNameFromDescriptiveString(item.csvRawSourceName); - const destIsDescriptive = item.csvRawDestinationName && parseNameFromDescriptiveString(item.csvRawDestinationName); - - if (item.csvDestinationType === "asset account" && !destIsDescriptive && item.csvRawDestinationName?.toLowerCase().includes('crypto')) { - accountCategory = 'crypto'; - } else if (item.csvSourceType === "asset account" && !sourceIsDescriptive && item.csvRawSourceName?.toLowerCase().includes('crypto')) { - accountCategory = 'crypto'; - } else if (item.csvDestinationType?.includes('crypto') || item.csvSourceType?.includes('crypto') || accountNameForOB.toLowerCase().includes('crypto') || accountNameForOB.toLowerCase().includes('wallet')) { - accountCategory = 'crypto'; - } - - accountMap.set(normalizedName, { - name: accountNameForOB, - currency: recordCurrency, - initialBalance: recordAmount, - category: existingDetailsInMap?.category || accountCategory, - }); - } - } else if (item.csvTransactionType === 'withdrawal' || item.csvTransactionType === 'deposit' || item.csvTransactionType === 'transfer') { - const accountsToConsiderRaw: {name?: string | null, type?: string | null, currency?: string}[] = []; - - if (item.csvRawSourceName && (item.csvSourceType === 'asset account' || item.csvSourceType === 'default asset account')) { - accountsToConsiderRaw.push({name: item.csvRawSourceName, type: item.csvSourceType, currency: item.currency}); - } - if (item.csvRawDestinationName && (item.csvDestinationType === 'asset account' || item.csvDestinationType === 'default asset account')) { - const destCurrency = (item.csvTransactionType === 'transfer' && item.foreignCurrency) ? item.foreignCurrency : item.currency; - accountsToConsiderRaw.push({name: item.csvRawDestinationName, type: item.csvDestinationType, currency: destCurrency}); - } - - const uniqueAccountNamesAndCurrencies = accountsToConsiderRaw.filter( - (value, index, self) => value.name && self.findIndex(t => t.name?.toLowerCase().trim() === value.name?.toLowerCase().trim()) === index - ); - - for (const accInfo of uniqueAccountNamesAndCurrencies) { - if (accInfo.name && accInfo.currency) { - const normalizedName = accInfo.name.toLowerCase().trim(); - let category: 'asset' | 'crypto' = 'asset'; - if (accInfo.name.toLowerCase().includes('crypto') || - accInfo.name.toLowerCase().includes('wallet') || - (accInfo.type && accInfo.type.includes('crypto')) ) { - category = 'crypto'; - } - - if (!accountMap.has(normalizedName)) { - const existingAppAccount = existingAccountsParam.find(a => a.name.toLowerCase() === normalizedName); - accountMap.set(normalizedName, { - name: accInfo.name, - currency: existingAppAccount?.currency || accInfo.currency, - initialBalance: existingAppAccount?.balance, - category: existingAppAccount?.category || category, - }); - } else { - const currentDetails = accountMap.get(normalizedName)!; - if (!currentDetails.currency && accInfo.currency) currentDetails.currency = accInfo.currency; - if (currentDetails.category === 'asset' && category === 'crypto') { - currentDetails.category = 'crypto'; - } - } - } - } - } - } - return accountMap; - }; - - const createOrUpdateAccountsAndGetMap = async ( - isPreviewOnly: boolean = false - ): Promise<{ success: boolean; map: { [key: string]: string }, updatedAccountsList: Account[] }> => { - let success = true; - let currentAppAccounts = [...accounts]; - - const workingMap = currentAppAccounts.reduce((map, acc) => { - map[acc.name.toLowerCase().trim()] = acc.id; - return map; - }, {} as { [key: string]: string }); - - if (accountPreviewData.length === 0 && !isPreviewOnly) { - return { success: true, map: workingMap, updatedAccountsList: currentAppAccounts }; - } - - let accountsProcessedCount = 0; - - for (const accPreview of accountPreviewData) { - const normalizedName = accPreview.name.toLowerCase().trim(); - try { - if (isPreviewOnly) { - if (accPreview.existingId) { - workingMap[normalizedName] = accPreview.existingId; - } else if (accPreview.action === 'create') { - workingMap[normalizedName] = `preview_create_${normalizedName.replace(/\s+/g, '_')}`; - } - continue; - } - - if (accPreview.action === 'create') { - const newAccountData: NewAccountData = { - name: accPreview.name, - type: (accPreview.category === 'crypto' ? 'wallet' : 'checking'), - balance: accPreview.initialBalance, - currency: accPreview.currency, - providerName: 'Imported - ' + accPreview.name, - category: accPreview.category, - isActive: true, - lastActivity: new Date().toISOString(), - balanceDifference: 0, - includeInNetWorth: true, - }; - const createdAccount = await addAccount(newAccountData); - workingMap[normalizedName] = createdAccount.id; - currentAppAccounts.push(createdAccount); - accountsProcessedCount++; - } else if (accPreview.action === 'update' && accPreview.existingId) { - const existingAccountForUpdate = currentAppAccounts.find(a => a.id === accPreview.existingId); - if (existingAccountForUpdate) { - const updatedAccountData: Account = { - ...existingAccountForUpdate, - balance: accPreview.initialBalance, - currency: accPreview.currency, - lastActivity: new Date().toISOString(), - category: accPreview.category, - includeInNetWorth: existingAccountForUpdate.includeInNetWorth ?? true, - }; - const savedUpdatedAccount = await updateAccount(updatedAccountData); - accountsProcessedCount++; - const idx = currentAppAccounts.findIndex(a => a.id === savedUpdatedAccount.id); - if (idx !== -1) currentAppAccounts[idx] = savedUpdatedAccount; - else currentAppAccounts.push(savedUpdatedAccount); - - workingMap[normalizedName] = savedUpdatedAccount.id; - } - } else if (accPreview.existingId) { - workingMap[normalizedName] = accPreview.existingId; - } - - } catch (err: any) { - console.error(`Failed to process account "${accPreview.name}":`, err); - toast({ title: "Account Processing Error", description: `Could not process account "${accPreview.name}". Error: ${err.message}`, variant: "destructive", duration: 7000 }); - success = false; - } - } - - if (accountsProcessedCount > 0 && !isPreviewOnly) { - toast({ title: "Accounts Processed", description: `Created or updated ${accountsProcessedCount} accounts based on CSV data.` }); - } - - if (!isPreviewOnly && accountsProcessedCount > 0) { - const finalFetchedAccounts = await getAccounts(); - setAccounts(finalFetchedAccounts); - const finalMap = finalFetchedAccounts.reduce((map, acc) => { - map[acc.name.toLowerCase().trim()] = acc.id; - return map; - }, {} as { [key: string]: string }); - return { success, map: finalMap, updatedAccountsList: finalFetchedAccounts }; - } - - return { success, map: workingMap, updatedAccountsList: currentAppAccounts }; - }; - - - const addMissingCategoriesAndTags = async (transactionsToProcess: MappedTransaction[]): Promise => { - const currentCategoriesList = await getCategories(); - const existingCategoryNames = new Set(currentCategoriesList.map(cat => cat.name.toLowerCase())); - const categoriesToAdd = new Set(); - - const currentTagsList = await getTags(); - const existingTagNames = new Set(currentTagsList.map(tag => tag.name.toLowerCase())); - const tagsToAdd = new Set(); - - let success = true; - - transactionsToProcess.forEach(tx => { - if (tx.importStatus === 'pending') { - if (tx.category && !['Uncategorized', 'Initial Balance', 'Transfer', 'Skipped', 'Opening Balance'].includes(tx.category)) { - const categoryName = tx.category.trim(); - if (categoryName && !existingCategoryNames.has(categoryName.toLowerCase())) { - categoriesToAdd.add(categoryName); - } - } - - if (tx.tags && tx.tags.length > 0) { - tx.tags.forEach(tagName => { - const trimmedTag = tagName.trim(); - if (trimmedTag && !existingTagNames.has(trimmedTag.toLowerCase())) { - tagsToAdd.add(trimmedTag); - } - }); - } - } - }); - - if (categoriesToAdd.size > 0) { - let categoriesAddedCount = 0; - const addCatPromises = Array.from(categoriesToAdd).map(async (catName) => { - try { - await addCategoryToDb(catName); - categoriesAddedCount++; - } catch (err: any) { - if (!err.message?.includes('already exists')) { - console.error(`Failed to add category "${catName}":`, err); - toast({ title: "Category Add Error", description: `Could not add category "${catName}". Error: ${err.message}`, variant: "destructive" }); - success = false; - } - } - }); - await Promise.all(addCatPromises); - if (categoriesAddedCount > 0) { - toast({ title: "Categories Added", description: `Added ${categoriesAddedCount} new categories.` }); - try { setCategories(await getCategories()); } catch { console.error("Failed to refetch categories after add."); } - } - } - - if (tagsToAdd.size > 0) { - let tagsAddedCount = 0; - const addTagPromises = Array.from(tagsToAdd).map(async (tagName) => { - try { - await addTagToDb(tagName); - tagsAddedCount++; - } catch (err: any) { - if (!err.message?.includes('already exists')) { - console.error(`Failed to add tag "${tagName}":`, err); - toast({ title: "Tag Add Error", description: `Could not add tag "${tagName}". Error: ${err.message}`, variant: "destructive" }); - success = false; - } - } - }); - await Promise.all(addTagPromises); - if (tagsAddedCount > 0) { - toast({ title: "Tags Added", description: `Added ${tagsAddedCount} new tags.` }); - try { setTags(await getTags()); } catch { console.error("Failed to refetch tags after add."); } - } - } - return success; - }; - - - const handleImport = async () => { - if (!user) { - toast({ title: "Authentication Required", description: "Please log in to import.", variant: "destructive" }); - return; - } - const recordsToImport = parsedData.filter(item => item.importStatus === 'pending'); - if (recordsToImport.length === 0) { - setError(parsedData.some(d => d.importStatus === 'error' || d.importStatus === 'skipped') ? "No pending records to import. Check rows marked as 'Error' or 'Skipped'." : "No data parsed or mapped correctly for import."); - toast({ title: "Import Info", description: "No pending transactions to import.", variant: "default" }); - return; - } - - setIsLoading(true); - setImportProgress(0); - setError(null); - let overallError = false; - - let finalMapForTxImport: { [key: string]: string }; - let latestAccountsList: Account[]; - try { - const accountMapResult = await createOrUpdateAccountsAndGetMap(false); - if (!accountMapResult.success) { - setError("Error processing some accounts during import. Some accounts might not have been created/updated correctly. Review account preview and transaction statuses."); - } - finalMapForTxImport = accountMapResult.map; - latestAccountsList = accountMapResult.updatedAccountsList; - setAccounts(latestAccountsList); - setFinalAccountMapForImport(finalMapForTxImport); - } catch (finalAccountMapError) { - console.error("Critical error during account finalization before import.", finalAccountMapError); - toast({ title: "Account Sync Error", description: "Could not synchronize accounts with the database before starting transaction import. Please try again.", variant: "destructive"}); - setIsLoading(false); - return; - } - - const metadataSuccess = await addMissingCategoriesAndTags(recordsToImport); - if (!metadataSuccess) { - setError("Error adding new categories or tags from CSV. Some transactions might use 'Uncategorized' or miss tags. Import halted to ensure data integrity."); - setIsLoading(false); - return; - } - - const currentCategoriesList = await getCategories(); - const currentTagsList = await getTags(); - - const totalToImport = recordsToImport.length; - let importedCount = 0; - let errorCount = 0; - const updatedDataForDisplay = [...parsedData]; - - const transactionPayloads: (Omit & { originalMappedTx: MappedTransaction })[] = []; - - for (const item of recordsToImport) { - const rowNumber = rawData.indexOf(item.originalRecord) + 2; - const itemIndexInDisplay = updatedDataForDisplay.findIndex(d => d.originalRecord === item.originalRecord && d.description === item.description && d.amount === item.amount && d.date === item.date); - - if (item.csvTransactionType === 'opening balance') { - if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'skipped', errorMessage: 'Opening Balance (handled via account balance creation/update)' }; - continue; - } - - const transactionCategory = currentCategoriesList.find(c => c.name.toLowerCase() === item.category.toLowerCase())?.name || 'Uncategorized'; - const transactionTags = item.tags?.map(tName => currentTagsList.find(t => t.name.toLowerCase() === tName.toLowerCase())?.name || tName).filter(Boolean) || []; - - if (item.csvTransactionType === 'transfer') { - const csvAmount = item.amount; - const csvCurrency = item.currency; - const csvForeignAmount = item.originalImportData?.foreignAmount; - const csvForeignCurrency = item.originalImportData?.foreignCurrency; - - const csvSourceName = item.csvRawSourceName; - const csvDestName = item.csvRawDestinationName; - - if (!csvSourceName || !csvDestName) { - if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Firefly 'transfer' type row missing source or destination name in CSV.` }; - errorCount++; overallError = true; continue; - } - - const fromAccountId = finalMapForTxImport[csvSourceName.toLowerCase().trim()]; - const toAccountId = finalMapForTxImport[csvDestName.toLowerCase().trim()]; - - if (!fromAccountId || fromAccountId.startsWith('preview_') || fromAccountId.startsWith('error_') || fromAccountId.startsWith('skipped_')) { - if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Invalid source account ID for transfer leg account "${csvSourceName}". Mapped ID: ${fromAccountId}.` }; - errorCount++; overallError = true; continue; - } - if (!toAccountId || toAccountId.startsWith('preview_') || toAccountId.startsWith('error_') || toAccountId.startsWith('skipped_')) { - if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Invalid destination account ID for transfer leg account "${csvDestName}". Mapped ID: ${toAccountId}.` }; - errorCount++; overallError = true; continue; - } - - const fromAccountDetails = latestAccountsList.find(a => a.id === fromAccountId); - const toAccountDetails = latestAccountsList.find(a => a.id === toAccountId); - if (!fromAccountDetails || !toAccountDetails) { - if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Could not find account details for transfer.` }; - errorCount++; overallError = true; continue; - } - - const transferDesc = item.description || `Transfer from ${fromAccountDetails.name} to ${toAccountDetails.name}`; - - let debitAmount = -Math.abs(csvAmount); - let debitCurrency = csvCurrency; - let creditAmount = Math.abs(csvAmount); - let creditCurrency = csvCurrency; - - if (csvForeignAmount != null && csvForeignCurrency && csvForeignCurrency.trim() !== '') { - creditAmount = Math.abs(csvForeignAmount); - creditCurrency = csvForeignCurrency; - } - - transactionPayloads.push({ - accountId: fromAccountId, - date: item.date, - amount: debitAmount, - transactionCurrency: debitCurrency, - description: transferDesc, - category: 'Transfer', - tags: transactionTags, - originalMappedTx: item, - originalImportData: item.originalImportData, - }); - transactionPayloads.push({ - accountId: toAccountId, - date: item.date, - amount: creditAmount, - transactionCurrency: creditCurrency, - description: transferDesc, - category: 'Transfer', - tags: transactionTags, - originalMappedTx: item, - originalImportData: item.originalImportData, - }); - - } else if (item.csvTransactionType === 'withdrawal' || item.csvTransactionType === 'deposit') { - let accountNameForTx: string | undefined | null; - let accountIdForTx: string | undefined; - let payloadAmount = item.amount; - let payloadCurrency = item.currency; - - if (item.csvTransactionType === 'withdrawal') { - accountNameForTx = item.csvRawSourceName; - if (item.csvSourceType !== 'asset account' && item.csvSourceType !== 'default asset account') { - if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Withdrawal from non-asset account type '${item.csvSourceType}' for source '${accountNameForTx}'. Skipping.` }; - errorCount++; overallError = true; continue; - } - } else { - accountNameForTx = item.csvRawDestinationName; - if (item.csvDestinationType !== 'asset account' && item.csvDestinationType !== 'default asset account') { - if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Deposit to non-asset account type '${item.csvDestinationType}' for destination '${accountNameForTx}'. Skipping.` }; - errorCount++; overallError = true; continue; - } - } - - if (!accountNameForTx) { - if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Could not determine asset account for ${item.csvTransactionType}. Source: ${item.csvRawSourceName}, Dest: ${item.csvRawDestinationName}` }; - errorCount++; overallError = true; continue; - } - - accountIdForTx = finalMapForTxImport[accountNameForTx.toLowerCase().trim()]; - if (!accountIdForTx || accountIdForTx.startsWith('preview_') || accountIdForTx.startsWith('error_') || accountIdForTx.startsWith('skipped_')) { - if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Could not find valid account ID for "${accountNameForTx}". Mapped ID: ${accountIdForTx}.` }; - errorCount++; overallError = true; continue; - } - - if (Number.isNaN(payloadAmount)) { - if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Invalid amount for import.` }; - errorCount++; overallError = true; continue; - } - - transactionPayloads.push({ - accountId: accountIdForTx, - date: item.date, - amount: payloadAmount, - transactionCurrency: payloadCurrency, - description: item.description, - category: transactionCategory, - tags: transactionTags, - originalMappedTx: item, - originalImportData: item.originalImportData, - }); - } else { - if(itemIndexInDisplay !== -1) updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: `Row ${rowNumber}: Unknown Firefly transaction type "${item.csvTransactionType}". Supported: withdrawal, deposit, transfer, opening balance.` }; - errorCount++; overallError = true; - } - } - - transactionPayloads.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); - - for (const payload of transactionPayloads) { - const itemIndexInDisplay = updatedDataForDisplay.findIndex(d => - d.originalRecord === payload.originalMappedTx.originalRecord && - d.description === payload.originalMappedTx.description && - d.amount === payload.originalMappedTx.amount && - d.date === payload.originalMappedTx.date && - d.importStatus !== 'success' - ); - try { - await addTransaction({ - accountId: payload.accountId, - date: payload.date, - amount: payload.amount, - transactionCurrency: payload.transactionCurrency, - description: payload.description, - category: payload.category, - tags: payload.tags, - originalImportData: payload.originalImportData, - }); - if(itemIndexInDisplay !== -1) { - updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'success', errorMessage: undefined }; - } - importedCount++; - } catch (err: any) { - console.error(`Failed to import transaction for original row:`, payload.originalMappedTx.originalRecord, err); - if(itemIndexInDisplay !== -1 && updatedDataForDisplay[itemIndexInDisplay].importStatus !== 'success') { - updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'error', errorMessage: err.message || 'Unknown import error' }; - } - errorCount++; - overallError = true; - } - setImportProgress(calculateProgress(importedCount + errorCount, transactionPayloads.length )); - setParsedData([...updatedDataForDisplay]); - } - - setIsLoading(false); - const finalMessage = `Import finished. Successfully processed transaction entries: ${importedCount}. Failed/Skipped rows (from preview): ${errorCount + parsedData.filter(d => d.importStatus === 'skipped').length}.`; - toast({ - title: overallError ? "Import Complete with Issues" : "Import Complete", - description: finalMessage, - variant: overallError ? "destructive" : "default", - duration: 7000, - }); - - if (overallError) { - setError(`Import finished with ${errorCount} transaction errors. Please review the table for details.`); - } else { - setError(null); - window.dispatchEvent(new Event('storage')); - } - setAccounts(await getAccounts()); - }; - - - const calculateProgress = (processed: number, total: number): number => { - if (total === 0) return 0; - return Math.round((processed / total) * 100); - } - - const handleClearData = async () => { - if (!user) { - toast({ title: "Not Authenticated", description: "Please log in to clear data.", variant: "destructive" }); - return; - } - setIsClearing(true); - try { - await clearAllSessionTransactions(); - - setAccounts([]); - setCategories([]); - setTags([]); - setParsedData([]); - setAccountPreviewData([]); - setError(null); - setRawData([]); - setFile(null); - setColumnMappings({}); - setImportProgress(0); - setFinalAccountMapForImport({}); - - const fileInput = document.getElementById('csv-file') as HTMLInputElement; - if (fileInput) fileInput.value = ''; - - toast({ title: "Data Cleared", description: "All user data (accounts, categories, tags, groups, subscriptions, transactions) has been removed." }); - window.dispatchEvent(new Event('storage')); - } catch (err) { - console.error("Failed to clear data:", err); - toast({ title: "Error", description: "Could not clear stored data.", variant: "destructive" }); - } finally { - setIsClearing(false); - } - }; - - const handleExportData = async () => { - if (!user) { - toast({ title: "Error", description: "User not authenticated.", variant: "destructive" }); - return; - } - setIsExporting(true); - toast({ title: "Exporting Data", description: "Preparing your data for download. This may take a moment..." }); - try { - await exportAllUserDataToCsvs(); - toast({ title: "Export Complete", description: "Your data files should be downloading now. Please check your browser's download folder." }); - } catch (error) { - console.error("Export failed:", error); - toast({ title: "Export Failed", description: "Could not export your data. Please try again.", variant: "destructive" }); - } finally { - setIsExporting(false); - } - }; - - - const handleTransactionFieldChange = ( - originalIndexInData: number, - field: 'description' | 'category' | 'tags' | 'amount' | 'date' | 'currency', - value: string - ) => { - setParsedData(prevData => { - const newData = [...prevData]; - let transactionToUpdate = { ...newData[originalIndexInData] }; - - if (transactionToUpdate.importStatus !== 'pending') { - toast({ title: "Edit Blocked", description: "Cannot edit transactions that are not pending import.", variant: "destructive" }); - return prevData; - } - - switch (field) { - case 'description': - transactionToUpdate.description = value; - break; - case 'category': - transactionToUpdate.category = value; - break; - case 'tags': - transactionToUpdate.tags = value.split(',').map(tag => tag.trim()).filter(Boolean); - break; - case 'amount': - const parsedAmount = parseFloat(value); - if (!isNaN(parsedAmount)) { - transactionToUpdate.amount = parsedAmount; - } else { - toast({ title: "Invalid Amount", description: "Amount not updated. Please enter a valid number.", variant: "destructive" }); - return prevData; - } - break; - case 'date': - if (value && /^\d{4}-\d{2}-\d{2}$/.test(value)) { - transactionToUpdate.date = value; - } else if (value) { - try { - const d = new Date(value); - if (!isNaN(d.getTime())) { - transactionToUpdate.date = format(d, 'yyyy-MM-dd'); - } else { throw new Error("Invalid date object"); } - } catch { - toast({ title: "Invalid Date", description: "Date not updated. Please use YYYY-MM-DD format or select a valid date.", variant: "destructive" }); - return prevData; - } - } else { - toast({ title: "Invalid Date", description: "Date not updated. Please select a valid date.", variant: "destructive" }); - return prevData; - } - break; - case 'currency': - if (supportedCurrencies.includes(value.toUpperCase())) { - transactionToUpdate.currency = value.toUpperCase(); - } else { - toast({ title: "Invalid Currency", description: `Currency ${value} not supported.`, variant: "destructive"}); - return prevData; - } - break; - default: - return prevData; - } - newData[originalIndexInData] = transactionToUpdate; - return newData; - }); - }; - - - const groupedTransactionsForPreview = useMemo(() => { - if (!parsedData || parsedData.length === 0) return {}; - const grouped: { [accountDisplayName: string]: MappedTransaction[] } = {}; - - const getDisplayableAccountName = (csvName?: string | null, csvType?: string | null): string => { - if (!csvName) return "Unknown / External"; - const lowerCsvName = csvName.toLowerCase().trim(); - const lowerCsvType = csvType?.toLowerCase().trim(); - - if (lowerCsvType && (lowerCsvType.includes('revenue account') || lowerCsvType.includes('expense account'))) { - return `${csvName} (External)`; - } - - const accountId = finalAccountMapForImport[lowerCsvName]; - if (accountId) { - const appAccount = accounts.find(acc => acc.id === accountId); - if (appAccount) return appAccount.name; - } - const previewAccount = accountPreviewData.find(ap => ap.name.toLowerCase().trim() === lowerCsvName); - if (previewAccount) return previewAccount.name; - - return csvName; - }; - - parsedData.forEach(item => { - let accountKeyForGrouping = "Unknown / Skipped / Error"; - let accountDisplayName = "Unknown / Skipped / Error"; - - if (item.importStatus === 'error' || item.importStatus === 'skipped') { - accountDisplayName = item.errorMessage?.includes("Opening Balance") - ? `Account Balance Update: ${getDisplayableAccountName(item.csvRawDestinationName || item.csvRawSourceName, item.csvDestinationType || item.csvSourceType)}` - : `Errors / Skipped Transactions`; - accountKeyForGrouping = `system-${item.importStatus}-${item.errorMessage?.substring(0,20) || 'general'}`; - } else if (item.csvTransactionType === 'transfer') { - const sourceName = getDisplayableAccountName(item.csvRawSourceName, item.csvSourceType); - const destName = getDisplayableAccountName(item.csvRawDestinationName, item.csvDestinationType); - accountDisplayName = `Transfer: ${sourceName} -> ${destName}`; - accountKeyForGrouping = `transfer-${sourceName}-${destName}`; - - } else if (item.csvTransactionType === 'withdrawal') { - accountDisplayName = getDisplayableAccountName(item.csvRawSourceName, item.csvSourceType); - accountKeyForGrouping = `account-${accountDisplayName}-withdrawal`; - } else if (item.csvTransactionType === 'deposit') { - accountDisplayName = getDisplayableAccountName(item.csvRawDestinationName, item.csvDestinationType); - accountKeyForGrouping = `account-${accountDisplayName}-deposit`; - } - - if (!grouped[accountKeyForGrouping]) { - grouped[accountKeyForGrouping] = []; - } - (item as any)._accountDisplayNameForGroupHeader = accountDisplayName; - grouped[accountKeyForGrouping].push(item); - }); - - return Object.entries(grouped) - .sort(([keyA], [keyB]) => { - const nameA = (grouped[keyA][0] as any)._accountDisplayNameForGroupHeader || keyA; - const nameB = (grouped[keyB][0] as any)._accountDisplayNameForGroupHeader || keyB; - if (nameA.startsWith("Errors") || nameA.startsWith("Account Balance Update")) return 1; - if (nameB.startsWith("Errors") || nameB.startsWith("Account Balance Update")) return -1; - return nameA.localeCompare(nameB); - }) - .reduce((obj, [key, value]) => { - obj[key] = value; - return obj; - }, {} as typeof grouped); - }, [parsedData, accountPreviewData, finalAccountMapForImport, accounts]); - - - if (isLoadingAuth) { - return

Loading authentication...

; - } - if (!user && !isLoadingAuth) { - return

Please login to manage data.

; - } + router.replace('/data-management'); + }, [router]); return ( -
-

Data Management

- - - - Step 1: Upload CSV File - - Select your CSV file. Firefly III export format is best supported. Map columns carefully in the next step. Ensure file is UTF-8 encoded. - - - -
- - -
- - {error && ( - - - {error.includes("Issues") || error.includes("Error") || error.includes("Failed") || error.includes("Missing") || error.includes("Critical") ? "Import Problem" : "Info"} - {error} - - )} - -
- - - - - - - - - - Are you absolutely sure? - - This action cannot be undone. This will permanently delete ALL your accounts, categories, tags, groups, subscriptions and transactions from the database. This is intended for testing or resetting your data. - - - - setIsClearing(false)} disabled={isClearing}>Cancel - - {isClearing ? "Clearing..." : "Yes, Clear All My Data"} - - - - -
- - {isLoading && importProgress > 0 && ( - - )} -
-
- - - - - Step 2: Map CSV Columns - - Match CSV columns (right) to application fields (left). For Firefly III CSVs, ensure 'type', 'amount', 'currency_code', 'date', 'source_name', and 'destination_name' are correctly mapped. - - - setIsMappingDialogOpen(false)} - /> - - - - {accountPreviewData.length > 0 && !isLoading && ( - - - Account Changes Preview - Review accounts to be created or updated. Initial balances are primarily from 'Opening Balance' rows in Firefly III CSVs. - - -
- - - - Account Name - Action - Currency - Initial/Updated Balance - - - - {accountPreviewData.map((acc, index) => ( - - {acc.name} - {acc.action} - {acc.currency} - - {formatCurrency(acc.initialBalance, acc.currency, undefined, false)} - - - ))} - -
-
-
-
- )} - - {parsedData.length > 0 && ( - - - Review & Import ({parsedData.filter(i => i.importStatus === 'pending').length} Pending Rows) - Review transactions. Rows marked 'Error' or 'Skipped' (like Opening Balances) won't be imported as transactions. Edit fields if needed. Click "Import Transactions" above when ready. - - - {Object.entries(groupedTransactionsForPreview).map(([accountGroupKey, transactionsInGroup]) => { - const firstTransactionInGroup = transactionsInGroup[0]; - const accountDisplayName = (firstTransactionInGroup as any)._accountDisplayNameForGroupHeader || accountGroupKey; - - return ( -
-

- {accountDisplayName} -

-
- - - - Date - CSV Type - Description - Category - Tags - Amount - Currency - Foreign Amt. - Foreign Curr. - Status - Message / Info - - - - {transactionsInGroup.map((item, index) => { - const originalIndex = parsedData.findIndex(pItem => pItem.originalRecord === item.originalRecord && pItem.date === item.date && pItem.description === item.description && pItem.amount === item.amount); - - return ( - - - handleTransactionFieldChange(originalIndex, 'date', e.target.value)} className="h-8 text-xs p-1" /> - - {item.csvTransactionType} - - handleTransactionFieldChange(originalIndex, 'description', e.target.value)} className="h-8 text-xs p-1" /> - - - handleTransactionFieldChange(originalIndex, 'category', e.target.value)} className="h-8 text-xs p-1" /> - - - handleTransactionFieldChange(originalIndex, 'tags', e.target.value)} placeholder="tag1, tag2" className="h-8 text-xs p-1" /> - - - handleTransactionFieldChange(originalIndex, 'amount', e.target.value)} className="h-8 text-xs p-1 text-right" /> - - - handleTransactionFieldChange(originalIndex, 'currency', e.target.value)} className="h-8 text-xs p-1" /> - - - {item.foreignAmount != null ? formatCurrency(item.foreignAmount, item.foreignCurrency || '', item.foreignCurrency || '', false) : '-'} - {item.foreignCurrency || '-'} - {item.importStatus} - {item.errorMessage} - - ); - })} - -
-
-
- ); - })} -
-
- )} +
+

Redirecting to Data Management...

); } - diff --git a/src/components/layout/auth-wrapper.tsx b/src/components/layout/auth-wrapper.tsx index e6bf412..6261038 100644 --- a/src/components/layout/auth-wrapper.tsx +++ b/src/components/layout/auth-wrapper.tsx @@ -130,6 +130,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { const isAnyFinancialControlRouteActive = isClient && pathname === '/financial-control'; const isOrganizationActive = isClient && (pathname === '/organization' || pathname.startsWith('/categories/') || pathname.startsWith('/tags/') || pathname.startsWith('/groups/')); const isAccountsActive = isClient && (pathname === '/accounts' || pathname.startsWith('/accounts/')); + const isDataManagementActive = isClient && pathname === '/data-management'; if (!isClient || isLoadingAuth) { @@ -294,10 +295,10 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { - - - - Import Data + + + + Data Management From d99571f2fbbe6d5f7145bf92d15c98efffa2d41d Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 20 May 2025 22:46:11 +0000 Subject: [PATCH 014/156] Como faremos para ser um backup num arquivo so? Voce pode meio que manda todas as planilhas em uma pasta compactada? Se sim, ja crie tambem a importacao de u arquivo compactado, esse sera o modo do usuario fazer o backup --- package-lock.json | 88 +++++++++++ package.json | 1 + src/app/data-management/page.tsx | 253 ++++++++++++++++++++----------- src/services/export.ts | 102 ++++--------- 4 files changed, 287 insertions(+), 157 deletions(-) diff --git a/package-lock.json b/package-lock.json index 457a1e9..fe3be66 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "emoji-picker-react": "^4.10.0", "firebase": "^11.3.0", "genkit": "^1.6.2", + "jszip": "^3.10.1", "lucide-react": "^0.475.0", "next": "15.2.3", "papaparse": "^5.4.1", @@ -4757,6 +4758,12 @@ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -6314,6 +6321,12 @@ } ] }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/import-in-the-middle": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-1.13.0.tgz", @@ -6725,6 +6738,54 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/jwa": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", @@ -6758,6 +6819,15 @@ "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", "dev": true }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -7345,6 +7415,12 @@ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/papaparse": { "version": "5.5.2", "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.2.tgz", @@ -7643,6 +7719,12 @@ "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==" }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -8340,6 +8422,12 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", diff --git a/package.json b/package.json index e52fe78..fe9b6f4 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "emoji-picker-react": "^4.10.0", "firebase": "^11.3.0", "genkit": "^1.6.2", + "jszip": "^3.10.1", "lucide-react": "^0.475.0", "next": "15.2.3", "papaparse": "^5.4.1", diff --git a/src/app/data-management/page.tsx b/src/app/data-management/page.tsx index 1af3fff..b18f8aa 100644 --- a/src/app/data-management/page.tsx +++ b/src/app/data-management/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import Papa, { ParseResult } from 'papaparse'; +import JSZip from 'jszip'; // Import JSZip import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -22,11 +23,11 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { format, parseISO, isValid, parse as parseDateFns } from 'date-fns'; import { getCurrencySymbol, supportedCurrencies, formatCurrency, convertCurrency } from '@/lib/currency'; import CsvMappingForm, { type ColumnMapping } from '@/components/import/csv-mapping-form'; -import { AlertCircle, Trash2, Download } from 'lucide-react'; +import { AlertCircle, Trash2, Download, FileZip } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAuthContext } from '@/contexts/AuthContext'; import Link from 'next/link'; -import { exportAllUserDataToCsvs } from '@/services/export'; +import { exportAllUserDataToZip } from '@/services/export'; // Updated import type CsvRecord = { [key: string]: string | undefined; @@ -249,9 +250,72 @@ export default function DataManagementPage() { } }; - const handleParseAndMap = () => { + const processCsvData = (csvString: string, fileName: string) => { + Papa.parse(csvString, { + header: true, + skipEmptyLines: true, + complete: (results: ParseResult) => { + if (results.errors.length > 0 && !results.data.length) { + const criticalError = results.errors.find(e => e.code !== 'TooManyFields' && e.code !== 'TooFewFields') || results.errors[0]; + setError(`CSV Parsing Error from ${fileName}: ${criticalError.message}. Code: ${criticalError.code}. Ensure headers are correct and file encoding is UTF-8.`); + setIsLoading(false); + return; + } + if (results.errors.length > 0) { + console.warn(`Minor CSV parsing errors encountered in ${fileName}:`, results.errors); + toast({ title: "CSV Parsing Warning", description: `Some rows in ${fileName} might have issues: ${results.errors.map(e=>e.message).slice(0,2).join('; ')}`, variant:"default", duration: 7000}); + } + + if (!results.data || results.data.length === 0) { + setError(`CSV file ${fileName} is empty or doesn't contain valid data rows.`); + setIsLoading(false); + return; + } + + const headers = results.meta.fields; + if (!headers || headers.length === 0) { + setError(`Could not read CSV headers from ${fileName}. Ensure the first row contains column names.`); + setIsLoading(false); + return; + } + + setCsvHeaders(headers.filter(h => h != null) as string[]); + setRawData(results.data); // This rawData will be used by processAndMapData + + const detectedHeaders = headers.filter(h => h != null) as string[]; + const initialMappings: ColumnMapping = {}; + + // Auto-detect common Firefly III CSV headers + initialMappings.date = findColumnName(detectedHeaders, 'date'); + initialMappings.amount = findColumnName(detectedHeaders, 'amount'); + initialMappings.description = findColumnName(detectedHeaders, 'description'); + initialMappings.source_name = findColumnName(detectedHeaders, 'source_name'); + initialMappings.destination_name = findColumnName(detectedHeaders, 'destination_name'); + initialMappings.currency_code = findColumnName(detectedHeaders, 'currency_code') || findColumnName(detectedHeaders, 'currency'); + initialMappings.category = findColumnName(detectedHeaders, 'category'); + initialMappings.tags = findColumnName(detectedHeaders, 'tags'); + initialMappings.transaction_type = findColumnName(detectedHeaders, 'type'); + initialMappings.notes = findColumnName(detectedHeaders, 'notes'); + initialMappings.foreign_amount = findColumnName(detectedHeaders, 'foreign_amount'); + initialMappings.foreign_currency_code = findColumnName(detectedHeaders, 'foreign_currency_code'); + initialMappings.source_type = findColumnName(detectedHeaders, 'source_type'); + initialMappings.destination_type = findColumnName(detectedHeaders, 'destination_type'); + initialMappings.initialBalance = findColumnName(detectedHeaders, 'initial_balance') || findColumnName(detectedHeaders, 'opening_balance'); + + setColumnMappings(initialMappings); + setIsMappingDialogOpen(true); // Open mapping dialog + setIsLoading(false); + }, + error: (err: Error) => { + setError(`Failed to read or parse CSV string from ${fileName}: ${err.message}.`); + setIsLoading(false); + } + }); + }; + + const handleParseAndMap = async () => { if (!file) { - setError("Please select a CSV file first."); + setError("Please select a CSV or ZIP file first."); return; } if (!user) { @@ -260,75 +324,79 @@ export default function DataManagementPage() { return; } - setIsLoading(true); + setIsLoading(true); setError(null); setParsedData([]); setAccountPreviewData([]); setRawData([]); setCsvHeaders([]); - Papa.parse(file, { - header: true, - skipEmptyLines: true, - complete: (results: ParseResult) => { - if (results.errors.length > 0 && !results.data.length) { - const criticalError = results.errors.find(e => e.code !== 'TooManyFields' && e.code !== 'TooFewFields') || results.errors[0]; - setError(`CSV Parsing Error: ${criticalError.message}. Code: ${criticalError.code}. Ensure headers are correct and file encoding is UTF-8.`); - setIsLoading(false); - return; - } - if (results.errors.length > 0) { - console.warn("Minor CSV parsing errors encountered:", results.errors); - toast({ title: "CSV Parsing Warning", description: `Some rows might have issues: ${results.errors.map(e=>e.message).slice(0,2).join('; ')}`, variant:"default", duration: 7000}); - } + if (file.name.endsWith('.zip') || file.type === 'application/zip' || file.type === 'application/x-zip-compressed') { + try { + const zip = await JSZip.loadAsync(file); + let primaryCsvFile: JSZip.JSZipObject | null = null; + + // Heuristic: Look for common "main" CSV file names from Firefly or our own export + const commonPrimaryNames = ['transactions.csv', 'firefly_iii_export.csv', 'default.csv']; + for (const name of commonPrimaryNames) { + const foundFile = zip.file(name); + if (foundFile) { + primaryCsvFile = foundFile; + break; + } + } + + // Fallback: find the largest CSV file in the zip if no common name is found + if (!primaryCsvFile) { + let largestSize = 0; + zip.forEach((relativePath, zipEntry) => { + if (zipEntry.name.toLowerCase().endsWith('.csv') && !zipEntry.dir) { + // A more robust size check might be needed if _data is not reliable + // For simplicity, using a placeholder for uncompressed size if available + const uncompressedSize = (zipEntry as any)._data?.uncompressedSize || 0; + if (uncompressedSize > largestSize) { + largestSize = uncompressedSize; + primaryCsvFile = zipEntry; + } + } + }); + } - if (!results.data || results.data.length === 0) { - setError("CSV file is empty or doesn't contain valid data rows."); + if (primaryCsvFile) { + toast({ title: "ZIP Detected", description: `Processing '${primaryCsvFile.name}' from the archive.`, duration: 4000}); + const csvString = await primaryCsvFile.async("string"); + processCsvData(csvString, primaryCsvFile.name); + } else { + setError("No suitable CSV file found within the ZIP archive to process for mapping. Expected names like 'transactions.csv' or 'firefly_iii_export.csv'."); + setIsLoading(false); + } + } catch (zipError: any) { + setError(`Failed to process ZIP file: ${zipError.message}`); setIsLoading(false); - return; - } - - const headers = results.meta.fields; - if (!headers || headers.length === 0) { - setError("Could not read CSV headers. Ensure the first row contains column names."); - setIsLoading(false); - return; - } - - setCsvHeaders(headers.filter(h => h != null) as string[]); - setRawData(results.data); - - const detectedHeaders = headers.filter(h => h != null) as string[]; - const initialMappings: ColumnMapping = {}; - - initialMappings.date = findColumnName(detectedHeaders, 'date'); - initialMappings.amount = findColumnName(detectedHeaders, 'amount'); - initialMappings.description = findColumnName(detectedHeaders, 'description'); - initialMappings.source_name = findColumnName(detectedHeaders, 'source_name'); - initialMappings.destination_name = findColumnName(detectedHeaders, 'destination_name'); - initialMappings.currency_code = findColumnName(detectedHeaders, 'currency_code') || findColumnName(detectedHeaders, 'currency'); - initialMappings.category = findColumnName(detectedHeaders, 'category'); - initialMappings.tags = findColumnName(detectedHeaders, 'tags'); - initialMappings.transaction_type = findColumnName(detectedHeaders, 'type'); - initialMappings.notes = findColumnName(detectedHeaders, 'notes'); - initialMappings.foreign_amount = findColumnName(detectedHeaders, 'foreign_amount'); - initialMappings.foreign_currency_code = findColumnName(detectedHeaders, 'foreign_currency_code'); - initialMappings.source_type = findColumnName(detectedHeaders, 'source_type'); - initialMappings.destination_type = findColumnName(detectedHeaders, 'destination_type'); - initialMappings.initialBalance = findColumnName(detectedHeaders, 'initial_balance') || findColumnName(detectedHeaders, 'opening_balance'); - - - setColumnMappings(initialMappings); - setIsMappingDialogOpen(true); - setIsLoading(false); - }, - error: (err: Error) => { - setError(`Failed to read or parse CSV file: ${err.message}.`); + } + } else if (file.name.endsWith('.csv') || file.type === 'text/csv') { + // It's a CSV file, process it directly + const reader = new FileReader(); + reader.onload = (event) => { + if (event.target?.result && typeof event.target.result === 'string') { + processCsvData(event.target.result, file.name); + } else { + setError("Failed to read CSV file content."); + setIsLoading(false); + } + }; + reader.onerror = () => { + setError("Error reading CSV file."); + setIsLoading(false); + }; + reader.readAsText(file); + } else { + setError("Unsupported file type. Please upload a CSV or a ZIP file containing CSVs."); setIsLoading(false); - } - }); + } }; + const processAndMapData = async (confirmedMappings: ColumnMapping) => { setIsLoading(true); setError(null); @@ -359,12 +427,12 @@ export default function DataManagementPage() { } const currentAccounts = await getAccounts(); - setAccounts(currentAccounts); + setAccounts(currentAccounts); // Update local state for use in preview/import - const { preview } = await previewAccountChanges( + const { preview } = await previewAccountChanges( rawData, confirmedMappings, - currentAccounts + currentAccounts ); setAccountPreviewData(preview); console.log("Account preview generated:", preview); @@ -597,6 +665,7 @@ export default function DataManagementPage() { const currency = record[mappings.currency_code!]?.trim().toUpperCase(); const amount = parseAmount(record[mappings.amount!]); const initialBalance = parseAmount(record[mappings.initialBalance!] || record[mappings.amount!]); + const foreignCurrencyVal = record[mappings.foreign_currency_code!]?.trim().toUpperCase(); return { csvTransactionType: type, @@ -605,6 +674,7 @@ export default function DataManagementPage() { csvSourceType: sourceType, csvDestinationType: destType, currency: currency, + foreignCurrency: foreignCurrencyVal, amount: type === 'opening balance' ? initialBalance : amount, }; }) as Partial[]; @@ -719,7 +789,10 @@ export default function DataManagementPage() { accountsToConsiderRaw.push({name: item.csvRawSourceName, type: item.csvSourceType, currency: item.currency}); } if (item.csvRawDestinationName && (item.csvDestinationType === 'asset account' || item.csvDestinationType === 'default asset account')) { - const destCurrency = (item.csvTransactionType === 'transfer' && item.originalImportData?.foreignCurrency) ? item.originalImportData.foreignCurrency : item.currency; + // For transfers, the destination account might use the foreign currency specified in the CSV + const destCurrency = (item.csvTransactionType === 'transfer' && item.foreignCurrency) + ? item.foreignCurrency + : item.currency; accountsToConsiderRaw.push({name: item.csvRawDestinationName, type: item.csvDestinationType, currency: destCurrency}); } @@ -742,15 +815,16 @@ export default function DataManagementPage() { accountMap.set(normalizedName, { name: accInfo.name, currency: existingAppAccount?.currency || accInfo.currency, - initialBalance: existingAppAccount?.balance, + initialBalance: existingAppAccount?.balance, // Keep existing balance if account already there category: existingAppAccount?.category || category, }); } else { const currentDetails = accountMap.get(normalizedName)!; - if (!currentDetails.currency && accInfo.currency) currentDetails.currency = accInfo.currency; - if (currentDetails.category === 'asset' && category === 'crypto') { + if (!currentDetails.currency && accInfo.currency) currentDetails.currency = accInfo.currency; // Set currency if not already set + if (currentDetails.category === 'asset' && category === 'crypto') { // Update category if more specific currentDetails.category = 'crypto'; } + // Do not override initialBalance if it's already set by an "opening balance" row } } } @@ -763,7 +837,7 @@ export default function DataManagementPage() { isPreviewOnly: boolean = false ): Promise<{ success: boolean; map: { [key: string]: string }, updatedAccountsList: Account[] }> => { let success = true; - let currentAppAccounts = [...accounts]; + let currentAppAccounts = [...accounts]; // Use the state version const workingMap = currentAppAccounts.reduce((map, acc) => { map[acc.name.toLowerCase().trim()] = acc.id; @@ -810,7 +884,7 @@ export default function DataManagementPage() { if (existingAccountForUpdate) { const updatedAccountData: Account = { ...existingAccountForUpdate, - balance: accPreview.initialBalance, + balance: accPreview.initialBalance, // This balance IS the initial balance from Firefly or calculated for preview currency: accPreview.currency, lastActivity: new Date().toISOString(), category: accPreview.category, @@ -840,8 +914,8 @@ export default function DataManagementPage() { } if (!isPreviewOnly && accountsProcessedCount > 0) { - const finalFetchedAccounts = await getAccounts(); - setAccounts(finalFetchedAccounts); + const finalFetchedAccounts = await getAccounts(); // Re-fetch to get the true state from DB + setAccounts(finalFetchedAccounts); // Update main page state const finalMap = finalFetchedAccounts.reduce((map, acc) => { map[acc.name.toLowerCase().trim()] = acc.id; return map; @@ -888,7 +962,7 @@ export default function DataManagementPage() { let categoriesAddedCount = 0; const addCatPromises = Array.from(categoriesToAdd).map(async (catName) => { try { - await addCategoryToDb(catName); + await addCategoryToDb(catName); // Assuming addCategoryToDb adds to Firebase categoriesAddedCount++; } catch (err: any) { if (!err.message?.includes('already exists')) { @@ -909,7 +983,7 @@ export default function DataManagementPage() { let tagsAddedCount = 0; const addTagPromises = Array.from(tagsToAdd).map(async (tagName) => { try { - await addTagToDb(tagName); + await addTagToDb(tagName); // Assuming addTagToDb adds to Firebase tagsAddedCount++; } catch (err: any) { if (!err.message?.includes('already exists')) { @@ -954,8 +1028,8 @@ export default function DataManagementPage() { setError("Error processing some accounts during import. Some accounts might not have been created/updated correctly. Review account preview and transaction statuses."); } finalMapForTxImport = accountMapResult.map; - latestAccountsList = accountMapResult.updatedAccountsList; - setAccounts(latestAccountsList); + latestAccountsList = accountMapResult.updatedAccountsList; // Use the updated list which includes newly created/updated accounts + setAccounts(latestAccountsList); // Ensure the main page state is also updated setFinalAccountMapForImport(finalMapForTxImport); } catch (finalAccountMapError) { console.error("Critical error during account finalization before import.", finalAccountMapError); @@ -1033,6 +1107,7 @@ export default function DataManagementPage() { let creditAmount = Math.abs(csvAmount); let creditCurrency = csvCurrency; + // If foreign amount/currency are present in Firefly for transfers, it means the destination received a different amount/currency if (csvForeignAmount != null && csvForeignCurrency && csvForeignCurrency.trim() !== '') { creditAmount = Math.abs(csvForeignAmount); creditCurrency = csvForeignCurrency; @@ -1114,6 +1189,7 @@ export default function DataManagementPage() { } } + // Ensure transactions are sorted by date before processing to correctly update account balances chronologically transactionPayloads.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); for (const payload of transactionPayloads) { @@ -1134,7 +1210,7 @@ export default function DataManagementPage() { category: payload.category, tags: payload.tags, originalImportData: payload.originalImportData, - }); + }); // This will now also update the account balance inside addTransaction if(itemIndexInDisplay !== -1) { updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'success', errorMessage: undefined }; } @@ -1166,6 +1242,7 @@ export default function DataManagementPage() { setError(null); window.dispatchEvent(new Event('storage')); } + // Final refetch of accounts to ensure balances displayed on other pages (like Accounts page) are fully up-to-date setAccounts(await getAccounts()); }; @@ -1217,8 +1294,8 @@ export default function DataManagementPage() { setIsExporting(true); toast({ title: "Exporting Data", description: "Preparing your data for download. This may take a moment..." }); try { - await exportAllUserDataToCsvs(); - toast({ title: "Export Complete", description: "Your data files should be downloading now. Please check your browser's download folder." }); + await exportAllUserDataToZip(); // Use the new ZIP export function + toast({ title: "Export Complete", description: "Your data backup ZIP file should be downloading now. Please check your browser's download folder." }); } catch (error) { console.error("Export failed:", error); toast({ title: "Export Failed", description: "Could not export your data. Please try again.", variant: "destructive" }); @@ -1378,15 +1455,15 @@ export default function DataManagementPage() { - Step 1: Upload CSV File + Step 1: Upload CSV or ZIP File - Select your CSV file. Firefly III export format is best supported. Map columns carefully in the next step. Ensure file is UTF-8 encoded. + Select your CSV file (Firefly III export is best supported) or a GoldQuest backup ZIP file. Map columns carefully in the next step. Ensure file is UTF-8 encoded.
- - + +
{error && ( @@ -1399,14 +1476,14 @@ export default function DataManagementPage() {
@@ -1443,7 +1520,7 @@ export default function DataManagementPage() { Step 2: Map CSV Columns - Match CSV columns (right) to application fields (left). For Firefly III CSVs, ensure 'type', 'amount', 'currency_code', 'date', 'source_name', and 'destination_name' are correctly mapped. + Match CSV columns (right) to application fields (left). For Firefly III CSVs, ensure 'type', 'amount', 'currency_code', 'date', 'source_name', and 'destination_name' are correctly mapped. If importing a GoldQuest backup, mapping should be automatic for supported files. { if (!dateInput) return ''; if (typeof dateInput === 'string') { - // Check if it's already a fully qualified ISO string (e.g., from serverTimestamp or new Date().toISOString()) - // or a simple YYYY-MM-DD date. - const parsed = parseISO(dateInput); // parseISO is robust + const parsed = parseISO(dateInput); return isValid(parsed) ? formatDateFns(parsed, "yyyy-MM-dd'T'HH:mm:ssXXX") : dateInput; } - // This case is tricky for Firebase serverTimestamp, which is an object initially. - // For simplicity, if it's an object here, it means it wasn't resolved to a number/string. - // A proper solution would handle the Firebase ServerValue.TIMESTAMP object correctly, - // or ensure data is fetched *after* timestamps are resolved. - // For now, returning an empty string or a placeholder for unresolved objects. if (typeof dateInput === 'object' && dateInput !== null) { - // If it has a toDate method (like a Firebase Timestamp object *after* conversion client-side) if ('toDate' in dateInput && typeof (dateInput as any).toDate === 'function') { return formatDateFns((dateInput as any).toDate(), "yyyy-MM-dd'T'HH:mm:ssXXX"); } - // Fallback for other objects or unresolved serverTimestamps - return new Date().toISOString(); // Or consider '' or a placeholder + // For Firebase serverTimestamp placeholder object, convert to current date as an example + // In a real scenario, this would be handled by ensuring data is read after server resolves it + return new Date().toISOString(); } - if (typeof dateInput === 'number') { // Assuming numeric timestamp + if (typeof dateInput === 'number') { return formatDateFns(new Date(dateInput), "yyyy-MM-dd'T'HH:mm:ssXXX"); } return String(dateInput); @@ -60,57 +53,51 @@ const formatDateForExport = (dateInput: object | string | undefined | null): str interface ExportableTransaction extends Omit { - tags?: string; // Pipe-separated - originalImportData?: string; // JSON string + tags?: string; + originalImportData?: string; createdAt?: string; updatedAt?: string; } interface ExportableSubscription extends Omit { - tags?: string; // Pipe-separated + tags?: string; createdAt?: string; updatedAt?: string; } interface ExportableGroup extends Omit { - categoryIds?: string; // Pipe-separated + categoryIds?: string; } interface ExportableBudget extends Omit { - selectedIds?: string; // Pipe-separated + selectedIds?: string; createdAt?: string; updatedAt?: string; } -export async function exportAllUserDataToCsvs(): Promise { +export async function exportAllUserDataToZip(): Promise { + const zip = new JSZip(); + const timestamp = formatDateFns(new Date(), 'yyyyMMdd_HHmmss'); + const zipFilename = `goldquest_backup_${timestamp}.zip`; + try { - // 1. User Preferences console.log("Exporting: Fetching User Preferences..."); const preferences = await getUserPreferences(); if (preferences) { - downloadCsv(Papa.unparse([preferences]), 'goldquest_preferences.csv'); - } else { - console.log("No preferences data to export."); + zip.file('goldquest_preferences.csv', Papa.unparse([preferences])); } - // 2. Categories console.log("Exporting: Fetching Categories..."); const categories = await getCategories(); if (categories.length > 0) { - downloadCsv(Papa.unparse(categories), 'goldquest_categories.csv'); - } else { - console.log("No categories data to export."); + zip.file('goldquest_categories.csv', Papa.unparse(categories)); } - // 3. Tags console.log("Exporting: Fetching Tags..."); const tags = await getTags(); if (tags.length > 0) { - downloadCsv(Papa.unparse(tags), 'goldquest_tags.csv'); - } else { - console.log("No tags data to export."); + zip.file('goldquest_tags.csv', Papa.unparse(tags)); } - // 4. Groups console.log("Exporting: Fetching Groups..."); const groups = await getGroups(); if (groups.length > 0) { @@ -118,22 +105,15 @@ export async function exportAllUserDataToCsvs(): Promise { ...g, categoryIds: g.categoryIds ? g.categoryIds.join('|') : '', })); - downloadCsv(Papa.unparse(exportableGroups), 'goldquest_groups.csv'); - } else { - console.log("No groups data to export."); + zip.file('goldquest_groups.csv', Papa.unparse(exportableGroups)); } - - // 5. Accounts console.log("Exporting: Fetching Accounts..."); const accounts = await getAccounts(); if (accounts.length > 0) { - downloadCsv(Papa.unparse(accounts), 'goldquest_accounts.csv'); - } else { - console.log("No accounts data to export."); + zip.file('goldquest_accounts.csv', Papa.unparse(accounts)); } - // 6. Transactions (fetch per account, then combine) if (accounts.length > 0) { console.log("Exporting: Fetching Transactions..."); let allTransactions: Transaction[] = []; @@ -153,16 +133,10 @@ export async function exportAllUserDataToCsvs(): Promise { createdAt: formatDateForExport(tx.createdAt), updatedAt: formatDateForExport(tx.updatedAt), })); - downloadCsv(Papa.unparse(exportableTransactions), 'goldquest_transactions.csv'); - } else { - console.log("No transactions data to export."); + zip.file('goldquest_transactions.csv', Papa.unparse(exportableTransactions)); } - } else { - console.log("No accounts found, skipping transaction export."); } - - // 7. Subscriptions console.log("Exporting: Fetching Subscriptions..."); const subscriptions = await getSubscriptions(); if (subscriptions.length > 0) { @@ -172,13 +146,9 @@ export async function exportAllUserDataToCsvs(): Promise { createdAt: formatDateForExport(sub.createdAt), updatedAt: formatDateForExport(sub.updatedAt), })); - downloadCsv(Papa.unparse(exportableSubscriptions), 'goldquest_subscriptions.csv'); - } else { - console.log("No subscriptions data to export."); + zip.file('goldquest_subscriptions.csv', Papa.unparse(exportableSubscriptions)); } - - // 8. Loans console.log("Exporting: Fetching Loans..."); const loans = await getLoans(); if (loans.length > 0) { @@ -187,12 +157,9 @@ export async function exportAllUserDataToCsvs(): Promise { createdAt: formatDateForExport(loan.createdAt), updatedAt: formatDateForExport(loan.updatedAt), })); - downloadCsv(Papa.unparse(exportableLoans), 'goldquest_loans.csv'); - } else { - console.log("No loans data to export."); + zip.file('goldquest_loans.csv', Papa.unparse(exportableLoans)); } - // 9. Credit Cards console.log("Exporting: Fetching Credit Cards..."); const creditCards = await getCreditCards(); if (creditCards.length > 0) { @@ -201,13 +168,9 @@ export async function exportAllUserDataToCsvs(): Promise { createdAt: formatDateForExport(card.createdAt), updatedAt: formatDateForExport(card.updatedAt), })); - downloadCsv(Papa.unparse(exportableCreditCards), 'goldquest_credit_cards.csv'); - } else { - console.log("No credit cards data to export."); + zip.file('goldquest_credit_cards.csv', Papa.unparse(exportableCreditCards)); } - - // 10. Budgets console.log("Exporting: Fetching Budgets..."); const budgets = await getBudgets(); if (budgets.length > 0) { @@ -217,15 +180,16 @@ export async function exportAllUserDataToCsvs(): Promise { createdAt: formatDateForExport(b.createdAt), updatedAt: formatDateForExport(b.updatedAt), })); - downloadCsv(Papa.unparse(exportableBudgets), 'goldquest_budgets.csv'); - } else { - console.log("No budgets data to export."); + zip.file('goldquest_budgets.csv', Papa.unparse(exportableBudgets)); } - console.log('Individual CSV data files prepared for download.'); + const zipContent = await zip.generateAsync({ type: "blob" }); + downloadBlob(zipContent, zipFilename); + + console.log(`Data export complete. ${zipFilename} prepared for download.`); } catch (error) { - console.error("Error exporting all user data:", error); + console.error("Error exporting all user data to ZIP:", error); throw error; } } From 317e4f5efc8af76e194174105ac11a75554ba964 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 20 May 2025 22:55:08 +0000 Subject: [PATCH 015/156] I see this error with the app, reported by NextJS, please fix it. The error is reported as HTML but presented visually to the user). A > before the line number in the error source usually indicates the line of interest: > Runtime Error: Error: ./src/app/data-management/page.tsx:26:1 > Export FileZip doesn't exist in target module > 24 | import { getCurrencySymbol, supportedCurrencies, formatCurrency, convertCurrency } from '@/lib/currency'; > 25 | import CsvMappingForm, { type ColumnMapping } from '@/components/import/csv-mapping-form'; > > 26 | import { AlertCircle, Trash2, Download, FileZip } from 'lucide-react'; > | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ > 27 | import { cn } from '@/lib/utils'; > 28 | import { useAuthContext } from '@/contexts/AuthContext'; > 29 | import Link from 'next/link'; > > The export FileZip was not found in module [project]/node_modules/lucide-react/dist/esm/lucide-react.js [app-client] (ecmascript) . > Did you mean to import File? > All exports of the module are statically known (It doesn't have dynamic exports). So it's known statically that the requested export doesn't exist. > > . Error source: Call Stack > 7 > > Show 5 ignore-listed frame(s) > > error: ./src/app/data-management/page.tsx (26:1) > (Error: > ./src/app/data-management/page.tsx (26:1) --- src/app/data-management/page.tsx | 111 ++++++++++++++----------------- 1 file changed, 50 insertions(+), 61 deletions(-) diff --git a/src/app/data-management/page.tsx b/src/app/data-management/page.tsx index b18f8aa..33ab05a 100644 --- a/src/app/data-management/page.tsx +++ b/src/app/data-management/page.tsx @@ -23,11 +23,11 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { format, parseISO, isValid, parse as parseDateFns } from 'date-fns'; import { getCurrencySymbol, supportedCurrencies, formatCurrency, convertCurrency } from '@/lib/currency'; import CsvMappingForm, { type ColumnMapping } from '@/components/import/csv-mapping-form'; -import { AlertCircle, Trash2, Download, FileZip } from 'lucide-react'; +import { AlertCircle, Trash2, Download } from 'lucide-react'; // Removed FileZip import { cn } from '@/lib/utils'; import { useAuthContext } from '@/contexts/AuthContext'; import Link from 'next/link'; -import { exportAllUserDataToZip } from '@/services/export'; // Updated import +import { exportAllUserDataToZip } from '@/services/export'; type CsvRecord = { [key: string]: string | undefined; @@ -196,46 +196,45 @@ export default function DataManagementPage() { const { toast } = useToast(); - useEffect(() => { - if (isLoadingAuth || !user) { - setIsLoading(false); - return; + const fetchData = useCallback(async () => { + if (typeof window === 'undefined' || !user || isLoadingAuth) { + setIsLoading(false); + return; } - let isMounted = true; - const fetchData = async () => { - if (!isMounted) return; - setIsLoading(true); - setError(null); - try { - const [fetchedAccounts, fetchedCategories, fetchedTagsList] = await Promise.all([ - getAccounts(), - getCategories(), - getTags() - ]); - - if (isMounted) { - setAccounts(fetchedAccounts); - setCategories(fetchedCategories); - setTags(fetchedTagsList); - } - } catch (err: any) { - console.error("Failed to fetch initial data for Data Management page:", err); - if (isMounted) { - setError("Could not load essential page data. Please try refreshing. Details: " + err.message); - toast({ title: "Page Load Error", description: "Failed to load initial data (accounts, categories, or tags). " + err.message, variant: "destructive" }); - } - } finally { - if (isMounted) { - setIsLoading(false); - } - } - }; + setIsLoading(true); + setError(null); - fetchData(); + try { + const [fetchedAccounts, fetchedCategories, fetchedTagsList] = await Promise.all([ + getAccounts(), + getCategories(), + getTags() + ]); + + if (isMounted) { + setAccounts(fetchedAccounts); + setCategories(fetchedCategories); + setTags(fetchedTagsList); + } + } catch (err: any) { + console.error("Failed to fetch initial data for Data Management page:", err); + if (isMounted) { + setError("Could not load essential page data. Please try refreshing. Details: " + err.message); + toast({ title: "Page Load Error", description: "Failed to load initial data (accounts, categories, or tags). " + err.message, variant: "destructive" }); + } + } finally { + if (isMounted) { + setIsLoading(false); + } + } return () => { isMounted = false; }; }, [user, isLoadingAuth]); + useEffect(() => { + fetchData(); + }, [fetchData]); + const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files[0]) { setFile(event.target.files[0]); @@ -336,7 +335,6 @@ export default function DataManagementPage() { const zip = await JSZip.loadAsync(file); let primaryCsvFile: JSZip.JSZipObject | null = null; - // Heuristic: Look for common "main" CSV file names from Firefly or our own export const commonPrimaryNames = ['transactions.csv', 'firefly_iii_export.csv', 'default.csv']; for (const name of commonPrimaryNames) { const foundFile = zip.file(name); @@ -346,13 +344,10 @@ export default function DataManagementPage() { } } - // Fallback: find the largest CSV file in the zip if no common name is found if (!primaryCsvFile) { let largestSize = 0; zip.forEach((relativePath, zipEntry) => { if (zipEntry.name.toLowerCase().endsWith('.csv') && !zipEntry.dir) { - // A more robust size check might be needed if _data is not reliable - // For simplicity, using a placeholder for uncompressed size if available const uncompressedSize = (zipEntry as any)._data?.uncompressedSize || 0; if (uncompressedSize > largestSize) { largestSize = uncompressedSize; @@ -375,7 +370,6 @@ export default function DataManagementPage() { setIsLoading(false); } } else if (file.name.endsWith('.csv') || file.type === 'text/csv') { - // It's a CSV file, process it directly const reader = new FileReader(); reader.onload = (event) => { if (event.target?.result && typeof event.target.result === 'string') { @@ -427,7 +421,7 @@ export default function DataManagementPage() { } const currentAccounts = await getAccounts(); - setAccounts(currentAccounts); // Update local state for use in preview/import + setAccounts(currentAccounts); const { preview } = await previewAccountChanges( rawData, @@ -789,7 +783,6 @@ export default function DataManagementPage() { accountsToConsiderRaw.push({name: item.csvRawSourceName, type: item.csvSourceType, currency: item.currency}); } if (item.csvRawDestinationName && (item.csvDestinationType === 'asset account' || item.csvDestinationType === 'default asset account')) { - // For transfers, the destination account might use the foreign currency specified in the CSV const destCurrency = (item.csvTransactionType === 'transfer' && item.foreignCurrency) ? item.foreignCurrency : item.currency; @@ -815,16 +808,15 @@ export default function DataManagementPage() { accountMap.set(normalizedName, { name: accInfo.name, currency: existingAppAccount?.currency || accInfo.currency, - initialBalance: existingAppAccount?.balance, // Keep existing balance if account already there + initialBalance: existingAppAccount?.balance, category: existingAppAccount?.category || category, }); } else { const currentDetails = accountMap.get(normalizedName)!; - if (!currentDetails.currency && accInfo.currency) currentDetails.currency = accInfo.currency; // Set currency if not already set - if (currentDetails.category === 'asset' && category === 'crypto') { // Update category if more specific + if (!currentDetails.currency && accInfo.currency) currentDetails.currency = accInfo.currency; + if (currentDetails.category === 'asset' && category === 'crypto') { currentDetails.category = 'crypto'; } - // Do not override initialBalance if it's already set by an "opening balance" row } } } @@ -837,7 +829,7 @@ export default function DataManagementPage() { isPreviewOnly: boolean = false ): Promise<{ success: boolean; map: { [key: string]: string }, updatedAccountsList: Account[] }> => { let success = true; - let currentAppAccounts = [...accounts]; // Use the state version + let currentAppAccounts = [...accounts]; const workingMap = currentAppAccounts.reduce((map, acc) => { map[acc.name.toLowerCase().trim()] = acc.id; @@ -884,7 +876,7 @@ export default function DataManagementPage() { if (existingAccountForUpdate) { const updatedAccountData: Account = { ...existingAccountForUpdate, - balance: accPreview.initialBalance, // This balance IS the initial balance from Firefly or calculated for preview + balance: accPreview.initialBalance, currency: accPreview.currency, lastActivity: new Date().toISOString(), category: accPreview.category, @@ -914,8 +906,8 @@ export default function DataManagementPage() { } if (!isPreviewOnly && accountsProcessedCount > 0) { - const finalFetchedAccounts = await getAccounts(); // Re-fetch to get the true state from DB - setAccounts(finalFetchedAccounts); // Update main page state + const finalFetchedAccounts = await getAccounts(); + setAccounts(finalFetchedAccounts); const finalMap = finalFetchedAccounts.reduce((map, acc) => { map[acc.name.toLowerCase().trim()] = acc.id; return map; @@ -962,7 +954,7 @@ export default function DataManagementPage() { let categoriesAddedCount = 0; const addCatPromises = Array.from(categoriesToAdd).map(async (catName) => { try { - await addCategoryToDb(catName); // Assuming addCategoryToDb adds to Firebase + await addCategoryToDb(catName); categoriesAddedCount++; } catch (err: any) { if (!err.message?.includes('already exists')) { @@ -983,7 +975,7 @@ export default function DataManagementPage() { let tagsAddedCount = 0; const addTagPromises = Array.from(tagsToAdd).map(async (tagName) => { try { - await addTagToDb(tagName); // Assuming addTagToDb adds to Firebase + await addTagToDb(tagName); tagsAddedCount++; } catch (err: any) { if (!err.message?.includes('already exists')) { @@ -1028,8 +1020,8 @@ export default function DataManagementPage() { setError("Error processing some accounts during import. Some accounts might not have been created/updated correctly. Review account preview and transaction statuses."); } finalMapForTxImport = accountMapResult.map; - latestAccountsList = accountMapResult.updatedAccountsList; // Use the updated list which includes newly created/updated accounts - setAccounts(latestAccountsList); // Ensure the main page state is also updated + latestAccountsList = accountMapResult.updatedAccountsList; + setAccounts(latestAccountsList); setFinalAccountMapForImport(finalMapForTxImport); } catch (finalAccountMapError) { console.error("Critical error during account finalization before import.", finalAccountMapError); @@ -1107,7 +1099,6 @@ export default function DataManagementPage() { let creditAmount = Math.abs(csvAmount); let creditCurrency = csvCurrency; - // If foreign amount/currency are present in Firefly for transfers, it means the destination received a different amount/currency if (csvForeignAmount != null && csvForeignCurrency && csvForeignCurrency.trim() !== '') { creditAmount = Math.abs(csvForeignAmount); creditCurrency = csvForeignCurrency; @@ -1189,7 +1180,6 @@ export default function DataManagementPage() { } } - // Ensure transactions are sorted by date before processing to correctly update account balances chronologically transactionPayloads.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); for (const payload of transactionPayloads) { @@ -1210,7 +1200,7 @@ export default function DataManagementPage() { category: payload.category, tags: payload.tags, originalImportData: payload.originalImportData, - }); // This will now also update the account balance inside addTransaction + }); if(itemIndexInDisplay !== -1) { updatedDataForDisplay[itemIndexInDisplay] = { ...updatedDataForDisplay[itemIndexInDisplay], importStatus: 'success', errorMessage: undefined }; } @@ -1242,7 +1232,6 @@ export default function DataManagementPage() { setError(null); window.dispatchEvent(new Event('storage')); } - // Final refetch of accounts to ensure balances displayed on other pages (like Accounts page) are fully up-to-date setAccounts(await getAccounts()); }; @@ -1294,7 +1283,7 @@ export default function DataManagementPage() { setIsExporting(true); toast({ title: "Exporting Data", description: "Preparing your data for download. This may take a moment..." }); try { - await exportAllUserDataToZip(); // Use the new ZIP export function + await exportAllUserDataToZip(); toast({ title: "Export Complete", description: "Your data backup ZIP file should be downloading now. Please check your browser's download folder." }); } catch (error) { console.error("Export failed:", error); @@ -1482,7 +1471,7 @@ export default function DataManagementPage() { {isLoading && importProgress > 0 ? `Importing... (${importProgress}%)` : "Import Transactions"} From bfe6173018818d84e9588e4d1d864f2a3c186267 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 20 May 2025 23:06:43 +0000 Subject: [PATCH 016/156] Faca isso para mim --- src/app/data-management/page.tsx | 363 ++++++++++++++++++++++++++++--- src/services/export.ts | 76 +++++-- 2 files changed, 389 insertions(+), 50 deletions(-) diff --git a/src/app/data-management/page.tsx b/src/app/data-management/page.tsx index 33ab05a..4fefa57 100644 --- a/src/app/data-management/page.tsx +++ b/src/app/data-management/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import Papa, { ParseResult } from 'papaparse'; -import JSZip from 'jszip'; // Import JSZip +import JSZip from 'jszip'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -12,10 +12,18 @@ import { useToast } from "@/hooks/use-toast"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Progress } from "@/components/ui/progress"; -import { addTransaction, type Transaction, clearAllSessionTransactions } from '@/services/transactions'; +import { addTransaction, type Transaction, clearAllSessionTransactions, type NewTransactionData } from '@/services/transactions'; import { getAccounts, addAccount, type Account, type NewAccountData, updateAccount } from '@/services/account-sync'; -import { getCategories, addCategory as addCategoryToDb, type Category } from '@/services/categories'; +import { getCategories, addCategory as addCategoryToDb, type Category, updateCategory as updateCategoryInDb } from '@/services/categories'; import { getTags, addTag as addTagToDb, type Tag } from '@/services/tags'; +import { getGroups, addGroup as addGroupToDb, updateGroup as updateGroupInDb, type Group } from '@/services/groups'; +import { getSubscriptions, addSubscription as addSubscriptionToDb, type Subscription } from '@/services/subscriptions'; +import { getLoans, addLoan as addLoanToDb, type Loan } from '@/services/loans'; +import { getCreditCards, addCreditCard as addCreditCardToDb, type CreditCard } from '@/services/credit-cards'; +import { getBudgets, addBudget as addBudgetToDb, type Budget } from '@/services/budgets'; +import { saveUserPreferences, type UserPreferences, getUserPreferences } from '@/lib/preferences'; + + import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; @@ -23,7 +31,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, import { format, parseISO, isValid, parse as parseDateFns } from 'date-fns'; import { getCurrencySymbol, supportedCurrencies, formatCurrency, convertCurrency } from '@/lib/currency'; import CsvMappingForm, { type ColumnMapping } from '@/components/import/csv-mapping-form'; -import { AlertCircle, Trash2, Download } from 'lucide-react'; // Removed FileZip +import { AlertCircle, Trash2, Download } from 'lucide-react'; import { cn } from '@/lib/utils'; import { useAuthContext } from '@/contexts/AuthContext'; import Link from 'next/link'; @@ -176,7 +184,7 @@ const parseNameFromDescriptiveString = (text: string | undefined): string | unde export default function DataManagementPage() { - const { user, isLoadingAuth } = useAuthContext(); + const { user, isLoadingAuth, refreshUserPreferences } = useAuthContext(); const [file, setFile] = useState(null); const [csvHeaders, setCsvHeaders] = useState([]); const [rawData, setRawData] = useState([]); @@ -195,6 +203,10 @@ export default function DataManagementPage() { const [isExporting, setIsExporting] = useState(false); const { toast } = useToast(); + // New state for restore confirmation + const [isRestoreConfirmOpen, setIsRestoreConfirmOpen] = useState(false); + const [zipFileForRestore, setZipFileForRestore] = useState(null); + const fetchData = useCallback(async () => { if (typeof window === 'undefined' || !user || isLoadingAuth) { @@ -202,7 +214,7 @@ export default function DataManagementPage() { return; } let isMounted = true; - setIsLoading(true); + if(isMounted) setIsLoading(true); // Only set loading if mounted setError(null); try { @@ -229,7 +241,7 @@ export default function DataManagementPage() { } } return () => { isMounted = false; }; - }, [user, isLoadingAuth]); + }, [user, isLoadingAuth]); // Removed toast useEffect(() => { fetchData(); @@ -246,6 +258,8 @@ export default function DataManagementPage() { setImportProgress(0); setColumnMappings({}); setFinalAccountMapForImport({}); + setZipFileForRestore(null); + setIsRestoreConfirmOpen(false); } }; @@ -279,12 +293,11 @@ export default function DataManagementPage() { } setCsvHeaders(headers.filter(h => h != null) as string[]); - setRawData(results.data); // This rawData will be used by processAndMapData + setRawData(results.data); const detectedHeaders = headers.filter(h => h != null) as string[]; const initialMappings: ColumnMapping = {}; - // Auto-detect common Firefly III CSV headers initialMappings.date = findColumnName(detectedHeaders, 'date'); initialMappings.amount = findColumnName(detectedHeaders, 'amount'); initialMappings.description = findColumnName(detectedHeaders, 'description'); @@ -302,7 +315,7 @@ export default function DataManagementPage() { initialMappings.initialBalance = findColumnName(detectedHeaders, 'initial_balance') || findColumnName(detectedHeaders, 'opening_balance'); setColumnMappings(initialMappings); - setIsMappingDialogOpen(true); // Open mapping dialog + setIsMappingDialogOpen(true); setIsLoading(false); }, error: (err: Error) => { @@ -329,12 +342,26 @@ export default function DataManagementPage() { setAccountPreviewData([]); setRawData([]); setCsvHeaders([]); + setZipFileForRestore(null); if (file.name.endsWith('.zip') || file.type === 'application/zip' || file.type === 'application/x-zip-compressed') { try { const zip = await JSZip.loadAsync(file); - let primaryCsvFile: JSZip.JSZipObject | null = null; + const manifestFile = zip.file('goldquest_manifest.json'); + if (manifestFile) { + const manifestContent = await manifestFile.async('string'); + const manifest = JSON.parse(manifestContent); + if (manifest.appName === "GoldQuest") { + setZipFileForRestore(file); + setIsRestoreConfirmOpen(true); + setIsLoading(false); // Stop general loading, dialog handles next step + return; + } + } + + // If not a GoldQuest backup or no manifest, try to find a primary CSV + let primaryCsvFile: JSZip.JSZipObject | null = null; const commonPrimaryNames = ['transactions.csv', 'firefly_iii_export.csv', 'default.csv']; for (const name of commonPrimaryNames) { const foundFile = zip.file(name); @@ -343,7 +370,6 @@ export default function DataManagementPage() { break; } } - if (!primaryCsvFile) { let largestSize = 0; zip.forEach((relativePath, zipEntry) => { @@ -358,11 +384,11 @@ export default function DataManagementPage() { } if (primaryCsvFile) { - toast({ title: "ZIP Detected", description: `Processing '${primaryCsvFile.name}' from the archive.`, duration: 4000}); + toast({ title: "ZIP Detected", description: `Processing '${primaryCsvFile.name}' from the archive for manual mapping.`, duration: 4000}); const csvString = await primaryCsvFile.async("string"); processCsvData(csvString, primaryCsvFile.name); } else { - setError("No suitable CSV file found within the ZIP archive to process for mapping. Expected names like 'transactions.csv' or 'firefly_iii_export.csv'."); + setError("No suitable CSV file found within the ZIP archive to process for mapping. If this is a GoldQuest backup, it might be missing a manifest."); setIsLoading(false); } } catch (zipError: any) { @@ -1430,6 +1456,266 @@ export default function DataManagementPage() { }, {} as typeof grouped); }, [parsedData, accountPreviewData, finalAccountMapForImport, accounts]); + // Full Restore Logic + const handleFullRestoreFromZip = async () => { + if (!zipFileForRestore || !user) { + toast({ title: "Error", description: "No backup file selected or user not authenticated.", variant: "destructive" }); + setIsRestoreConfirmOpen(false); + return; + } + setIsLoading(true); + setImportProgress(0); + setError(null); + let overallSuccess = true; + + try { + toast({ title: "Restore Started", description: "Clearing existing data...", duration: 2000 }); + await clearAllSessionTransactions(); + toast({ title: "Restore Progress", description: "Existing data cleared. Starting import...", duration: 2000 }); + + const zip = await JSZip.loadAsync(zipFileForRestore); + let progressCounter = 0; + const totalFilesToProcess = 10; // Approx count of CSV files + + const updateProgress = () => { + progressCounter++; + setImportProgress(calculateProgress(progressCounter, totalFilesToProcess)); + }; + + const oldAccountIdToNewIdMap: Record = {}; + const oldCategoryIdToNewIdMap: Record = {}; + const oldGroupIdToNewIdMap: Record = {}; + + // 1. Preferences + const prefsFile = zip.file('goldquest_preferences.csv'); + if (prefsFile) { + const prefsCsv = await prefsFile.async('text'); + const parsedPrefs = Papa.parse(prefsCsv, { header: true, skipEmptyLines: true }).data[0]; + if (parsedPrefs) { + await saveUserPreferences(parsedPrefs); + await refreshUserPreferences(); // AuthContext function + toast({ title: "Restore Progress", description: "Preferences restored."}); + } + } + updateProgress(); + + // 2. Categories + const categoriesFile = zip.file('goldquest_categories.csv'); + if (categoriesFile) { + const categoriesCsv = await categoriesFile.async('text'); + const parsedCategories = Papa.parse(categoriesCsv, { header: true, skipEmptyLines: true }).data; + for (const cat of parsedCategories) { + if(cat.id && cat.name) { // Ensure critical fields are present + try { + const newCategory = await addCategoryToDb(cat.name, cat.icon); + oldCategoryIdToNewIdMap[cat.id] = newCategory.id; + } catch (e: any) { + if (!e.message?.includes('already exists')) { + console.warn(`Skipping category restore due to error: ${e.message}`, cat); + overallSuccess = false; + } else { // If it already exists, try to find and map its ID + const existingCats = await getCategories(); + const existing = existingCats.find(ec => ec.name.toLowerCase() === cat.name.toLowerCase()); + if (existing) oldCategoryIdToNewIdMap[cat.id] = existing.id; + } + } + } + } + toast({ title: "Restore Progress", description: "Categories restored."}); + } + updateProgress(); + setCategories(await getCategories()); + + + // 3. Tags + const tagsFile = zip.file('goldquest_tags.csv'); + if (tagsFile) { + const tagsCsv = await tagsFile.async('text'); + const parsedTags = Papa.parse(tagsCsv, { header: true, skipEmptyLines: true }).data; + for (const tag of parsedTags) { + if (tag.name) { + try { + await addTagToDb(tag.name); + } catch (e: any) { + if (!e.message?.includes('already exists')) { + console.warn(`Skipping tag restore due to error: ${e.message}`, tag); + overallSuccess = false; + } + } + } + } + toast({ title: "Restore Progress", description: "Tags restored."}); + } + updateProgress(); + setTags(await getTags()); + + // 4. Accounts + const accountsFile = zip.file('goldquest_accounts.csv'); + if (accountsFile) { + const accountsCsv = await accountsFile.async('text'); + const parsedAccounts = Papa.parse(accountsCsv, { header: true, skipEmptyLines: true, dynamicTyping: true }).data; + for (const acc of parsedAccounts) { + if(acc.id && acc.name && acc.currency && acc.type && acc.balance !== undefined) { + const newAccData: NewAccountData = { + name: acc.name, + type: acc.type, + balance: typeof acc.balance === 'string' ? parseFloat(acc.balance) : acc.balance, + currency: acc.currency, + providerName: acc.providerName || 'Restored', + category: acc.category || 'asset', + isActive: acc.isActive !== undefined ? acc.isActive : true, + lastActivity: acc.lastActivity || new Date().toISOString(), + balanceDifference: acc.balanceDifference || 0, + includeInNetWorth: acc.includeInNetWorth !== undefined ? acc.includeInNetWorth : true, + }; + try { + const newAccount = await addAccount(newAccData); + oldAccountIdToNewIdMap[acc.id] = newAccount.id; + } catch (e: any) { + console.error(`Error restoring account ${acc.name}: ${e.message}`); + overallSuccess = false; + } + } + } + toast({ title: "Restore Progress", description: "Accounts restored."}); + } + updateProgress(); + setAccounts(await getAccounts()); + + // 5. Groups + const groupsFile = zip.file('goldquest_groups.csv'); + if (groupsFile) { + const groupsCsv = await groupsFile.async('text'); + const parsedGroups = Papa.parse<{ id: string; name: string; categoryIds: string }>(groupsCsv, { header: true, skipEmptyLines: true }).data; + for (const group of parsedGroups) { + if (group.id && group.name) { + const oldCatIds = group.categoryIds ? group.categoryIds.split('|').filter(Boolean) : []; + const newCatIds = oldCatIds.map(oldId => oldCategoryIdToNewIdMap[oldId]).filter(Boolean); + try { + const newGroup = await addGroupToDb(group.name); + if (newCatIds.length > 0) { + await updateGroupInDb({ ...newGroup, categoryIds: newCatIds }); + } + oldGroupIdToNewIdMap[group.id] = newGroup.id; + } catch (e: any) { + if (!e.message?.includes('already exists')) { + console.error(`Error restoring group ${group.name}: ${e.message}`); + overallSuccess = false; + } else { + const existingGroups = await getGroups(); + const existing = existingGroups.find(eg => eg.name.toLowerCase() === group.name.toLowerCase()); + if(existing) oldGroupIdToNewIdMap[group.id] = existing.id; + } + } + } + } + toast({ title: "Restore Progress", description: "Groups restored." }); + } + updateProgress(); + // setGroups(await getGroups()); // Assuming getGroups updates local state + + // 6. Transactions + const transactionsFile = zip.file('goldquest_transactions.csv'); + if (transactionsFile) { + const transactionsCsv = await transactionsFile.async('text'); + const parsedTransactions = Papa.parse(transactionsCsv, { header: true, skipEmptyLines: true, dynamicTyping: true }).data; + for (const tx of parsedTransactions) { + if (tx.id && tx.accountId && tx.date && tx.amount !== undefined && tx.transactionCurrency && tx.category) { + const newAccountId = oldAccountIdToNewIdMap[tx.accountId]; + if (newAccountId) { + const newTxData: NewTransactionData = { + date: typeof tx.date === 'string' ? tx.date : formatDateFns(new Date(tx.date as any), 'yyyy-MM-dd'), + amount: typeof tx.amount === 'string' ? parseFloat(tx.amount) : tx.amount, + transactionCurrency: tx.transactionCurrency, + description: tx.description || 'Restored Transaction', + category: tx.category, + accountId: newAccountId, + tags: tx.tags ? (tx.tags as string).split('|').filter(Boolean) : [], + originalImportData: tx.originalImportData ? JSON.parse(tx.originalImportData as string) : undefined, + }; + try { + await addTransaction(newTxData); + } catch (e: any) { + console.error(`Error restoring transaction ${tx.description}: ${e.message}`); + overallSuccess = false; + } + } else { + console.warn(`Could not map old account ID ${tx.accountId} for transaction ${tx.description}`); + } + } + } + toast({ title: "Restore Progress", description: "Transactions restored." }); + } + updateProgress(); + + // Placeholders for other data types (Subscriptions, Loans, CreditCards, Budgets) + // For each, parse its CSV, remap IDs (accountId, categoryId, groupId etc.) and call its add service. + const dataTypesToRestore = [ + { name: 'Subscriptions', file: 'goldquest_subscriptions.csv', addFn: addSubscriptionToDb, serviceName: 'subscription' }, + { name: 'Loans', file: 'goldquest_loans.csv', addFn: addLoanToDb, serviceName: 'loan' }, + { name: 'Credit Cards', file: 'goldquest_credit_cards.csv', addFn: addCreditCardToDb, serviceName: 'credit card' }, + { name: 'Budgets', file: 'goldquest_budgets.csv', addFn: addBudgetToDb, serviceName: 'budget' }, + ]; + + for (const dataType of dataTypesToRestore) { + const fileContent = zip.file(dataType.file); + if (fileContent) { + const csvData = await fileContent.async('text'); + const parsedItems = Papa.parse(csvData, { header: true, skipEmptyLines: true, dynamicTyping: true }).data; + for (const item of parsedItems) { + try { + let itemData = { ...item }; + delete itemData.id; // Remove old ID + if (itemData.accountId) itemData.accountId = oldAccountIdToNewIdMap[itemData.accountId] || itemData.accountId; + if (itemData.groupId) itemData.groupId = oldGroupIdToNewIdMap[itemData.groupId] || itemData.groupId; + + if(dataType.serviceName === 'subscription' && itemData.tags && typeof itemData.tags === 'string') itemData.tags = itemData.tags.split('|').filter(Boolean); + if(dataType.serviceName === 'budget' && itemData.selectedIds && typeof itemData.selectedIds === 'string') { + const oldSelectedIds = itemData.selectedIds.split('|').filter(Boolean); + itemData.selectedIds = oldSelectedIds.map((oldId: string) => + itemData.appliesTo === 'categories' ? oldCategoryIdToNewIdMap[oldId] : oldGroupIdToNewIdMap[oldId] + ).filter(Boolean); + } + // Convert date strings if necessary + if (itemData.startDate && typeof itemData.startDate !== 'string') itemData.startDate = formatDateFns(new Date(itemData.startDate), 'yyyy-MM-dd'); + if (itemData.nextPaymentDate && typeof itemData.nextPaymentDate !== 'string') itemData.nextPaymentDate = formatDateFns(new Date(itemData.nextPaymentDate), 'yyyy-MM-dd'); + if (itemData.endDate && typeof itemData.endDate !== 'string') itemData.endDate = formatDateFns(new Date(itemData.endDate), 'yyyy-MM-dd'); + if (itemData.paymentDueDate && typeof itemData.paymentDueDate !== 'string') itemData.paymentDueDate = formatDateFns(new Date(itemData.paymentDueDate), 'yyyy-MM-dd'); + + + await dataType.addFn(itemData); + } catch (e: any) { + console.error(`Error restoring ${dataType.name} item: ${e.message}`, item); + overallSuccess = false; + } + } + toast({ title: "Restore Progress", description: `${dataType.name} restored.` }); + } + updateProgress(); + } + + + if (overallSuccess) { + toast({ title: "Restore Complete", description: "All data restored successfully.", duration: 5000 }); + } else { + toast({ title: "Restore Partially Complete", description: "Some data could not be restored. Check console for errors.", variant: "destructive", duration: 10000 }); + } + await fetchData(); // Refresh page data + window.dispatchEvent(new Event('storage')); // Notify other components + + } catch (restoreError: any) { + console.error("Full restore failed:", restoreError); + setError(`Full restore failed: ${restoreError.message}`); + toast({ title: "Restore Failed", description: restoreError.message || "An unknown error occurred during restore.", variant: "destructive" }); + } finally { + setIsLoading(false); + setImportProgress(100); + setIsRestoreConfirmOpen(false); + setZipFileForRestore(null); + } + }; + + if (isLoadingAuth) { return

Loading authentication...

; @@ -1444,9 +1730,9 @@ export default function DataManagementPage() { - Step 1: Upload CSV or ZIP File + Step 1: Upload CSV or GoldQuest Backup ZIP File - Select your CSV file (Firefly III export is best supported) or a GoldQuest backup ZIP file. Map columns carefully in the next step. Ensure file is UTF-8 encoded. + Select your CSV file (Firefly III export is best supported) or a GoldQuest backup ZIP file. Map columns if needed. Ensure file is UTF-8 encoded. @@ -1464,11 +1750,11 @@ export default function DataManagementPage() { )}
- - - @@ -1485,7 +1771,7 @@ export default function DataManagementPage() { Are you absolutely sure? - This action cannot be undone. This will permanently delete ALL your accounts, categories, tags, groups, subscriptions and transactions from the database. This is intended for testing or resetting your data. + This action cannot be undone. This will permanently delete ALL your accounts, categories, tags, groups, subscriptions, transactions, loans, credit cards, and budgets from the database. This is intended for testing or resetting your data. @@ -1509,7 +1795,7 @@ export default function DataManagementPage() { Step 2: Map CSV Columns - Match CSV columns (right) to application fields (left). For Firefly III CSVs, ensure 'type', 'amount', 'currency_code', 'date', 'source_name', and 'destination_name' are correctly mapped. If importing a GoldQuest backup, mapping should be automatic for supported files. + Match CSV columns (right) to application fields (left). For Firefly III CSVs, ensure 'type', 'amount', 'currency_code', 'date', 'source_name', and 'destination_name' are correctly mapped. - - {accountPreviewData.length > 0 && !isLoading && ( + + { + if (!open) setZipFileForRestore(null); // Clear file if dialog is cancelled + setIsRestoreConfirmOpen(open); + }}> + + + Confirm Full Restore from Backup + + You've uploaded a GoldQuest backup ZIP file. + Restoring from this backup will clear all your current data (accounts, transactions, categories, etc.) and replace it with the data from the backup. + This action cannot be undone. Are you sure you want to proceed? + + + + {setIsRestoreConfirmOpen(false); setZipFileForRestore(null);}} disabled={isLoading}>Cancel + + {isLoading ? "Restoring..." : "Yes, Restore from Backup"} + + + + + + + {accountPreviewData.length > 0 && !isLoading && !isRestoreConfirmOpen && ( Account Changes Preview @@ -1559,11 +1868,11 @@ export default function DataManagementPage() { )} - {parsedData.length > 0 && ( + {parsedData.length > 0 && !isRestoreConfirmOpen && ( Review & Import ({parsedData.filter(i => i.importStatus === 'pending').length} Pending Rows) - Review transactions. Rows marked 'Error' or 'Skipped' (like Opening Balances) won't be imported as transactions. Edit fields if needed. Click "Import Transactions" above when ready. + Review transactions. Rows marked 'Error' or 'Skipped' (like Opening Balances) won't be imported as transactions. Edit fields if needed. Click "Import Mapped Data" above when ready. {Object.entries(groupedTransactionsForPreview).map(([accountGroupKey, transactionsInGroup]) => { diff --git a/src/services/export.ts b/src/services/export.ts index 295f92f..401d863 100644 --- a/src/services/export.ts +++ b/src/services/export.ts @@ -27,48 +27,59 @@ function downloadBlob(blob: Blob, filename: string) { document.body.removeChild(link); URL.revokeObjectURL(url); } else { - alert('File download is not supported by your browser.'); + // Fallback for browsers that don't support the download attribute + // This might open the blob in a new tab depending on the browser and blob type + // For a zip file, it usually still triggers a download or prompts the user. + const newWindow = window.open(URL.createObjectURL(blob), '_blank'); + if (!newWindow) { + alert('File download is not fully supported by your browser or was blocked. Please check your pop-up blocker settings.'); + } } } const formatDateForExport = (dateInput: object | string | undefined | null): string => { if (!dateInput) return ''; if (typeof dateInput === 'string') { + // Attempt to parse if it looks like an ISO string already, otherwise return as is const parsed = parseISO(dateInput); return isValid(parsed) ? formatDateFns(parsed, "yyyy-MM-dd'T'HH:mm:ssXXX") : dateInput; } + // Check for Firebase ServerTimestamp placeholder object which is an object with no direct date properties. + // A common way to check is if it's an object and doesn't have typical Date methods. + // For robust handling, if you expect specific Firebase Timestamp objects, you might check for `toDate` method. if (typeof dateInput === 'object' && dateInput !== null) { if ('toDate' in dateInput && typeof (dateInput as any).toDate === 'function') { return formatDateFns((dateInput as any).toDate(), "yyyy-MM-dd'T'HH:mm:ssXXX"); } - // For Firebase serverTimestamp placeholder object, convert to current date as an example + // For Firebase serverTimestamp placeholder object, or other objects, convert to a placeholder or empty string // In a real scenario, this would be handled by ensuring data is read after server resolves it - return new Date().toISOString(); + // For export, if it's a placeholder, it's better to represent it as such or empty + return 'SERVER_TIMESTAMP_PLACEHOLDER'; // Or return an empty string or a specific string indicating it's a server value } - if (typeof dateInput === 'number') { + if (typeof dateInput === 'number') { // Assuming it's a Unix timestamp (milliseconds) return formatDateFns(new Date(dateInput), "yyyy-MM-dd'T'HH:mm:ssXXX"); } - return String(dateInput); + return String(dateInput); // Fallback for other types }; interface ExportableTransaction extends Omit { - tags?: string; - originalImportData?: string; - createdAt?: string; - updatedAt?: string; + tags?: string; // Pipe-separated + originalImportData?: string; // JSON string + createdAt?: string; // ISO Timestamp + updatedAt?: string; // ISO Timestamp } interface ExportableSubscription extends Omit { - tags?: string; + tags?: string; // Pipe-separated createdAt?: string; updatedAt?: string; } interface ExportableGroup extends Omit { - categoryIds?: string; + categoryIds?: string; // Pipe-separated } interface ExportableBudget extends Omit { - selectedIds?: string; + selectedIds?: string; // Pipe-separated createdAt?: string; updatedAt?: string; } @@ -79,26 +90,36 @@ export async function exportAllUserDataToZip(): Promise { const timestamp = formatDateFns(new Date(), 'yyyyMMdd_HHmmss'); const zipFilename = `goldquest_backup_${timestamp}.zip`; + const manifest = { + backupVersion: "1.0.0", + exportedAt: new Date().toISOString(), + appName: "GoldQuest", + contains: [] as string[], + }; + try { - console.log("Exporting: Fetching User Preferences..."); + console.log("Exporting: User Preferences..."); const preferences = await getUserPreferences(); if (preferences) { zip.file('goldquest_preferences.csv', Papa.unparse([preferences])); + manifest.contains.push('preferences'); } - console.log("Exporting: Fetching Categories..."); + console.log("Exporting: Categories..."); const categories = await getCategories(); if (categories.length > 0) { zip.file('goldquest_categories.csv', Papa.unparse(categories)); + manifest.contains.push('categories'); } - console.log("Exporting: Fetching Tags..."); + console.log("Exporting: Tags..."); const tags = await getTags(); if (tags.length > 0) { zip.file('goldquest_tags.csv', Papa.unparse(tags)); + manifest.contains.push('tags'); } - console.log("Exporting: Fetching Groups..."); + console.log("Exporting: Groups..."); const groups = await getGroups(); if (groups.length > 0) { const exportableGroups: ExportableGroup[] = groups.map(g => ({ @@ -106,16 +127,18 @@ export async function exportAllUserDataToZip(): Promise { categoryIds: g.categoryIds ? g.categoryIds.join('|') : '', })); zip.file('goldquest_groups.csv', Papa.unparse(exportableGroups)); + manifest.contains.push('groups'); } - console.log("Exporting: Fetching Accounts..."); + console.log("Exporting: Accounts..."); const accounts = await getAccounts(); if (accounts.length > 0) { - zip.file('goldquest_accounts.csv', Papa.unparse(accounts)); + zip.file('goldquest_accounts.csv', Papa.unparse(accounts.map(acc => ({...acc, includeInNetWorth: acc.includeInNetWorth ?? true})))); + manifest.contains.push('accounts'); } if (accounts.length > 0) { - console.log("Exporting: Fetching Transactions..."); + console.log("Exporting: Transactions..."); let allTransactions: Transaction[] = []; for (const account of accounts) { try { @@ -134,10 +157,11 @@ export async function exportAllUserDataToZip(): Promise { updatedAt: formatDateForExport(tx.updatedAt), })); zip.file('goldquest_transactions.csv', Papa.unparse(exportableTransactions)); + manifest.contains.push('transactions'); } } - console.log("Exporting: Fetching Subscriptions..."); + console.log("Exporting: Subscriptions..."); const subscriptions = await getSubscriptions(); if (subscriptions.length > 0) { const exportableSubscriptions: ExportableSubscription[] = subscriptions.map(sub => ({ @@ -147,9 +171,10 @@ export async function exportAllUserDataToZip(): Promise { updatedAt: formatDateForExport(sub.updatedAt), })); zip.file('goldquest_subscriptions.csv', Papa.unparse(exportableSubscriptions)); + manifest.contains.push('subscriptions'); } - console.log("Exporting: Fetching Loans..."); + console.log("Exporting: Loans..."); const loans = await getLoans(); if (loans.length > 0) { const exportableLoans = loans.map(loan => ({ @@ -158,9 +183,10 @@ export async function exportAllUserDataToZip(): Promise { updatedAt: formatDateForExport(loan.updatedAt), })); zip.file('goldquest_loans.csv', Papa.unparse(exportableLoans)); + manifest.contains.push('loans'); } - console.log("Exporting: Fetching Credit Cards..."); + console.log("Exporting: Credit Cards..."); const creditCards = await getCreditCards(); if (creditCards.length > 0) { const exportableCreditCards = creditCards.map(card => ({ @@ -169,9 +195,10 @@ export async function exportAllUserDataToZip(): Promise { updatedAt: formatDateForExport(card.updatedAt), })); zip.file('goldquest_credit_cards.csv', Papa.unparse(exportableCreditCards)); + manifest.contains.push('creditCards'); } - console.log("Exporting: Fetching Budgets..."); + console.log("Exporting: Budgets..."); const budgets = await getBudgets(); if (budgets.length > 0) { const exportableBudgets: ExportableBudget[] = budgets.map(b => ({ @@ -181,7 +208,10 @@ export async function exportAllUserDataToZip(): Promise { updatedAt: formatDateForExport(b.updatedAt), })); zip.file('goldquest_budgets.csv', Papa.unparse(exportableBudgets)); + manifest.contains.push('budgets'); } + + zip.file('goldquest_manifest.json', JSON.stringify(manifest, null, 2)); const zipContent = await zip.generateAsync({ type: "blob" }); downloadBlob(zipContent, zipFilename); From b7499e15a0fa38b6e1d4e166bc0c42f96143dfad Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 20 May 2025 23:13:28 +0000 Subject: [PATCH 017/156] Nao quero ter que colocar os campos manualmente na importacao do arquvio ZIP gerado exclusivamente pelo nosso app. Nao consegue reconhecer todos? --- src/app/data-management/page.tsx | 53 +++++++++++++++++--------------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/src/app/data-management/page.tsx b/src/app/data-management/page.tsx index 4fefa57..49b3f05 100644 --- a/src/app/data-management/page.tsx +++ b/src/app/data-management/page.tsx @@ -14,8 +14,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@ import { Progress } from "@/components/ui/progress"; import { addTransaction, type Transaction, clearAllSessionTransactions, type NewTransactionData } from '@/services/transactions'; import { getAccounts, addAccount, type Account, type NewAccountData, updateAccount } from '@/services/account-sync'; -import { getCategories, addCategory as addCategoryToDb, type Category, updateCategory as updateCategoryInDb } from '@/services/categories'; -import { getTags, addTag as addTagToDb, type Tag } from '@/services/tags'; +import { getCategories, addCategory as addCategoryToDb, type Category, updateCategory as updateCategoryInDb, getCategoryStyle } from '@/services/categories'; +import { getTags, addTag as addTagToDb, type Tag, getTagStyle } from '@/services/tags'; import { getGroups, addGroup as addGroupToDb, updateGroup as updateGroupInDb, type Group } from '@/services/groups'; import { getSubscriptions, addSubscription as addSubscriptionToDb, type Subscription } from '@/services/subscriptions'; import { getLoans, addLoan as addLoanToDb, type Loan } from '@/services/loans'; @@ -209,12 +209,13 @@ export default function DataManagementPage() { const fetchData = useCallback(async () => { + let isMounted = true; + if (isMounted) setIsLoading(true); + if (typeof window === 'undefined' || !user || isLoadingAuth) { - setIsLoading(false); + if(isMounted) setIsLoading(false); return; } - let isMounted = true; - if(isMounted) setIsLoading(true); // Only set loading if mounted setError(null); try { @@ -241,7 +242,7 @@ export default function DataManagementPage() { } } return () => { isMounted = false; }; - }, [user, isLoadingAuth]); // Removed toast + }, [user, isLoadingAuth, toast]); // Added toast to dependency array as it's used in error handling useEffect(() => { fetchData(); @@ -315,7 +316,7 @@ export default function DataManagementPage() { initialMappings.initialBalance = findColumnName(detectedHeaders, 'initial_balance') || findColumnName(detectedHeaders, 'opening_balance'); setColumnMappings(initialMappings); - setIsMappingDialogOpen(true); + setIsMappingDialogOpen(true); // Show mapping dialog for generic CSV or non-GoldQuest ZIP setIsLoading(false); }, error: (err: Error) => { @@ -343,24 +344,25 @@ export default function DataManagementPage() { setRawData([]); setCsvHeaders([]); setZipFileForRestore(null); + setIsMappingDialogOpen(false); // Reset mapping dialog state if (file.name.endsWith('.zip') || file.type === 'application/zip' || file.type === 'application/x-zip-compressed') { try { const zip = await JSZip.loadAsync(file); const manifestFile = zip.file('goldquest_manifest.json'); - if (manifestFile) { + if (manifestFile) { // Detected a GoldQuest backup const manifestContent = await manifestFile.async('string'); const manifest = JSON.parse(manifestContent); if (manifest.appName === "GoldQuest") { setZipFileForRestore(file); - setIsRestoreConfirmOpen(true); - setIsLoading(false); // Stop general loading, dialog handles next step - return; + setIsRestoreConfirmOpen(true); // Go directly to restore confirmation + setIsLoading(false); + return; // Skip manual mapping } } - // If not a GoldQuest backup or no manifest, try to find a primary CSV + // If not a GoldQuest backup or no manifest, try to find a primary CSV for manual mapping let primaryCsvFile: JSZip.JSZipObject | null = null; const commonPrimaryNames = ['transactions.csv', 'firefly_iii_export.csv', 'default.csv']; for (const name of commonPrimaryNames) { @@ -370,7 +372,7 @@ export default function DataManagementPage() { break; } } - if (!primaryCsvFile) { + if (!primaryCsvFile) { // Fallback to largest CSV if common names not found let largestSize = 0; zip.forEach((relativePath, zipEntry) => { if (zipEntry.name.toLowerCase().endsWith('.csv') && !zipEntry.dir) { @@ -386,20 +388,20 @@ export default function DataManagementPage() { if (primaryCsvFile) { toast({ title: "ZIP Detected", description: `Processing '${primaryCsvFile.name}' from the archive for manual mapping.`, duration: 4000}); const csvString = await primaryCsvFile.async("string"); - processCsvData(csvString, primaryCsvFile.name); + processCsvData(csvString, primaryCsvFile.name); // This will open mapping dialog } else { - setError("No suitable CSV file found within the ZIP archive to process for mapping. If this is a GoldQuest backup, it might be missing a manifest."); + setError("No suitable CSV file found within the ZIP archive to process for mapping. If this is a GoldQuest backup, it might be missing a manifest or CSV files."); setIsLoading(false); } } catch (zipError: any) { setError(`Failed to process ZIP file: ${zipError.message}`); setIsLoading(false); } - } else if (file.name.endsWith('.csv') || file.type === 'text/csv') { + } else if (file.name.endsWith('.csv') || file.type === 'text/csv') { // Direct CSV upload const reader = new FileReader(); reader.onload = (event) => { if (event.target?.result && typeof event.target.result === 'string') { - processCsvData(event.target.result, file.name); + processCsvData(event.target.result, file.name); // This will open mapping dialog } else { setError("Failed to read CSV file content."); setIsLoading(false); @@ -633,20 +635,20 @@ export default function DataManagementPage() { } } return { - date: parseDate(record[dateCol!]), + date: parseDate(record[confirmedMappings.date!]), amount: 0, - currency: record[currencyCol!]?.trim().toUpperCase() || 'N/A', + currency: record[confirmedMappings.currency_code!]?.trim().toUpperCase() || 'N/A', description: `Error Processing Row ${index + 2}`, category: 'Uncategorized', tags: [], originalRecord: errorSanitizedRecord, importStatus: 'error', errorMessage: rowError.message || 'Failed to process row.', - csvRawSourceName: (sourceNameCol && record[sourceNameCol] ? record[sourceNameCol]?.trim() : undefined) ?? null, - csvRawDestinationName: (destNameCol && record[destNameCol] ? record[destNameCol]?.trim() : undefined) ?? null, - csvTransactionType: (typeCol && record[typeCol] ? record[typeCol]?.trim().toLowerCase() : undefined) ?? null, - csvSourceType: (sourceTypeCol && record[sourceTypeCol] ? record[sourceTypeCol]?.trim().toLowerCase() : undefined) ?? null, - csvDestinationType: (destTypeCol && record[destTypeCol] ? record[destTypeCol]?.trim().toLowerCase() : undefined) ?? null, + csvRawSourceName: (confirmedMappings.source_name && record[confirmedMappings.source_name] ? record[confirmedMappings.source_name]?.trim() : undefined) ?? null, + csvRawDestinationName: (confirmedMappings.destination_name && record[confirmedMappings.destination_name] ? record[confirmedMappings.destination_name]?.trim() : undefined) ?? null, + csvTransactionType: (confirmedMappings.transaction_type && record[confirmedMappings.transaction_type] ? record[confirmedMappings.transaction_type]?.trim().toLowerCase() : undefined) ?? null, + csvSourceType: (confirmedMappings.source_type && record[confirmedMappings.source_type] ? record[confirmedMappings.source_type]?.trim().toLowerCase() : undefined) ?? null, + csvDestinationType: (confirmedMappings.destination_type && record[confirmedMappings.destination_type] ? record[confirmedMappings.destination_type]?.trim().toLowerCase() : undefined) ?? null, foreignAmount: null, foreignCurrency: null, appSourceAccountId: null, @@ -1776,7 +1778,7 @@ export default function DataManagementPage() { setIsClearing(false)} disabled={isClearing}>Cancel - + {isClearing ? "Clearing..." : "Yes, Clear All My Data"} @@ -1951,3 +1953,4 @@ export default function DataManagementPage() {
); } + From 0de449ca611069c8865883fa365f4f50cf430f15 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 20 May 2025 23:18:01 +0000 Subject: [PATCH 018/156] I see this error with the app, reported by NextJS, please fix it. The error is reported as HTML but presented visually to the user). A > before the line number in the error source usually indicates the line of interest: > ./src/app/preferences/page.tsx:15:1 > Export exportAllUserDataToCsvs doesn't exist in target module > 13 | import { Skeleton } from '@/components/ui/skeleton'; > 14 | import { useAuthContext } from '@/contexts/AuthContext'; > > 15 | import { exportAllUserDataToCsvs } from '@/services/export'; // Import the export function > | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ > 16 | import { Download } from 'lucide-react'; > 17 | > 18 | export default function PreferencesPage() { > > The export exportAllUserDataToCsvs was not found in module [project]/src/services/export.ts [app-client] (ecmascript). > Did you mean to import exportAllUserDataToZip? > All exports of the module are statically known (It doesn't have dynamic exports). So it's known statically that the requested export doesn't exist. > > --- src/app/preferences/page.tsx | 46 +----------------------------------- 1 file changed, 1 insertion(+), 45 deletions(-) diff --git a/src/app/preferences/page.tsx b/src/app/preferences/page.tsx index c4b4081..258dfd1 100644 --- a/src/app/preferences/page.tsx +++ b/src/app/preferences/page.tsx @@ -2,7 +2,7 @@ 'use client'; import { useState, useEffect } from 'react'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from "@/components/ui/card"; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Label } from "@/components/ui/label"; @@ -12,15 +12,12 @@ import { supportedCurrencies, getCurrencySymbol } from '@/lib/currency'; import { useToast } from "@/hooks/use-toast"; import { Skeleton } from '@/components/ui/skeleton'; import { useAuthContext } from '@/contexts/AuthContext'; -import { exportAllUserDataToCsvs } from '@/services/export'; // Import the export function -import { Download } from 'lucide-react'; export default function PreferencesPage() { const { user, isLoadingAuth, userPreferences, refreshUserPreferences } = useAuthContext(); const [preferredCurrency, setPreferredCurrency] = useState(userPreferences?.preferredCurrency || 'BRL'); const [selectedTheme, setSelectedTheme] = useState(userPreferences?.theme || 'system'); const [isSaving, setIsSaving] = useState(false); - const [isExporting, setIsExporting] = useState(false); // New state for export loading const { toast } = useToast(); useEffect(() => { @@ -68,25 +65,6 @@ export default function PreferencesPage() { } }; - const handleExportData = async () => { - if (!user) { - toast({ title: "Error", description: "User not authenticated.", variant: "destructive" }); - return; - } - setIsExporting(true); - toast({ title: "Exporting Data", description: "Preparing your data for download. This may take a moment..." }); - try { - await exportAllUserDataToCsvs(); - toast({ title: "Export Complete", description: "Your data files should be downloading now. Please check your browser's download folder." }); - } catch (error) { - console.error("Export failed:", error); - toast({ title: "Export Failed", description: "Could not export your data. Please try again.", variant: "destructive" }); - } finally { - setIsExporting(false); - } - }; - - if (isLoadingAuth || (!userPreferences && user)) { return (
@@ -179,28 +157,6 @@ export default function PreferencesPage() { - - - Data Management - - Export all your application data to CSV files. - - - - - - -

- This will download multiple CSV files, one for each data type (accounts, transactions, etc.). - These files can be used as a backup or for importing into other systems. -

-
-
-
); } - From 9ed1443e51c963ee13c252e0132cf9439ffa0549 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 20 May 2025 23:25:42 +0000 Subject: [PATCH 019/156] Perfeito, acho que funcionou, so precisamos adicionar as partes que estao na aba Financial Control, os Loans, credit cards, budget, targets e subscriptions. E tambem adicione para apagar tudo isso no botao de clear data --- src/services/export.ts | 30 +++++++------------- src/services/transactions.tsx | 52 +++++++++++++++++++++++------------ 2 files changed, 44 insertions(+), 38 deletions(-) diff --git a/src/services/export.ts b/src/services/export.ts index 401d863..97bc751 100644 --- a/src/services/export.ts +++ b/src/services/export.ts @@ -27,9 +27,6 @@ function downloadBlob(blob: Blob, filename: string) { document.body.removeChild(link); URL.revokeObjectURL(url); } else { - // Fallback for browsers that don't support the download attribute - // This might open the blob in a new tab depending on the browser and blob type - // For a zip file, it usually still triggers a download or prompts the user. const newWindow = window.open(URL.createObjectURL(blob), '_blank'); if (!newWindow) { alert('File download is not fully supported by your browser or was blocked. Please check your pop-up blocker settings.'); @@ -40,46 +37,39 @@ function downloadBlob(blob: Blob, filename: string) { const formatDateForExport = (dateInput: object | string | undefined | null): string => { if (!dateInput) return ''; if (typeof dateInput === 'string') { - // Attempt to parse if it looks like an ISO string already, otherwise return as is const parsed = parseISO(dateInput); return isValid(parsed) ? formatDateFns(parsed, "yyyy-MM-dd'T'HH:mm:ssXXX") : dateInput; } - // Check for Firebase ServerTimestamp placeholder object which is an object with no direct date properties. - // A common way to check is if it's an object and doesn't have typical Date methods. - // For robust handling, if you expect specific Firebase Timestamp objects, you might check for `toDate` method. if (typeof dateInput === 'object' && dateInput !== null) { if ('toDate' in dateInput && typeof (dateInput as any).toDate === 'function') { return formatDateFns((dateInput as any).toDate(), "yyyy-MM-dd'T'HH:mm:ssXXX"); } - // For Firebase serverTimestamp placeholder object, or other objects, convert to a placeholder or empty string - // In a real scenario, this would be handled by ensuring data is read after server resolves it - // For export, if it's a placeholder, it's better to represent it as such or empty - return 'SERVER_TIMESTAMP_PLACEHOLDER'; // Or return an empty string or a specific string indicating it's a server value + return 'SERVER_TIMESTAMP_PLACEHOLDER'; } - if (typeof dateInput === 'number') { // Assuming it's a Unix timestamp (milliseconds) + if (typeof dateInput === 'number') { return formatDateFns(new Date(dateInput), "yyyy-MM-dd'T'HH:mm:ssXXX"); } - return String(dateInput); // Fallback for other types + return String(dateInput); }; interface ExportableTransaction extends Omit { - tags?: string; // Pipe-separated - originalImportData?: string; // JSON string - createdAt?: string; // ISO Timestamp - updatedAt?: string; // ISO Timestamp + tags?: string; + originalImportData?: string; + createdAt?: string; + updatedAt?: string; } interface ExportableSubscription extends Omit { - tags?: string; // Pipe-separated + tags?: string; createdAt?: string; updatedAt?: string; } interface ExportableGroup extends Omit { - categoryIds?: string; // Pipe-separated + categoryIds?: string; } interface ExportableBudget extends Omit { - selectedIds?: string; // Pipe-separated + selectedIds?: string; createdAt?: string; updatedAt?: string; } diff --git a/src/services/transactions.tsx b/src/services/transactions.tsx index ce10e18..6503b25 100644 --- a/src/services/transactions.tsx +++ b/src/services/transactions.tsx @@ -1,3 +1,4 @@ + import { database, auth } from '@/lib/firebase'; import { ref, set, get, push, remove, update, serverTimestamp } from 'firebase/database'; import type { User } from 'firebase/auth'; @@ -8,6 +9,9 @@ import { getCategoriesRefPath } from './categories'; import { getTagsRefPath } from './tags'; import { getGroupsRefPath } from './groups'; import { getSubscriptionsRefPath } from './subscriptions'; +import { getLoansRefPath } from './loans'; +import { getCreditCardsRefPath } from './credit-cards'; +import { getBudgetsRefPath } from './budgets'; export interface Transaction { @@ -112,7 +116,7 @@ export async function getTransactions( const storageKey = `transactions-${accountId}-${currentUser.uid}`; const data = localStorage.getItem(storageKey); - console.log("Fetching transactions for account:", accountId, "from localStorage key:", storageKey); + // console.log("Fetching transactions for account:", accountId, "from localStorage key:", storageKey); if (data) { try { @@ -158,8 +162,8 @@ export async function addTransaction(transactionData: NewTransactionData): Promi createdAt: serverTimestamp(), updatedAt: serverTimestamp(), originalImportData: { - foreignAmount: transactionData.originalImportData?.foreignAmount ?? null, - foreignCurrency: transactionData.originalImportData?.foreignCurrency ?? null, + foreignAmount: transactionData.originalImportData?.foreignAmount === undefined ? null : transactionData.originalImportData.foreignAmount, + foreignCurrency: transactionData.originalImportData?.foreignCurrency === undefined ? null : transactionData.originalImportData.foreignCurrency, } }; @@ -167,7 +171,7 @@ export async function addTransaction(transactionData: NewTransactionData): Promi delete dataToSave.id; - console.log("Adding transaction to Firebase RTDB:", newTransaction); + // console.log("Adding transaction to Firebase RTDB:", newTransaction); try { await set(newTransactionRef, dataToSave); if (category?.toLowerCase() !== 'opening balance') { @@ -208,14 +212,14 @@ export async function updateTransaction(updatedTransaction: Transaction): Promis ...updatedTransaction, updatedAt: serverTimestamp(), originalImportData: { - foreignAmount: updatedTransaction.originalImportData?.foreignAmount ?? null, - foreignCurrency: updatedTransaction.originalImportData?.foreignCurrency ?? null, + foreignAmount: updatedTransaction.originalImportData?.foreignAmount === undefined ? null : updatedTransaction.originalImportData.foreignAmount, + foreignCurrency: updatedTransaction.originalImportData?.foreignCurrency === undefined ? null : updatedTransaction.originalImportData.foreignCurrency, } } as any; delete dataToUpdateFirebase.id; - console.log("Updating transaction in Firebase RTDB:", id, dataToUpdateFirebase); + // console.log("Updating transaction in Firebase RTDB:", id, dataToUpdateFirebase); try { await update(transactionRef, dataToUpdateFirebase); // Update DB @@ -266,7 +270,7 @@ export async function deleteTransaction(transactionId: string, accountId: string const allAppAccounts = await getAllAccounts(); const txCurrency = transactionToDelete.transactionCurrency || allAppAccounts.find(a => a.id === accountId)?.currency || 'USD'; - console.log("Deleting transaction from Firebase RTDB:", transactionRefPath); + // console.log("Deleting transaction from Firebase RTDB:", transactionRefPath); try { await remove(transactionRef); // Remove from DB // Use the currency from the transaction being deleted for accurate balance reversal @@ -293,12 +297,15 @@ export async function clearAllSessionTransactions(): Promise { console.warn("Attempting to clear ALL user data for user:", currentUser.uid); try { // Get paths for all data types - const userFirebaseTransactionsBasePath = `users/${currentUser.uid}/transactions`; + const userFirebaseTransactionsBasePath = `users/${currentUser.uid}/transactions`; // Base path for all account transactions const categoriesPath = getCategoriesRefPath(currentUser); const tagsPath = getTagsRefPath(currentUser); const groupsPath = getGroupsRefPath(currentUser); const subscriptionsPath = getSubscriptionsRefPath(currentUser); - const accountsPath = `users/${currentUser.uid}/accounts`; // Directly use path + const loansPath = getLoansRefPath(currentUser); + const creditCardsPath = getCreditCardsRefPath(currentUser); + const budgetsPath = getBudgetsRefPath(currentUser); + const accountsPath = `users/${currentUser.uid}/accounts`; // Base path for all accounts // Clear from Firebase DB await Promise.all([ @@ -307,22 +314,31 @@ export async function clearAllSessionTransactions(): Promise { remove(ref(database, tagsPath)), remove(ref(database, groupsPath)), remove(ref(database, subscriptionsPath)), - remove(ref(database, accountsPath)) // Clear accounts from DB + remove(ref(database, loansPath)), + remove(ref(database, creditCardsPath)), + remove(ref(database, budgetsPath)), + remove(ref(database, accountsPath)) // Clear all accounts from DB ]); // Clear from localStorage - const accounts = await getAllAccounts(); // This will now be empty if DB clear was first, or from old cache - for (const acc of accounts) { // If accounts were cleared from DB first, this loop might not run for transactions - const storageKey = `transactions-${acc.id}-${currentUser.uid}`; - localStorage.removeItem(storageKey); - } - // Clear other localStorage items + // Since accounts are cleared, we don't need to iterate through them for transaction keys + // but we should clear any general keys if they existed. + const allKeys = Object.keys(localStorage); + allKeys.forEach(key => { + if (key.startsWith(`transactions-`) && key.endsWith(`-${currentUser.uid}`)) { + localStorage.removeItem(key); + } + }); + localStorage.removeItem(`userAccounts-${currentUser.uid}`); localStorage.removeItem(`userCategories-${currentUser.uid}`); localStorage.removeItem(`userTags-${currentUser.uid}`); localStorage.removeItem(`userGroups-${currentUser.uid}`); localStorage.removeItem(`userSubscriptions-${currentUser.uid}`); - localStorage.removeItem(`userPreferences-${currentUser.uid}`); // Also clear preferences + localStorage.removeItem(`userLoans-${currentUser.uid}`); + localStorage.removeItem(`userCreditCards-${currentUser.uid}`); + localStorage.removeItem(`userBudgets-${currentUser.uid}`); + localStorage.removeItem(`userPreferences-${currentUser.uid}`); console.log("All user data cleared from Firebase and localStorage for user:", currentUser.uid); From 9d4a50400fafc876fd7b1b77a28122cb50033796 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 20 May 2025 23:36:44 +0000 Subject: [PATCH 020/156] Voce e quase perfeito! hahah mas a importacao de volta para o sistema, no final as contas nao estao batendo os valores, nao consegui identificar o motivo --- src/app/data-management/page.tsx | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/app/data-management/page.tsx b/src/app/data-management/page.tsx index 49b3f05..451b24e 100644 --- a/src/app/data-management/page.tsx +++ b/src/app/data-management/page.tsx @@ -189,7 +189,7 @@ export default function DataManagementPage() { const [csvHeaders, setCsvHeaders] = useState([]); const [rawData, setRawData] = useState([]); const [parsedData, setParsedData] = useState([]); - const [isLoading, setIsLoading] = useState(true); + const [isLoading, setIsLoading] = useState(false); const [importProgress, setImportProgress] = useState(0); const [error, setError] = useState(null); const [accounts, setAccounts] = useState([]); @@ -210,7 +210,7 @@ export default function DataManagementPage() { const fetchData = useCallback(async () => { let isMounted = true; - if (isMounted) setIsLoading(true); + if (isMounted && !isLoading) setIsLoading(true); if (typeof window === 'undefined' || !user || isLoadingAuth) { if(isMounted) setIsLoading(false); @@ -242,11 +242,11 @@ export default function DataManagementPage() { } } return () => { isMounted = false; }; - }, [user, isLoadingAuth, toast]); // Added toast to dependency array as it's used in error handling + }, [user, isLoadingAuth]); // Removed toast, added isLoading to prevent re-trigger if already loading useEffect(() => { fetchData(); - }, [fetchData]); + }, [fetchData]); // fetchData is memoized, so this runs once on mount / when user/auth status changes. const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files[0]) { @@ -768,10 +768,13 @@ export default function DataManagementPage() { const parsedNameFromDest = parseNameFromDescriptiveString(descriptiveDestName); const parsedNameFromSource = parseNameFromDescriptiveString(descriptiveSourceName); + const sourceIsDescriptive = !!parsedNameFromSource; + const destIsDescriptive = !!parsedNameFromDest; - if (item.csvDestinationType === "asset account" && descriptiveDestName && !parseNameFromDescriptiveString(descriptiveDestName) ) { + + if (item.csvDestinationType === "asset account" && descriptiveDestName && !destIsDescriptive ) { accountNameForOB = descriptiveDestName; - } else if (item.csvSourceType === "asset account" && descriptiveSourceName && !parseNameFromDescriptiveString(descriptiveSourceName)) { + } else if (item.csvSourceType === "asset account" && descriptiveSourceName && !sourceIsDescriptive) { accountNameForOB = descriptiveSourceName; } else if (parsedNameFromDest) { accountNameForOB = parsedNameFromDest; @@ -786,9 +789,6 @@ export default function DataManagementPage() { const existingDetailsInMap = accountMap.get(normalizedName); let accountCategory: 'asset' | 'crypto' = 'asset'; - const sourceIsDescriptive = item.csvRawSourceName && parseNameFromDescriptiveString(item.csvRawSourceName); - const destIsDescriptive = item.csvRawDestinationName && parseNameFromDescriptiveString(item.csvRawDestinationName); - if (item.csvDestinationType === "asset account" && !destIsDescriptive && item.csvRawDestinationName?.toLowerCase().includes('crypto')) { accountCategory = 'crypto'; } else if (item.csvSourceType === "asset account" && !sourceIsDescriptive && item.csvRawSourceName?.toLowerCase().includes('crypto')) { @@ -1293,7 +1293,7 @@ export default function DataManagementPage() { const fileInput = document.getElementById('csv-file') as HTMLInputElement; if (fileInput) fileInput.value = ''; - toast({ title: "Data Cleared", description: "All user data (accounts, categories, tags, groups, subscriptions, transactions) has been removed." }); + toast({ title: "Data Cleared", description: "All user data (accounts, categories, tags, groups, subscriptions, transactions, loans, credit cards, and budgets) has been removed." }); window.dispatchEvent(new Event('storage')); } catch (err) { console.error("Failed to clear data:", err); @@ -1557,11 +1557,11 @@ export default function DataManagementPage() { const accountsCsv = await accountsFile.async('text'); const parsedAccounts = Papa.parse(accountsCsv, { header: true, skipEmptyLines: true, dynamicTyping: true }).data; for (const acc of parsedAccounts) { - if(acc.id && acc.name && acc.currency && acc.type && acc.balance !== undefined) { + if(acc.id && acc.name && acc.currency && acc.type ) { // Balance is no longer required for initial setup const newAccData: NewAccountData = { name: acc.name, type: acc.type, - balance: typeof acc.balance === 'string' ? parseFloat(acc.balance) : acc.balance, + balance: 0, // Initialize with ZERO balance for restore currency: acc.currency, providerName: acc.providerName || 'Restored', category: acc.category || 'asset', @@ -1621,6 +1621,8 @@ export default function DataManagementPage() { if (transactionsFile) { const transactionsCsv = await transactionsFile.async('text'); const parsedTransactions = Papa.parse(transactionsCsv, { header: true, skipEmptyLines: true, dynamicTyping: true }).data; + parsedTransactions.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); // Sort by date to process in order + for (const tx of parsedTransactions) { if (tx.id && tx.accountId && tx.date && tx.amount !== undefined && tx.transactionCurrency && tx.category) { const newAccountId = oldAccountIdToNewIdMap[tx.accountId]; @@ -1636,6 +1638,8 @@ export default function DataManagementPage() { originalImportData: tx.originalImportData ? JSON.parse(tx.originalImportData as string) : undefined, }; try { + // During restore, addTransaction will call modifyAccountBalance which will + // build up the balance from 0, as set during account creation in this function. await addTransaction(newTxData); } catch (e: any) { console.error(`Error restoring transaction ${tx.description}: ${e.message}`); @@ -1953,4 +1957,3 @@ export default function DataManagementPage() {
); } - From bf46616bb3652d1bceaa4388b534066812adc3fe Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Wed, 21 May 2025 00:16:46 +0000 Subject: [PATCH 021/156] Novamente, o primeiro print e de como as contas sao corretamentes (expostadas) e o segundo de como elas estao voltando do backup ( importadas), esta quase tudo certo, so alguns valores que nao estao batendo, estamos quase corrigindo isso! --- src/app/data-management/page.tsx | 222 ++++++++++++++++--------------- src/services/transactions.tsx | 87 +++++------- 2 files changed, 148 insertions(+), 161 deletions(-) diff --git a/src/app/data-management/page.tsx b/src/app/data-management/page.tsx index 451b24e..a8cbbe4 100644 --- a/src/app/data-management/page.tsx +++ b/src/app/data-management/page.tsx @@ -24,11 +24,10 @@ import { getBudgets, addBudget as addBudgetToDb, type Budget } from '@/services/ import { saveUserPreferences, type UserPreferences, getUserPreferences } from '@/lib/preferences'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogDescription, DialogFooter } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; -import { format, parseISO, isValid, parse as parseDateFns } from 'date-fns'; +import { format as formatDateFns, parseISO, isValid, parse as parseDateFns } from 'date-fns'; import { getCurrencySymbol, supportedCurrencies, formatCurrency, convertCurrency } from '@/lib/currency'; import CsvMappingForm, { type ColumnMapping } from '@/components/import/csv-mapping-form'; import { AlertCircle, Trash2, Download } from 'lucide-react'; @@ -134,11 +133,11 @@ const parseAmount = (amountStr: string | undefined): number => { const parseDate = (dateStr: string | undefined): string => { - if (!dateStr) return format(new Date(), 'yyyy-MM-dd'); + if (!dateStr) return formatDateFns(new Date(), 'yyyy-MM-dd'); try { let parsedDate = parseISO(dateStr); if (isValid(parsedDate)) { - return format(parsedDate, 'yyyy-MM-dd'); + return formatDateFns(parsedDate, 'yyyy-MM-dd'); } const commonFormats = [ @@ -153,27 +152,27 @@ const parseDate = (dateStr: string | undefined): string => { for (const fmt of commonFormats) { try { parsedDate = parseDateFns(dateStr, fmt, new Date()); - if (isValid(parsedDate)) return format(parsedDate, 'yyyy-MM-dd'); + if (isValid(parsedDate)) return formatDateFns(parsedDate, 'yyyy-MM-dd'); const datePartOnly = dateStr.split('T')[0].split(' ')[0]; const dateFormatOnly = fmt.split('T')[0].split(' ')[0]; if (datePartOnly !== dateStr && dateFormatOnly !== fmt) { parsedDate = parseDateFns(datePartOnly, dateFormatOnly, new Date()); - if (isValid(parsedDate)) return format(parsedDate, 'yyyy-MM-dd'); + if (isValid(parsedDate)) return formatDateFns(parsedDate, 'yyyy-MM-dd'); } } catch { /* ignore parse error for this format, try next */ } } parsedDate = new Date(dateStr); if (isValid(parsedDate)) { - return format(parsedDate, 'yyyy-MM-dd'); + return formatDateFns(parsedDate, 'yyyy-MM-dd'); } } catch (e) { console.error("Error parsing date:", dateStr, e); } console.warn(`Could not parse date "${dateStr}", defaulting to today.`); - return format(new Date(), 'yyyy-MM-dd'); + return formatDateFns(new Date(), 'yyyy-MM-dd'); }; const parseNameFromDescriptiveString = (text: string | undefined): string | undefined => { @@ -203,7 +202,6 @@ export default function DataManagementPage() { const [isExporting, setIsExporting] = useState(false); const { toast } = useToast(); - // New state for restore confirmation const [isRestoreConfirmOpen, setIsRestoreConfirmOpen] = useState(false); const [zipFileForRestore, setZipFileForRestore] = useState(null); @@ -242,11 +240,11 @@ export default function DataManagementPage() { } } return () => { isMounted = false; }; - }, [user, isLoadingAuth]); // Removed toast, added isLoading to prevent re-trigger if already loading + }, [user, isLoadingAuth, isLoading, toast]); useEffect(() => { fetchData(); - }, [fetchData]); // fetchData is memoized, so this runs once on mount / when user/auth status changes. + }, [fetchData]); const handleFileChange = (event: React.ChangeEvent) => { if (event.target.files && event.target.files[0]) { @@ -316,7 +314,7 @@ export default function DataManagementPage() { initialMappings.initialBalance = findColumnName(detectedHeaders, 'initial_balance') || findColumnName(detectedHeaders, 'opening_balance'); setColumnMappings(initialMappings); - setIsMappingDialogOpen(true); // Show mapping dialog for generic CSV or non-GoldQuest ZIP + setIsMappingDialogOpen(true); setIsLoading(false); }, error: (err: Error) => { @@ -344,25 +342,24 @@ export default function DataManagementPage() { setRawData([]); setCsvHeaders([]); setZipFileForRestore(null); - setIsMappingDialogOpen(false); // Reset mapping dialog state + setIsMappingDialogOpen(false); if (file.name.endsWith('.zip') || file.type === 'application/zip' || file.type === 'application/x-zip-compressed') { try { const zip = await JSZip.loadAsync(file); const manifestFile = zip.file('goldquest_manifest.json'); - if (manifestFile) { // Detected a GoldQuest backup + if (manifestFile) { const manifestContent = await manifestFile.async('string'); const manifest = JSON.parse(manifestContent); if (manifest.appName === "GoldQuest") { setZipFileForRestore(file); - setIsRestoreConfirmOpen(true); // Go directly to restore confirmation + setIsRestoreConfirmOpen(true); setIsLoading(false); - return; // Skip manual mapping + return; } } - // If not a GoldQuest backup or no manifest, try to find a primary CSV for manual mapping let primaryCsvFile: JSZip.JSZipObject | null = null; const commonPrimaryNames = ['transactions.csv', 'firefly_iii_export.csv', 'default.csv']; for (const name of commonPrimaryNames) { @@ -372,7 +369,7 @@ export default function DataManagementPage() { break; } } - if (!primaryCsvFile) { // Fallback to largest CSV if common names not found + if (!primaryCsvFile) { let largestSize = 0; zip.forEach((relativePath, zipEntry) => { if (zipEntry.name.toLowerCase().endsWith('.csv') && !zipEntry.dir) { @@ -388,7 +385,7 @@ export default function DataManagementPage() { if (primaryCsvFile) { toast({ title: "ZIP Detected", description: `Processing '${primaryCsvFile.name}' from the archive for manual mapping.`, duration: 4000}); const csvString = await primaryCsvFile.async("string"); - processCsvData(csvString, primaryCsvFile.name); // This will open mapping dialog + processCsvData(csvString, primaryCsvFile.name); } else { setError("No suitable CSV file found within the ZIP archive to process for mapping. If this is a GoldQuest backup, it might be missing a manifest or CSV files."); setIsLoading(false); @@ -397,11 +394,11 @@ export default function DataManagementPage() { setError(`Failed to process ZIP file: ${zipError.message}`); setIsLoading(false); } - } else if (file.name.endsWith('.csv') || file.type === 'text/csv') { // Direct CSV upload + } else if (file.name.endsWith('.csv') || file.type === 'text/csv') { const reader = new FileReader(); reader.onload = (event) => { if (event.target?.result && typeof event.target.result === 'string') { - processCsvData(event.target.result, file.name); // This will open mapping dialog + processCsvData(event.target.result, file.name); } else { setError("Failed to read CSV file content."); setIsLoading(false); @@ -574,6 +571,8 @@ export default function DataManagementPage() { if (parsedFromNameInDest) actualAccountNameForOB = parsedFromNameInDest; else if (parsedFromNameInSource) actualAccountNameForOB = parsedFromNameInSource; + else if (rawDestType === "initial balance account" && rawDestName) actualAccountNameForOB = rawDestName; // Firefly structure + else if (rawSourceType === "initial balance account" && rawSourceName) actualAccountNameForOB = rawSourceName; // Firefly structure else actualAccountNameForOB = rawDestName || rawSourceName; } @@ -698,6 +697,7 @@ export default function DataManagementPage() { currency: currency, foreignCurrency: foreignCurrencyVal, amount: type === 'opening balance' ? initialBalance : amount, + description: record[mappings.description!]?.trim() }; }) as Partial[]; @@ -761,39 +761,41 @@ export default function DataManagementPage() { if (item.csvTransactionType === 'opening balance') { let accountNameForOB: string | undefined; const recordCurrency = item.currency; - const recordAmount = item.amount; - - const descriptiveDestName = item.csvRawDestinationName; - const descriptiveSourceName = item.csvRawSourceName; - - const parsedNameFromDest = parseNameFromDescriptiveString(descriptiveDestName); - const parsedNameFromSource = parseNameFromDescriptiveString(descriptiveSourceName); - const sourceIsDescriptive = !!parsedNameFromSource; - const destIsDescriptive = !!parsedNameFromDest; - - - if (item.csvDestinationType === "asset account" && descriptiveDestName && !destIsDescriptive ) { - accountNameForOB = descriptiveDestName; - } else if (item.csvSourceType === "asset account" && descriptiveSourceName && !sourceIsDescriptive) { - accountNameForOB = descriptiveSourceName; - } else if (parsedNameFromDest) { - accountNameForOB = parsedNameFromDest; - } else if (parsedNameFromSource) { - accountNameForOB = parsedNameFromSource; + const recordAmount = item.amount; // This is already parsed initialBalance or amount for OB type + const desc = item.description; // Using the description field from mappedCsvData + const sourceName = item.csvRawSourceName; + const destName = item.csvRawDestinationName; + const sourceType = item.csvSourceType; + const destType = item.csvDestinationType; + + if (destType === "asset account" && destName) { + accountNameForOB = destName; + } else if (sourceType === "asset account" && sourceName) { + accountNameForOB = sourceName; } else { - accountNameForOB = descriptiveDestName || descriptiveSourceName; + const parsedNameFromDesc = parseNameFromDescriptiveString(desc); + if (parsedNameFromDesc) { + accountNameForOB = parsedNameFromDesc; + } else if (destType === "initial balance account" && destName) { // Firefly structure for account name in destination + accountNameForOB = destName; + } else if (sourceType === "initial balance account" && sourceName) { // Firefly structure for account name in source + accountNameForOB = sourceName; + } else { + accountNameForOB = destName || sourceName; // Fallback + } } + if (accountNameForOB && recordCurrency && recordAmount !== undefined && !isNaN(recordAmount)) { const normalizedName = accountNameForOB.toLowerCase().trim(); const existingDetailsInMap = accountMap.get(normalizedName); let accountCategory: 'asset' | 'crypto' = 'asset'; - if (item.csvDestinationType === "asset account" && !destIsDescriptive && item.csvRawDestinationName?.toLowerCase().includes('crypto')) { + if (destType === "asset account" && destName?.toLowerCase().includes('crypto')) { accountCategory = 'crypto'; - } else if (item.csvSourceType === "asset account" && !sourceIsDescriptive && item.csvRawSourceName?.toLowerCase().includes('crypto')) { + } else if (sourceType === "asset account" && sourceName?.toLowerCase().includes('crypto')) { accountCategory = 'crypto'; - } else if (item.csvDestinationType?.includes('crypto') || item.csvSourceType?.includes('crypto') || accountNameForOB.toLowerCase().includes('crypto') || accountNameForOB.toLowerCase().includes('wallet')) { + } else if (destType?.includes('crypto') || sourceType?.includes('crypto') || accountNameForOB.toLowerCase().includes('crypto') || accountNameForOB.toLowerCase().includes('wallet')) { accountCategory = 'crypto'; } @@ -836,7 +838,7 @@ export default function DataManagementPage() { accountMap.set(normalizedName, { name: accInfo.name, currency: existingAppAccount?.currency || accInfo.currency, - initialBalance: existingAppAccount?.balance, + initialBalance: existingAppAccount?.balance, // Use existing balance as default, will be overwritten by OB if present category: existingAppAccount?.category || category, }); } else { @@ -885,8 +887,8 @@ export default function DataManagementPage() { if (accPreview.action === 'create') { const newAccountData: NewAccountData = { name: accPreview.name, - type: (accPreview.category === 'crypto' ? 'wallet' : 'checking'), - balance: accPreview.initialBalance, + type: (accPreview.category === 'crypto' ? 'wallet' : 'checking'), // Default type + balance: accPreview.initialBalance, // Use the balance determined by preview (from OB or 0) currency: accPreview.currency, providerName: 'Imported - ' + accPreview.name, category: accPreview.category, @@ -959,7 +961,7 @@ export default function DataManagementPage() { let success = true; transactionsToProcess.forEach(tx => { - if (tx.importStatus === 'pending') { + if (tx.importStatus === 'pending') { // Only consider pending transactions if (tx.category && !['Uncategorized', 'Initial Balance', 'Transfer', 'Skipped', 'Opening Balance'].includes(tx.category)) { const categoryName = tx.category.trim(); if (categoryName && !existingCategoryNames.has(categoryName.toLowerCase())) { @@ -1362,7 +1364,7 @@ export default function DataManagementPage() { try { const d = new Date(value); if (!isNaN(d.getTime())) { - transactionToUpdate.date = format(d, 'yyyy-MM-dd'); + transactionToUpdate.date = formatDateFns(d, 'yyyy-MM-dd'); } else { throw new Error("Invalid date object"); } } catch { toast({ title: "Invalid Date", description: "Date not updated. Please use YYYY-MM-DD format or select a valid date.", variant: "destructive" }); @@ -1469,6 +1471,11 @@ export default function DataManagementPage() { setImportProgress(0); setError(null); let overallSuccess = true; + const accountsFromBackup: Account[] = []; + const oldAccountIdToNewIdMap: Record = {}; + const oldCategoryIdToNewIdMap: Record = {}; + const oldGroupIdToNewIdMap: Record = {}; + let newAccountsListAfterRestore: Account[] = []; try { toast({ title: "Restore Started", description: "Clearing existing data...", duration: 2000 }); @@ -1477,17 +1484,13 @@ export default function DataManagementPage() { const zip = await JSZip.loadAsync(zipFileForRestore); let progressCounter = 0; - const totalFilesToProcess = 10; // Approx count of CSV files + const totalFilesToProcess = 10; const updateProgress = () => { progressCounter++; setImportProgress(calculateProgress(progressCounter, totalFilesToProcess)); }; - const oldAccountIdToNewIdMap: Record = {}; - const oldCategoryIdToNewIdMap: Record = {}; - const oldGroupIdToNewIdMap: Record = {}; - // 1. Preferences const prefsFile = zip.file('goldquest_preferences.csv'); if (prefsFile) { @@ -1495,7 +1498,7 @@ export default function DataManagementPage() { const parsedPrefs = Papa.parse(prefsCsv, { header: true, skipEmptyLines: true }).data[0]; if (parsedPrefs) { await saveUserPreferences(parsedPrefs); - await refreshUserPreferences(); // AuthContext function + await refreshUserPreferences(); toast({ title: "Restore Progress", description: "Preferences restored."}); } } @@ -1507,7 +1510,7 @@ export default function DataManagementPage() { const categoriesCsv = await categoriesFile.async('text'); const parsedCategories = Papa.parse(categoriesCsv, { header: true, skipEmptyLines: true }).data; for (const cat of parsedCategories) { - if(cat.id && cat.name) { // Ensure critical fields are present + if(cat.id && cat.name) { try { const newCategory = await addCategoryToDb(cat.name, cat.icon); oldCategoryIdToNewIdMap[cat.id] = newCategory.id; @@ -1515,7 +1518,7 @@ export default function DataManagementPage() { if (!e.message?.includes('already exists')) { console.warn(`Skipping category restore due to error: ${e.message}`, cat); overallSuccess = false; - } else { // If it already exists, try to find and map its ID + } else { const existingCats = await getCategories(); const existing = existingCats.find(ec => ec.name.toLowerCase() === cat.name.toLowerCase()); if (existing) oldCategoryIdToNewIdMap[cat.id] = existing.id; @@ -1551,44 +1554,47 @@ export default function DataManagementPage() { updateProgress(); setTags(await getTags()); - // 4. Accounts + // 4. Accounts (Crucial: set balance from CSV here) const accountsFile = zip.file('goldquest_accounts.csv'); if (accountsFile) { const accountsCsv = await accountsFile.async('text'); const parsedAccounts = Papa.parse(accountsCsv, { header: true, skipEmptyLines: true, dynamicTyping: true }).data; - for (const acc of parsedAccounts) { - if(acc.id && acc.name && acc.currency && acc.type ) { // Balance is no longer required for initial setup + for (const accFromCsv of parsedAccounts) { + if(accFromCsv.id && accFromCsv.name && accFromCsv.currency && accFromCsv.type ) { const newAccData: NewAccountData = { - name: acc.name, - type: acc.type, - balance: 0, // Initialize with ZERO balance for restore - currency: acc.currency, - providerName: acc.providerName || 'Restored', - category: acc.category || 'asset', - isActive: acc.isActive !== undefined ? acc.isActive : true, - lastActivity: acc.lastActivity || new Date().toISOString(), - balanceDifference: acc.balanceDifference || 0, - includeInNetWorth: acc.includeInNetWorth !== undefined ? acc.includeInNetWorth : true, + name: accFromCsv.name, + type: accFromCsv.type, + balance: parseFloat(String(accFromCsv.balance)) || 0, // Use balance from CSV + currency: accFromCsv.currency, + providerName: accFromCsv.providerName || 'Restored', + category: accFromCsv.category || 'asset', + isActive: accFromCsv.isActive !== undefined ? (String(accFromCsv.isActive).toLowerCase() === 'true') : true, + lastActivity: accFromCsv.lastActivity || new Date().toISOString(), + balanceDifference: parseFloat(String(accFromCsv.balanceDifference)) || 0, + includeInNetWorth: accFromCsv.includeInNetWorth !== undefined ? (String(accFromCsv.includeInNetWorth).toLowerCase() === 'true') : true, }; try { - const newAccount = await addAccount(newAccData); - oldAccountIdToNewIdMap[acc.id] = newAccount.id; + const newAccount = await addAccount(newAccData); // addAccount sets balance + oldAccountIdToNewIdMap[accFromCsv.id] = newAccount.id; + accountsFromBackup.push(newAccount); // Store for later reference if needed } catch (e: any) { - console.error(`Error restoring account ${acc.name}: ${e.message}`); + console.error(`Error restoring account ${accFromCsv.name}: ${e.message}`); overallSuccess = false; } } } + newAccountsListAfterRestore = await getAccounts(); // Get all accounts after they've been added + setAccounts(newAccountsListAfterRestore); toast({ title: "Restore Progress", description: "Accounts restored."}); } updateProgress(); - setAccounts(await getAccounts()); + // 5. Groups const groupsFile = zip.file('goldquest_groups.csv'); if (groupsFile) { const groupsCsv = await groupsFile.async('text'); - const parsedGroups = Papa.parse<{ id: string; name: string; categoryIds: string }>(groupsCsv, { header: true, skipEmptyLines: true }).data; + const parsedGroups = Papa.parse(groupsCsv, { header: true, skipEmptyLines: true }).data; for (const group of parsedGroups) { if (group.id && group.name) { const oldCatIds = group.categoryIds ? group.categoryIds.split('|').filter(Boolean) : []; @@ -1614,39 +1620,36 @@ export default function DataManagementPage() { toast({ title: "Restore Progress", description: "Groups restored." }); } updateProgress(); - // setGroups(await getGroups()); // Assuming getGroups updates local state - // 6. Transactions + // 6. Transactions (Skip balance modification) const transactionsFile = zip.file('goldquest_transactions.csv'); if (transactionsFile) { const transactionsCsv = await transactionsFile.async('text'); - const parsedTransactions = Papa.parse(transactionsCsv, { header: true, skipEmptyLines: true, dynamicTyping: true }).data; - parsedTransactions.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); // Sort by date to process in order - - for (const tx of parsedTransactions) { - if (tx.id && tx.accountId && tx.date && tx.amount !== undefined && tx.transactionCurrency && tx.category) { - const newAccountId = oldAccountIdToNewIdMap[tx.accountId]; + const parsedTransactions = Papa.parse(transactionsCsv, { header: true, skipEmptyLines: true, dynamicTyping: true }).data; + + for (const txCSV of parsedTransactions) { + if (txCSV.id && txCSV.accountId && txCSV.date && txCSV.amount !== undefined && txCSV.transactionCurrency && txCSV.category) { + const newAccountId = oldAccountIdToNewIdMap[txCSV.accountId]; if (newAccountId) { const newTxData: NewTransactionData = { - date: typeof tx.date === 'string' ? tx.date : formatDateFns(new Date(tx.date as any), 'yyyy-MM-dd'), - amount: typeof tx.amount === 'string' ? parseFloat(tx.amount) : tx.amount, - transactionCurrency: tx.transactionCurrency, - description: tx.description || 'Restored Transaction', - category: tx.category, + date: typeof txCSV.date === 'string' ? txCSV.date : formatDateFns(new Date(txCSV.date as any), 'yyyy-MM-dd'), + amount: typeof txCSV.amount === 'string' ? parseFloat(txCSV.amount) : txCSV.amount, + transactionCurrency: txCSV.transactionCurrency, + description: txCSV.description || 'Restored Transaction', + category: txCSV.category, accountId: newAccountId, - tags: tx.tags ? (tx.tags as string).split('|').filter(Boolean) : [], - originalImportData: tx.originalImportData ? JSON.parse(tx.originalImportData as string) : undefined, + tags: txCSV.tags ? txCSV.tags.split('|').filter(Boolean) : [], + originalImportData: txCSV.originalImportData ? JSON.parse(txCSV.originalImportData) : undefined, }; try { - // During restore, addTransaction will call modifyAccountBalance which will - // build up the balance from 0, as set during account creation in this function. - await addTransaction(newTxData); + // Add transaction without modifying balance, as balances are set from accounts.csv + await addTransaction(newTxData, { skipBalanceModification: true }); } catch (e: any) { - console.error(`Error restoring transaction ${tx.description}: ${e.message}`); + console.error(`Error restoring transaction ${txCSV.description}: ${e.message}`); overallSuccess = false; } } else { - console.warn(`Could not map old account ID ${tx.accountId} for transaction ${tx.description}`); + console.warn(`Could not map old account ID ${txCSV.accountId} for transaction ${txCSV.description}`); } } } @@ -1654,8 +1657,6 @@ export default function DataManagementPage() { } updateProgress(); - // Placeholders for other data types (Subscriptions, Loans, CreditCards, Budgets) - // For each, parse its CSV, remap IDs (accountId, categoryId, groupId etc.) and call its add service. const dataTypesToRestore = [ { name: 'Subscriptions', file: 'goldquest_subscriptions.csv', addFn: addSubscriptionToDb, serviceName: 'subscription' }, { name: 'Loans', file: 'goldquest_loans.csv', addFn: addLoanToDb, serviceName: 'loan' }, @@ -1671,7 +1672,7 @@ export default function DataManagementPage() { for (const item of parsedItems) { try { let itemData = { ...item }; - delete itemData.id; // Remove old ID + delete itemData.id; if (itemData.accountId) itemData.accountId = oldAccountIdToNewIdMap[itemData.accountId] || itemData.accountId; if (itemData.groupId) itemData.groupId = oldGroupIdToNewIdMap[itemData.groupId] || itemData.groupId; @@ -1682,12 +1683,10 @@ export default function DataManagementPage() { itemData.appliesTo === 'categories' ? oldCategoryIdToNewIdMap[oldId] : oldGroupIdToNewIdMap[oldId] ).filter(Boolean); } - // Convert date strings if necessary - if (itemData.startDate && typeof itemData.startDate !== 'string') itemData.startDate = formatDateFns(new Date(itemData.startDate), 'yyyy-MM-dd'); - if (itemData.nextPaymentDate && typeof itemData.nextPaymentDate !== 'string') itemData.nextPaymentDate = formatDateFns(new Date(itemData.nextPaymentDate), 'yyyy-MM-dd'); - if (itemData.endDate && typeof itemData.endDate !== 'string') itemData.endDate = formatDateFns(new Date(itemData.endDate), 'yyyy-MM-dd'); - if (itemData.paymentDueDate && typeof itemData.paymentDueDate !== 'string') itemData.paymentDueDate = formatDateFns(new Date(itemData.paymentDueDate), 'yyyy-MM-dd'); - + if (itemData.startDate && typeof itemData.startDate !== 'string' && !/^\d{4}-\d{2}-\d{2}$/.test(itemData.startDate)) itemData.startDate = formatDateFns(new Date(itemData.startDate), 'yyyy-MM-dd'); + if (itemData.nextPaymentDate && typeof itemData.nextPaymentDate !== 'string' && !/^\d{4}-\d{2}-\d{2}$/.test(itemData.nextPaymentDate)) itemData.nextPaymentDate = formatDateFns(new Date(itemData.nextPaymentDate), 'yyyy-MM-dd'); + if (itemData.endDate && typeof itemData.endDate !== 'string' && !/^\d{4}-\d{2}-\d{2}$/.test(itemData.endDate)) itemData.endDate = formatDateFns(new Date(itemData.endDate), 'yyyy-MM-dd'); + if (itemData.paymentDueDate && typeof itemData.paymentDueDate !== 'string' && !/^\d{4}-\d{2}-\d{2}$/.test(itemData.paymentDueDate)) itemData.paymentDueDate = formatDateFns(new Date(itemData.paymentDueDate), 'yyyy-MM-dd'); await dataType.addFn(itemData); } catch (e: any) { @@ -1706,18 +1705,21 @@ export default function DataManagementPage() { } else { toast({ title: "Restore Partially Complete", description: "Some data could not be restored. Check console for errors.", variant: "destructive", duration: 10000 }); } - await fetchData(); // Refresh page data - window.dispatchEvent(new Event('storage')); // Notify other components - + } catch (restoreError: any) { console.error("Full restore failed:", restoreError); setError(`Full restore failed: ${restoreError.message}`); toast({ title: "Restore Failed", description: restoreError.message || "An unknown error occurred during restore.", variant: "destructive" }); + overallSuccess = false; } finally { setIsLoading(false); - setImportProgress(100); + setImportProgress(100); // Indicate completion even if errors setIsRestoreConfirmOpen(false); setZipFileForRestore(null); + if (overallSuccess) { + await fetchData(); // Refresh page data + window.dispatchEvent(new Event('storage')); // Notify other components + } } }; @@ -1814,7 +1816,7 @@ export default function DataManagementPage() { { - if (!open) setZipFileForRestore(null); // Clear file if dialog is cancelled + if (!open) setZipFileForRestore(null); setIsRestoreConfirmOpen(open); }}> diff --git a/src/services/transactions.tsx b/src/services/transactions.tsx index 6503b25..c274868 100644 --- a/src/services/transactions.tsx +++ b/src/services/transactions.tsx @@ -1,8 +1,10 @@ +'use client'; + import { database, auth } from '@/lib/firebase'; import { ref, set, get, push, remove, update, serverTimestamp } from 'firebase/database'; import type { User } from 'firebase/auth'; -import { getAccounts as getAllAccounts, updateAccount as updateAccountInDb, type Account } from './account-sync'; // Renamed getAccounts to avoid conflict +import { getAccounts as getAllAccounts, updateAccount as updateAccountInDb, type Account } from './account-sync'; import { convertCurrency } from '@/lib/currency'; // Import ref path getters from other services import { getCategoriesRefPath } from './categories'; @@ -35,12 +37,16 @@ export interface Transaction { // Type for data passed to addTransaction - transactionCurrency is now mandatory export type NewTransactionData = Omit & { transactionCurrency: string; - originalImportData?: { // Ensure this is part of the type if passed directly + originalImportData?: { foreignAmount?: number | null; foreignCurrency?: string | null; } }; +interface AddTransactionOptions { + skipBalanceModification?: boolean; +} + function getTransactionsRefPath(currentUser: User | null, accountId: string) { if (!currentUser?.uid) throw new Error("User not authenticated to access transactions."); @@ -53,12 +59,13 @@ function getSingleTransactionRefPath(currentUser: User | null, accountId: string } async function modifyAccountBalance(accountId: string, amountInTransactionCurrency: number, transactionCurrency: string, operation: 'add' | 'subtract') { - const accounts = await getAllAccounts(); + const accounts = await getAllAccounts(); // This fetches from Firebase/localStorage as per account-sync const accountToUpdate = accounts.find(acc => acc.id === accountId); if (accountToUpdate) { let amountInAccountCurrency = amountInTransactionCurrency; - if (transactionCurrency.toUpperCase() !== accountToUpdate.currency.toUpperCase()) { + // Ensure both currencies are defined and not empty strings before attempting conversion + if (transactionCurrency && accountToUpdate.currency && transactionCurrency.toUpperCase() !== accountToUpdate.currency.toUpperCase()) { amountInAccountCurrency = convertCurrency( amountInTransactionCurrency, transactionCurrency, @@ -66,22 +73,19 @@ async function modifyAccountBalance(accountId: string, amountInTransactionCurren ); } const balanceChange = operation === 'add' ? amountInAccountCurrency : -amountInAccountCurrency; - // Ensure newBalance calculation handles floating point inaccuracies for currency const newBalance = parseFloat((accountToUpdate.balance + balanceChange).toFixed(2)); - - await updateAccountInDb({ + await updateAccountInDb({ // This updates Firebase/localStorage as per account-sync ...accountToUpdate, balance: newBalance, lastActivity: new Date().toISOString(), }); - console.log(`Account ${accountToUpdate.name} balance updated by ${balanceChange} ${accountToUpdate.currency}. New balance: ${newBalance}`); + // console.log(`Account ${accountToUpdate.name} balance updated by ${balanceChange} ${accountToUpdate.currency}. New balance: ${newBalance}`); } else { console.warn(`Account ID ${accountId} not found for balance update.`); } } -// Helper function to get transactions from localStorage (internal to this service) async function _getTransactionsFromLocalStorage(accountId: string): Promise { const currentUser = auth.currentUser; if (!currentUser?.uid) return []; @@ -91,11 +95,10 @@ async function _getTransactionsFromLocalStorage(accountId: string): Promise { const currentUser = auth.currentUser; if (!currentUser?.uid) return; @@ -103,7 +106,6 @@ async function _saveTransactionsToLocalStorage(accountId: string, transactions: localStorage.setItem(key, JSON.stringify(transactions)); } - export async function getTransactions( accountId: string, options?: { limit?: number } @@ -116,13 +118,11 @@ export async function getTransactions( const storageKey = `transactions-${accountId}-${currentUser.uid}`; const data = localStorage.getItem(storageKey); - // console.log("Fetching transactions for account:", accountId, "from localStorage key:", storageKey); - if (data) { try { - const allAppAccounts = await getAllAccounts(); // Fetch accounts for currency fallback + const allAppAccounts = await getAllAccounts(); const transactionsArray = (JSON.parse(data) as Transaction[]) - .map(tx => ({ // Ensure default values for robustness with old data + .map(tx => ({ ...tx, tags: tx.tags || [], category: tx.category || 'Uncategorized', @@ -136,14 +136,17 @@ export async function getTransactions( return transactionsArray; } catch(e) { console.error("Error parsing transactions from localStorage during getTransactions for account:", accountId, e); - localStorage.removeItem(storageKey); // Clear corrupted data - return []; // Return empty if parsing fails + localStorage.removeItem(storageKey); + return []; } } return []; } -export async function addTransaction(transactionData: NewTransactionData): Promise { +export async function addTransaction( + transactionData: NewTransactionData, + options?: AddTransactionOptions +): Promise { const currentUser = auth.currentUser; const { accountId, amount, transactionCurrency, category } = transactionData; const transactionsRefPath = getTransactionsRefPath(currentUser, accountId); @@ -170,22 +173,18 @@ export async function addTransaction(transactionData: NewTransactionData): Promi const dataToSave = { ...newTransaction } as any; delete dataToSave.id; - - // console.log("Adding transaction to Firebase RTDB:", newTransaction); try { await set(newTransactionRef, dataToSave); - if (category?.toLowerCase() !== 'opening balance') { + // Conditionally modify balance + if (category?.toLowerCase() !== 'opening balance' && !options?.skipBalanceModification) { await modifyAccountBalance(accountId, amount, transactionCurrency, 'add'); } - // Update localStorage transaction list const storedTransactions = await _getTransactionsFromLocalStorage(accountId); - // Ensure createdAt/updatedAt are strings for localStorage const newTxForStorage = { ...newTransaction, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; storedTransactions.push(newTxForStorage); await _saveTransactionsToLocalStorage(accountId, storedTransactions); - return newTransaction; } catch (error) { console.error("Error adding transaction to Firebase:", error); @@ -199,7 +198,7 @@ export async function updateTransaction(updatedTransaction: Transaction): Promis const transactionRefPath = getSingleTransactionRefPath(currentUser, accountId, id); const transactionRef = ref(database, transactionRefPath); - const originalSnapshot = await get(transactionRef); // Get from DB for balance adjustment + const originalSnapshot = await get(transactionRef); if (!originalSnapshot.exists()) { throw new Error(`Transaction with ID ${id} not found for update.`); } @@ -207,8 +206,7 @@ export async function updateTransaction(updatedTransaction: Transaction): Promis const allAppAccounts = await getAllAccounts(); const originalDbTxCurrency = originalTransactionDataFromDB.transactionCurrency || allAppAccounts.find(a => a.id === accountId)?.currency || 'USD'; - - const dataToUpdateFirebase = { // Data for Firebase update + const dataToUpdateFirebase = { ...updatedTransaction, updatedAt: serverTimestamp(), originalImportData: { @@ -217,26 +215,22 @@ export async function updateTransaction(updatedTransaction: Transaction): Promis } } as any; delete dataToUpdateFirebase.id; + delete dataToUpdateFirebase.createdAt; // Should not update createdAt - - // console.log("Updating transaction in Firebase RTDB:", id, dataToUpdateFirebase); try { - await update(transactionRef, dataToUpdateFirebase); // Update DB + await update(transactionRef, dataToUpdateFirebase); - // Adjust account balances based on original (from DB) and new transaction amounts await modifyAccountBalance(accountId, originalTransactionDataFromDB.amount, originalDbTxCurrency, 'subtract'); await modifyAccountBalance(accountId, amount, transactionCurrency, 'add'); - // Update localStorage transaction list const storedTransactions = await _getTransactionsFromLocalStorage(accountId); const transactionIndex = storedTransactions.findIndex(t => t.id === id); if (transactionIndex !== -1) { - // Preserve original createdAt from localStorage if it exists and is a string, otherwise use what's in updatedTransaction const originalStoredCreatedAt = storedTransactions[transactionIndex].createdAt; storedTransactions[transactionIndex] = { - ...updatedTransaction, // Apply all updates - createdAt: updatedTransaction.createdAt || originalStoredCreatedAt || new Date().toISOString(), // Ensure createdAt is set - updatedAt: new Date().toISOString(), // For localStorage, use ISO string + ...updatedTransaction, + createdAt: updatedTransaction.createdAt || originalStoredCreatedAt || new Date().toISOString(), + updatedAt: new Date().toISOString(), }; await _saveTransactionsToLocalStorage(accountId, storedTransactions); } else { @@ -261,7 +255,7 @@ export async function deleteTransaction(transactionId: string, accountId: string const transactionRefPath = getSingleTransactionRefPath(currentUser, accountId, transactionId); const transactionRef = ref(database, transactionRefPath); - const snapshot = await get(transactionRef); // Get from DB for balance adjustment + const snapshot = await get(transactionRef); if (!snapshot.exists()) { console.warn(`Transaction ${transactionId} not found for deletion.`); return; @@ -270,13 +264,10 @@ export async function deleteTransaction(transactionId: string, accountId: string const allAppAccounts = await getAllAccounts(); const txCurrency = transactionToDelete.transactionCurrency || allAppAccounts.find(a => a.id === accountId)?.currency || 'USD'; - // console.log("Deleting transaction from Firebase RTDB:", transactionRefPath); try { - await remove(transactionRef); // Remove from DB - // Use the currency from the transaction being deleted for accurate balance reversal + await remove(transactionRef); await modifyAccountBalance(accountId, transactionToDelete.amount, txCurrency, 'subtract'); - // Update localStorage transaction list let storedTransactions = await _getTransactionsFromLocalStorage(accountId); storedTransactions = storedTransactions.filter(t => t.id !== transactionId); await _saveTransactionsToLocalStorage(accountId, storedTransactions); @@ -296,8 +287,7 @@ export async function clearAllSessionTransactions(): Promise { console.warn("Attempting to clear ALL user data for user:", currentUser.uid); try { - // Get paths for all data types - const userFirebaseTransactionsBasePath = `users/${currentUser.uid}/transactions`; // Base path for all account transactions + const userFirebaseTransactionsBasePath = `users/${currentUser.uid}/transactions`; const categoriesPath = getCategoriesRefPath(currentUser); const tagsPath = getTagsRefPath(currentUser); const groupsPath = getGroupsRefPath(currentUser); @@ -305,9 +295,8 @@ export async function clearAllSessionTransactions(): Promise { const loansPath = getLoansRefPath(currentUser); const creditCardsPath = getCreditCardsRefPath(currentUser); const budgetsPath = getBudgetsRefPath(currentUser); - const accountsPath = `users/${currentUser.uid}/accounts`; // Base path for all accounts + const accountsPath = `users/${currentUser.uid}/accounts`; - // Clear from Firebase DB await Promise.all([ remove(ref(database, userFirebaseTransactionsBasePath)), remove(ref(database, categoriesPath)), @@ -317,12 +306,9 @@ export async function clearAllSessionTransactions(): Promise { remove(ref(database, loansPath)), remove(ref(database, creditCardsPath)), remove(ref(database, budgetsPath)), - remove(ref(database, accountsPath)) // Clear all accounts from DB + remove(ref(database, accountsPath)) ]); - // Clear from localStorage - // Since accounts are cleared, we don't need to iterate through them for transaction keys - // but we should clear any general keys if they existed. const allKeys = Object.keys(localStorage); allKeys.forEach(key => { if (key.startsWith(`transactions-`) && key.endsWith(`-${currentUser.uid}`)) { @@ -340,7 +326,6 @@ export async function clearAllSessionTransactions(): Promise { localStorage.removeItem(`userBudgets-${currentUser.uid}`); localStorage.removeItem(`userPreferences-${currentUser.uid}`); - console.log("All user data cleared from Firebase and localStorage for user:", currentUser.uid); } catch (error) { console.error("Error clearing all user data:", error); From daf04844f24b6a967aaa82bc11ffaed17ce05528 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Wed, 21 May 2025 17:23:45 +0000 Subject: [PATCH 022/156] Somente na hora que vou importar um arquivo do proprio App ele aparece essa pagina e so fica carregadno --- src/app/data-management/page.tsx | 91 ++++++++++++++++---------------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/src/app/data-management/page.tsx b/src/app/data-management/page.tsx index a8cbbe4..e0c5412 100644 --- a/src/app/data-management/page.tsx +++ b/src/app/data-management/page.tsx @@ -204,6 +204,7 @@ export default function DataManagementPage() { const [isRestoreConfirmOpen, setIsRestoreConfirmOpen] = useState(false); const [zipFileForRestore, setZipFileForRestore] = useState(null); + const [isRestoring, setIsRestoring] = useState(false); // Specific state for restore operation const fetchData = useCallback(async () => { @@ -240,7 +241,7 @@ export default function DataManagementPage() { } } return () => { isMounted = false; }; - }, [user, isLoadingAuth, isLoading, toast]); + }, [user, isLoadingAuth, toast]); // Removed isLoading from dep array as it's managed inside useEffect(() => { fetchData(); @@ -355,11 +356,12 @@ export default function DataManagementPage() { if (manifest.appName === "GoldQuest") { setZipFileForRestore(file); setIsRestoreConfirmOpen(true); - setIsLoading(false); + setIsLoading(false); // Stop general loading as we're moving to restore dialog return; } } + // Fallback to finding a CSV if not a GoldQuest backup let primaryCsvFile: JSZip.JSZipObject | null = null; const commonPrimaryNames = ['transactions.csv', 'firefly_iii_export.csv', 'default.csv']; for (const name of commonPrimaryNames) { @@ -383,7 +385,7 @@ export default function DataManagementPage() { } if (primaryCsvFile) { - toast({ title: "ZIP Detected", description: `Processing '${primaryCsvFile.name}' from the archive for manual mapping.`, duration: 4000}); + toast({ title: "ZIP Detected", description: `Processing '${primaryCsvFile.name}' from archive for manual mapping.`, duration: 4000}); const csvString = await primaryCsvFile.async("string"); processCsvData(csvString, primaryCsvFile.name); } else { @@ -1467,15 +1469,13 @@ export default function DataManagementPage() { setIsRestoreConfirmOpen(false); return; } - setIsLoading(true); + setIsRestoring(true); // Use dedicated state for restore button setImportProgress(0); setError(null); let overallSuccess = true; - const accountsFromBackup: Account[] = []; const oldAccountIdToNewIdMap: Record = {}; const oldCategoryIdToNewIdMap: Record = {}; const oldGroupIdToNewIdMap: Record = {}; - let newAccountsListAfterRestore: Account[] = []; try { toast({ title: "Restore Started", description: "Clearing existing data...", duration: 2000 }); @@ -1554,7 +1554,7 @@ export default function DataManagementPage() { updateProgress(); setTags(await getTags()); - // 4. Accounts (Crucial: set balance from CSV here) + // 4. Accounts const accountsFile = zip.file('goldquest_accounts.csv'); if (accountsFile) { const accountsCsv = await accountsFile.async('text'); @@ -1564,7 +1564,7 @@ export default function DataManagementPage() { const newAccData: NewAccountData = { name: accFromCsv.name, type: accFromCsv.type, - balance: parseFloat(String(accFromCsv.balance)) || 0, // Use balance from CSV + balance: 0, // Start with 0, balance will be reconstructed by transactions currency: accFromCsv.currency, providerName: accFromCsv.providerName || 'Restored', category: accFromCsv.category || 'asset', @@ -1574,22 +1574,18 @@ export default function DataManagementPage() { includeInNetWorth: accFromCsv.includeInNetWorth !== undefined ? (String(accFromCsv.includeInNetWorth).toLowerCase() === 'true') : true, }; try { - const newAccount = await addAccount(newAccData); // addAccount sets balance + const newAccount = await addAccount(newAccData); oldAccountIdToNewIdMap[accFromCsv.id] = newAccount.id; - accountsFromBackup.push(newAccount); // Store for later reference if needed } catch (e: any) { console.error(`Error restoring account ${accFromCsv.name}: ${e.message}`); overallSuccess = false; } } } - newAccountsListAfterRestore = await getAccounts(); // Get all accounts after they've been added - setAccounts(newAccountsListAfterRestore); - toast({ title: "Restore Progress", description: "Accounts restored."}); + toast({ title: "Restore Progress", description: "Accounts (metadata) restored."}); } updateProgress(); - // 5. Groups const groupsFile = zip.file('goldquest_groups.csv'); if (groupsFile) { @@ -1621,14 +1617,17 @@ export default function DataManagementPage() { } updateProgress(); - // 6. Transactions (Skip balance modification) + // 6. Transactions (NOW these will update balances from 0) const transactionsFile = zip.file('goldquest_transactions.csv'); if (transactionsFile) { const transactionsCsv = await transactionsFile.async('text'); const parsedTransactions = Papa.parse(transactionsCsv, { header: true, skipEmptyLines: true, dynamicTyping: true }).data; + // Sort transactions by date to ensure correct balance calculation + parsedTransactions.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + for (const txCSV of parsedTransactions) { - if (txCSV.id && txCSV.accountId && txCSV.date && txCSV.amount !== undefined && txCSV.transactionCurrency && txCSV.category) { + if (txCSV.id && txCSV.accountId && txCSV.date && txCSV.amount !== undefined && txCSV.transactionCurrency) { const newAccountId = oldAccountIdToNewIdMap[txCSV.accountId]; if (newAccountId) { const newTxData: NewTransactionData = { @@ -1636,27 +1635,32 @@ export default function DataManagementPage() { amount: typeof txCSV.amount === 'string' ? parseFloat(txCSV.amount) : txCSV.amount, transactionCurrency: txCSV.transactionCurrency, description: txCSV.description || 'Restored Transaction', - category: txCSV.category, + category: txCSV.category || 'Uncategorized', // Ensure category is not empty accountId: newAccountId, tags: txCSV.tags ? txCSV.tags.split('|').filter(Boolean) : [], originalImportData: txCSV.originalImportData ? JSON.parse(txCSV.originalImportData) : undefined, }; try { - // Add transaction without modifying balance, as balances are set from accounts.csv - await addTransaction(newTxData, { skipBalanceModification: true }); + // IMPORTANT: Use skipBalanceModification: false (or omit) so balances are modified + await addTransaction(newTxData, { skipBalanceModification: txCSV.category?.toLowerCase() === 'opening balance' }); } catch (e: any) { console.error(`Error restoring transaction ${txCSV.description}: ${e.message}`); overallSuccess = false; } } else { console.warn(`Could not map old account ID ${txCSV.accountId} for transaction ${txCSV.description}`); + overallSuccess = false; } + } else { + console.warn("Skipping invalid transaction row from CSV:", txCSV); + overallSuccess = false; } } - toast({ title: "Restore Progress", description: "Transactions restored." }); + toast({ title: "Restore Progress", description: "Transactions restored and balances recalculated." }); } updateProgress(); + // Restore other data types... const dataTypesToRestore = [ { name: 'Subscriptions', file: 'goldquest_subscriptions.csv', addFn: addSubscriptionToDb, serviceName: 'subscription' }, { name: 'Loans', file: 'goldquest_loans.csv', addFn: addLoanToDb, serviceName: 'loan' }, @@ -1698,13 +1702,6 @@ export default function DataManagementPage() { } updateProgress(); } - - - if (overallSuccess) { - toast({ title: "Restore Complete", description: "All data restored successfully.", duration: 5000 }); - } else { - toast({ title: "Restore Partially Complete", description: "Some data could not be restored. Check console for errors.", variant: "destructive", duration: 10000 }); - } } catch (restoreError: any) { console.error("Full restore failed:", restoreError); @@ -1712,19 +1709,23 @@ export default function DataManagementPage() { toast({ title: "Restore Failed", description: restoreError.message || "An unknown error occurred during restore.", variant: "destructive" }); overallSuccess = false; } finally { - setIsLoading(false); - setImportProgress(100); // Indicate completion even if errors + setIsRestoring(false); // Reset dedicated restore loading state + setImportProgress(100); setIsRestoreConfirmOpen(false); setZipFileForRestore(null); + if (overallSuccess) { - await fetchData(); // Refresh page data - window.dispatchEvent(new Event('storage')); // Notify other components + toast({ title: "Restore Complete", description: "All data restored successfully from backup.", duration: 7000 }); + await fetchData(); + window.dispatchEvent(new Event('storage')); + } else { + toast({ title: "Restore Partially Complete or Failed", description: "Some data may not have been restored. Check console for errors.", variant: "destructive", duration: 10000 }); + await fetchData(); // Still try to refresh what might have been restored } } }; - if (isLoadingAuth) { return

Loading authentication...

; } @@ -1746,7 +1747,7 @@ export default function DataManagementPage() {
- +
{error && ( @@ -1758,19 +1759,19 @@ export default function DataManagementPage() { )}
- - - - @@ -1792,7 +1793,7 @@ export default function DataManagementPage() {
- {isLoading && importProgress > 0 && ( + {isLoading && importProgress > 0 && importProgress < 100 && ( )}
@@ -1829,16 +1830,16 @@ export default function DataManagementPage() { - {setIsRestoreConfirmOpen(false); setZipFileForRestore(null);}} disabled={isLoading}>Cancel - - {isLoading ? "Restoring..." : "Yes, Restore from Backup"} + {setIsRestoreConfirmOpen(false); setZipFileForRestore(null);}} disabled={isRestoring}>Cancel + + {isRestoring ? "Restoring..." : "Yes, Restore from Backup"}
- {accountPreviewData.length > 0 && !isLoading && !isRestoreConfirmOpen && ( + {accountPreviewData.length > 0 && !isLoading && !isRestoring && ( Account Changes Preview @@ -1876,7 +1877,7 @@ export default function DataManagementPage() { )} - {parsedData.length > 0 && !isRestoreConfirmOpen && ( + {parsedData.length > 0 && !isRestoring && ( Review & Import ({parsedData.filter(i => i.importStatus === 'pending').length} Pending Rows) From 969d54e9d799bdc8b7be64cd57b240828f7c0584 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Wed, 21 May 2025 17:35:32 +0000 Subject: [PATCH 023/156] As transacoes vieram corretas mas os calculos finais de cada conta nao. Acredito que esta falntando reconhecer no caso da transicao de uma moeda para outra e tambem alguns saldos negativos. Revise e corrija por favor. --- src/app/data-management/page.tsx | 198 +++++++++++++++++-------------- 1 file changed, 112 insertions(+), 86 deletions(-) diff --git a/src/app/data-management/page.tsx b/src/app/data-management/page.tsx index e0c5412..212e39c 100644 --- a/src/app/data-management/page.tsx +++ b/src/app/data-management/page.tsx @@ -94,7 +94,7 @@ const findColumnName = (headers: string[], targetName: string): string | undefin }; -const parseAmount = (amountStr: string | undefined): number => { +const parseAmount = (amountStr: string | undefined | null): number => { if (typeof amountStr !== 'string' || amountStr.trim() === '') return NaN; let cleaned = amountStr.replace(/[^\d.,-]/g, '').trim(); @@ -107,7 +107,13 @@ const parseAmount = (amountStr: string | undefined): number => { } else { cleaned = cleaned.replace(/,/g, ''); } - } else if (hasComma) { + } else if (hasComma && cleaned.lastIndexOf(',') > cleaned.indexOf('.')) { // Handle cases like 1.234,56 -> 1234.56 + if (cleaned.split(',')[1]?.length === 2 && cleaned.indexOf('.') < cleaned.lastIndexOf(',')) { // Likely European style + cleaned = cleaned.replace(/\./g, '').replace(',', '.'); + } else { // Likely US style with comma as thousands separator + cleaned = cleaned.replace(/,/g, ''); + } + } else if (hasComma && !hasPeriod) { // Only comma, assume it's decimal cleaned = cleaned.replace(',', '.'); } @@ -205,14 +211,18 @@ export default function DataManagementPage() { const [isRestoreConfirmOpen, setIsRestoreConfirmOpen] = useState(false); const [zipFileForRestore, setZipFileForRestore] = useState(null); const [isRestoring, setIsRestoring] = useState(false); // Specific state for restore operation + const [isInitializing, setIsInitializing] = useState(true); const fetchData = useCallback(async () => { let isMounted = true; - if (isMounted && !isLoading) setIsLoading(true); + if (isMounted && isInitializing) setIsLoading(true); if (typeof window === 'undefined' || !user || isLoadingAuth) { - if(isMounted) setIsLoading(false); + if (isMounted) { + setIsLoading(false); + setIsInitializing(false); + } return; } setError(null); @@ -238,10 +248,11 @@ export default function DataManagementPage() { } finally { if (isMounted) { setIsLoading(false); + setIsInitializing(false); } } return () => { isMounted = false; }; - }, [user, isLoadingAuth, toast]); // Removed isLoading from dep array as it's managed inside + }, [user, isLoadingAuth, isInitializing, toast]); useEffect(() => { fetchData(); @@ -267,6 +278,7 @@ export default function DataManagementPage() { Papa.parse(csvString, { header: true, skipEmptyLines: true, + dynamicTyping: true, // Automatically convert numbers and booleans complete: (results: ParseResult) => { if (results.errors.length > 0 && !results.data.length) { const criticalError = results.errors.find(e => e.code !== 'TooManyFields' && e.code !== 'TooFewFields') || results.errors[0]; @@ -356,30 +368,24 @@ export default function DataManagementPage() { if (manifest.appName === "GoldQuest") { setZipFileForRestore(file); setIsRestoreConfirmOpen(true); - setIsLoading(false); // Stop general loading as we're moving to restore dialog + setIsLoading(false); return; } } - // Fallback to finding a CSV if not a GoldQuest backup + // Fallback: if not a GoldQuest backup, try to find a primary CSV for manual mapping let primaryCsvFile: JSZip.JSZipObject | null = null; const commonPrimaryNames = ['transactions.csv', 'firefly_iii_export.csv', 'default.csv']; for (const name of commonPrimaryNames) { const foundFile = zip.file(name); - if (foundFile) { - primaryCsvFile = foundFile; - break; - } + if (foundFile) { primaryCsvFile = foundFile; break; } } if (!primaryCsvFile) { let largestSize = 0; zip.forEach((relativePath, zipEntry) => { if (zipEntry.name.toLowerCase().endsWith('.csv') && !zipEntry.dir) { const uncompressedSize = (zipEntry as any)._data?.uncompressedSize || 0; - if (uncompressedSize > largestSize) { - largestSize = uncompressedSize; - primaryCsvFile = zipEntry; - } + if (uncompressedSize > largestSize) { largestSize = uncompressedSize; primaryCsvFile = zipEntry; } } }); } @@ -389,7 +395,7 @@ export default function DataManagementPage() { const csvString = await primaryCsvFile.async("string"); processCsvData(csvString, primaryCsvFile.name); } else { - setError("No suitable CSV file found within the ZIP archive to process for mapping. If this is a GoldQuest backup, it might be missing a manifest or CSV files."); + setError("No suitable CSV file found within the ZIP. If this is a GoldQuest backup, it might be missing a manifest or critical CSV files."); setIsLoading(false); } } catch (zipError: any) { @@ -406,13 +412,10 @@ export default function DataManagementPage() { setIsLoading(false); } }; - reader.onerror = () => { - setError("Error reading CSV file."); - setIsLoading(false); - }; + reader.onerror = () => { setError("Error reading CSV file."); setIsLoading(false); }; reader.readAsText(file); } else { - setError("Unsupported file type. Please upload a CSV or a ZIP file containing CSVs."); + setError("Unsupported file type. Please upload a CSV or a ZIP file."); setIsLoading(false); } }; @@ -480,7 +483,7 @@ export default function DataManagementPage() { const sanitizedRecord: Record = {}; for (const key in record) { if (Object.prototype.hasOwnProperty.call(record, key)) { - sanitizedRecord[key] = record[key] === undefined ? null : record[key]!; + sanitizedRecord[key] = record[key] === undefined ? null : String(record[key]!); } } @@ -505,7 +508,7 @@ export default function DataManagementPage() { let rawDestType = destTypeCol ? record[destTypeCol]?.trim().toLowerCase() : undefined; if (!dateValue) throw new Error(`Row ${rowNumber}: Missing mapped 'Date' data.`); - if (amountValue === undefined || amountValue.trim() === '') throw new Error(`Row ${rowNumber}: Missing or empty 'Amount' data.`); + if (amountValue === undefined || String(amountValue).trim() === '') throw new Error(`Row ${rowNumber}: Missing or empty 'Amount' data.`); if (!currencyValue || currencyValue.trim() === '') throw new Error(`Row ${rowNumber}: Missing or empty 'Currency Code' data.`); if (!csvType) throw new Error(`Row ${rowNumber}: Missing or empty 'Transaction Type' (Firefly 'type') data.`); @@ -521,32 +524,32 @@ export default function DataManagementPage() { } } - const parsedAmount = parseAmount(amountValue); + const parsedAmount = parseAmount(String(amountValue)); if (isNaN(parsedAmount)) throw new Error(`Row ${rowNumber}: Could not parse amount "${amountValue}".`); let tempParsedForeignAmount: number | null = null; - if (foreignAmountValue !== undefined && foreignAmountValue.trim() !== "") { - const tempAmount = parseAmount(foreignAmountValue); + if (foreignAmountValue !== undefined && String(foreignAmountValue).trim() !== "") { + const tempAmount = parseAmount(String(foreignAmountValue)); if (!Number.isNaN(tempAmount)) { tempParsedForeignAmount = tempAmount; - } else if (foreignAmountValue.trim() !== '') { + } else if (String(foreignAmountValue).trim() !== '') { console.warn(`Row ${rowNumber}: Could not parse foreign amount "${foreignAmountValue}". It will be ignored.`); } } const finalParsedForeignAmount = tempParsedForeignAmount; let finalParsedForeignCurrency: string | null = null; - if (foreignCurrencyCol && record[foreignCurrencyCol] && record[foreignCurrencyCol]!.trim() !== '') { - finalParsedForeignCurrency = record[foreignCurrencyCol]!.trim().toUpperCase() || null; + if (foreignCurrencyCol && record[foreignCurrencyCol] && String(record[foreignCurrencyCol]!).trim() !== '') { + finalParsedForeignCurrency = String(record[foreignCurrencyCol]!).trim().toUpperCase() || null; } if (finalParsedForeignCurrency === "") finalParsedForeignCurrency = null; const parsedDate = parseDate(dateValue); - const parsedTags = tagsValue.split(',').map(tag => tag.trim()).filter(tag => tag.length > 0); + const parsedTags = String(tagsValue).split(',').map(tag => tag.trim()).filter(tag => tag.length > 0); - let finalDescription = descriptionValue.trim(); - if (notesValue.trim()) { - finalDescription = finalDescription ? `${finalDescription} (Notes: ${notesValue.trim()})` : `Notes: ${notesValue.trim()}`; + let finalDescription = String(descriptionValue).trim(); + if (String(notesValue).trim()) { + finalDescription = finalDescription ? `${finalDescription} (Notes: ${String(notesValue).trim()})` : `Notes: ${String(notesValue).trim()}`; } if (!finalDescription && csvType === 'withdrawal' && rawDestName) finalDescription = `To: ${rawDestName}`; @@ -557,7 +560,7 @@ export default function DataManagementPage() { if (csvType === 'opening balance') { let actualAccountNameForOB: string | undefined; let initialBalanceValue = initialBalanceCol ? record[initialBalanceCol] : amountValue; - let parsedInitialBalance = parseAmount(initialBalanceValue); + let parsedInitialBalance = parseAmount(String(initialBalanceValue)); if(isNaN(parsedInitialBalance)) { throw new Error(`Row ${rowNumber}: Could not parse initial balance for 'opening balance'. Value was: '${initialBalanceValue}'.`) @@ -573,8 +576,8 @@ export default function DataManagementPage() { if (parsedFromNameInDest) actualAccountNameForOB = parsedFromNameInDest; else if (parsedFromNameInSource) actualAccountNameForOB = parsedFromNameInSource; - else if (rawDestType === "initial balance account" && rawDestName) actualAccountNameForOB = rawDestName; // Firefly structure - else if (rawSourceType === "initial balance account" && rawSourceName) actualAccountNameForOB = rawSourceName; // Firefly structure + else if (rawDestType === "initial balance account" && rawDestName) actualAccountNameForOB = rawDestName; + else if (rawSourceType === "initial balance account" && rawSourceName) actualAccountNameForOB = rawSourceName; else actualAccountNameForOB = rawDestName || rawSourceName; } @@ -617,7 +620,7 @@ export default function DataManagementPage() { foreignAmount: finalParsedForeignAmount, foreignCurrency: finalParsedForeignCurrency, description: finalDescription, - category: categoryValue.trim(), + category: String(categoryValue).trim(), tags: parsedTags, originalRecord: sanitizedRecord, importStatus: 'pending', @@ -632,24 +635,24 @@ export default function DataManagementPage() { const errorSanitizedRecord: Record = {}; for (const key in record) { if (Object.prototype.hasOwnProperty.call(record, key)) { - errorSanitizedRecord[key] = record[key] === undefined ? null : record[key]!; + errorSanitizedRecord[key] = record[key] === undefined ? null : String(record[key]!); } } return { date: parseDate(record[confirmedMappings.date!]), amount: 0, - currency: record[confirmedMappings.currency_code!]?.trim().toUpperCase() || 'N/A', + currency: String(record[confirmedMappings.currency_code!] || 'N/A').trim().toUpperCase(), description: `Error Processing Row ${index + 2}`, category: 'Uncategorized', tags: [], originalRecord: errorSanitizedRecord, importStatus: 'error', errorMessage: rowError.message || 'Failed to process row.', - csvRawSourceName: (confirmedMappings.source_name && record[confirmedMappings.source_name] ? record[confirmedMappings.source_name]?.trim() : undefined) ?? null, - csvRawDestinationName: (confirmedMappings.destination_name && record[confirmedMappings.destination_name] ? record[confirmedMappings.destination_name]?.trim() : undefined) ?? null, - csvTransactionType: (confirmedMappings.transaction_type && record[confirmedMappings.transaction_type] ? record[confirmedMappings.transaction_type]?.trim().toLowerCase() : undefined) ?? null, - csvSourceType: (confirmedMappings.source_type && record[confirmedMappings.source_type] ? record[confirmedMappings.source_type]?.trim().toLowerCase() : undefined) ?? null, - csvDestinationType: (confirmedMappings.destination_type && record[confirmedMappings.destination_type] ? record[confirmedMappings.destination_type]?.trim().toLowerCase() : undefined) ?? null, + csvRawSourceName: (confirmedMappings.source_name && record[confirmedMappings.source_name] ? String(record[confirmedMappings.source_name])?.trim() : undefined) ?? null, + csvRawDestinationName: (confirmedMappings.destination_name && record[confirmedMappings.destination_name] ? String(record[confirmedMappings.destination_name])?.trim() : undefined) ?? null, + csvTransactionType: (confirmedMappings.transaction_type && record[confirmedMappings.transaction_type] ? String(record[confirmedMappings.transaction_type])?.trim().toLowerCase() : undefined) ?? null, + csvSourceType: (confirmedMappings.source_type && record[confirmedMappings.source_type] ? String(record[confirmedMappings.source_type])?.trim().toLowerCase() : undefined) ?? null, + csvDestinationType: (confirmedMappings.destination_type && record[confirmedMappings.destination_type] ? String(record[confirmedMappings.destination_type])?.trim().toLowerCase() : undefined) ?? null, foreignAmount: null, foreignCurrency: null, appSourceAccountId: null, @@ -680,15 +683,15 @@ export default function DataManagementPage() { ): Promise<{ preview: AccountPreview[] }> => { const mappedTransactions = csvData.map(record => { - const type = record[mappings.transaction_type!]?.trim().toLowerCase(); - const sourceName = record[mappings.source_name!]?.trim(); - const destName = record[mappings.destination_name!]?.trim(); - const sourceType = record[mappings.source_type!]?.trim().toLowerCase(); - const destType = record[mappings.destination_type!]?.trim().toLowerCase(); - const currency = record[mappings.currency_code!]?.trim().toUpperCase(); - const amount = parseAmount(record[mappings.amount!]); - const initialBalance = parseAmount(record[mappings.initialBalance!] || record[mappings.amount!]); - const foreignCurrencyVal = record[mappings.foreign_currency_code!]?.trim().toUpperCase(); + const type = String(record[mappings.transaction_type!] || '')?.trim().toLowerCase(); + const sourceName = String(record[mappings.source_name!] || '')?.trim(); + const destName = String(record[mappings.destination_name!] || '')?.trim(); + const sourceType = String(record[mappings.source_type!] || '')?.trim().toLowerCase(); + const destType = String(record[mappings.destination_type!] || '')?.trim().toLowerCase(); + const currency = String(record[mappings.currency_code!] || '')?.trim().toUpperCase(); + const amount = parseAmount(String(record[mappings.amount!])); + const initialBalance = parseAmount(String(record[mappings.initialBalance!] || record[mappings.amount!])); + const foreignCurrencyVal = String(record[mappings.foreign_currency_code!] || '')?.trim().toUpperCase(); return { csvTransactionType: type, @@ -699,7 +702,7 @@ export default function DataManagementPage() { currency: currency, foreignCurrency: foreignCurrencyVal, amount: type === 'opening balance' ? initialBalance : amount, - description: record[mappings.description!]?.trim() + description: String(record[mappings.description!] || '')?.trim() }; }) as Partial[]; @@ -763,8 +766,8 @@ export default function DataManagementPage() { if (item.csvTransactionType === 'opening balance') { let accountNameForOB: string | undefined; const recordCurrency = item.currency; - const recordAmount = item.amount; // This is already parsed initialBalance or amount for OB type - const desc = item.description; // Using the description field from mappedCsvData + const recordAmount = item.amount; + const desc = item.description; const sourceName = item.csvRawSourceName; const destName = item.csvRawDestinationName; const sourceType = item.csvSourceType; @@ -778,12 +781,12 @@ export default function DataManagementPage() { const parsedNameFromDesc = parseNameFromDescriptiveString(desc); if (parsedNameFromDesc) { accountNameForOB = parsedNameFromDesc; - } else if (destType === "initial balance account" && destName) { // Firefly structure for account name in destination + } else if (destType === "initial balance account" && destName) { accountNameForOB = destName; - } else if (sourceType === "initial balance account" && sourceName) { // Firefly structure for account name in source + } else if (sourceType === "initial balance account" && sourceName) { accountNameForOB = sourceName; } else { - accountNameForOB = destName || sourceName; // Fallback + accountNameForOB = destName || sourceName; } } @@ -793,14 +796,24 @@ export default function DataManagementPage() { const existingDetailsInMap = accountMap.get(normalizedName); let accountCategory: 'asset' | 'crypto' = 'asset'; - if (destType === "asset account" && destName?.toLowerCase().includes('crypto')) { + const sourceIsDescriptive = sourceType === "initial balance account" || sourceType === "revenue account" || sourceType === "expense account" || sourceType === "debt"; + const destIsDescriptive = destType === "initial balance account" || destType === "revenue account" || destType === "expense account" || destType === "debt"; + + if (sourceType === "asset account" && sourceName?.toLowerCase().includes('crypto')) { accountCategory = 'crypto'; - } else if (sourceType === "asset account" && sourceName?.toLowerCase().includes('crypto')) { + } else if (destType === "asset account" && destName?.toLowerCase().includes('crypto')) { accountCategory = 'crypto'; - } else if (destType?.includes('crypto') || sourceType?.includes('crypto') || accountNameForOB.toLowerCase().includes('crypto') || accountNameForOB.toLowerCase().includes('wallet')) { + } else if (sourceType === "asset account" && sourceIsDescriptive && destType?.includes('crypto')) { + accountCategory = 'crypto'; + } else if (destType === "asset account" && destIsDescriptive && sourceType?.includes('crypto')) { + accountCategory = 'crypto'; + } else if (sourceType?.includes('crypto') || destType?.includes('crypto')) { + accountCategory = 'crypto'; + } else if (accountNameForOB.toLowerCase().includes('crypto') || accountNameForOB.toLowerCase().includes('wallet')) { accountCategory = 'crypto'; } + accountMap.set(normalizedName, { name: accountNameForOB, currency: recordCurrency, @@ -840,7 +853,7 @@ export default function DataManagementPage() { accountMap.set(normalizedName, { name: accInfo.name, currency: existingAppAccount?.currency || accInfo.currency, - initialBalance: existingAppAccount?.balance, // Use existing balance as default, will be overwritten by OB if present + initialBalance: existingAppAccount?.balance, category: existingAppAccount?.category || category, }); } else { @@ -889,8 +902,8 @@ export default function DataManagementPage() { if (accPreview.action === 'create') { const newAccountData: NewAccountData = { name: accPreview.name, - type: (accPreview.category === 'crypto' ? 'wallet' : 'checking'), // Default type - balance: accPreview.initialBalance, // Use the balance determined by preview (from OB or 0) + type: (accPreview.category === 'crypto' ? 'wallet' : 'checking'), + balance: accPreview.initialBalance, currency: accPreview.currency, providerName: 'Imported - ' + accPreview.name, category: accPreview.category, @@ -963,7 +976,7 @@ export default function DataManagementPage() { let success = true; transactionsToProcess.forEach(tx => { - if (tx.importStatus === 'pending') { // Only consider pending transactions + if (tx.importStatus === 'pending') { if (tx.category && !['Uncategorized', 'Initial Balance', 'Transfer', 'Skipped', 'Opening Balance'].includes(tx.category)) { const categoryName = tx.category.trim(); if (categoryName && !existingCategoryNames.has(categoryName.toLowerCase())) { @@ -1469,7 +1482,7 @@ export default function DataManagementPage() { setIsRestoreConfirmOpen(false); return; } - setIsRestoring(true); // Use dedicated state for restore button + setIsRestoring(true); setImportProgress(0); setError(null); let overallSuccess = true; @@ -1495,7 +1508,7 @@ export default function DataManagementPage() { const prefsFile = zip.file('goldquest_preferences.csv'); if (prefsFile) { const prefsCsv = await prefsFile.async('text'); - const parsedPrefs = Papa.parse(prefsCsv, { header: true, skipEmptyLines: true }).data[0]; + const parsedPrefs = Papa.parse(prefsCsv, { header: true, skipEmptyLines: true, dynamicTyping: true }).data[0]; if (parsedPrefs) { await saveUserPreferences(parsedPrefs); await refreshUserPreferences(); @@ -1508,7 +1521,7 @@ export default function DataManagementPage() { const categoriesFile = zip.file('goldquest_categories.csv'); if (categoriesFile) { const categoriesCsv = await categoriesFile.async('text'); - const parsedCategories = Papa.parse(categoriesCsv, { header: true, skipEmptyLines: true }).data; + const parsedCategories = Papa.parse(categoriesCsv, { header: true, skipEmptyLines: true, dynamicTyping: true }).data; for (const cat of parsedCategories) { if(cat.id && cat.name) { try { @@ -1536,7 +1549,7 @@ export default function DataManagementPage() { const tagsFile = zip.file('goldquest_tags.csv'); if (tagsFile) { const tagsCsv = await tagsFile.async('text'); - const parsedTags = Papa.parse(tagsCsv, { header: true, skipEmptyLines: true }).data; + const parsedTags = Papa.parse(tagsCsv, { header: true, skipEmptyLines: true, dynamicTyping: true }).data; for (const tag of parsedTags) { if (tag.name) { try { @@ -1561,12 +1574,15 @@ export default function DataManagementPage() { const parsedAccounts = Papa.parse(accountsCsv, { header: true, skipEmptyLines: true, dynamicTyping: true }).data; for (const accFromCsv of parsedAccounts) { if(accFromCsv.id && accFromCsv.name && accFromCsv.currency && accFromCsv.type ) { + const balanceFromCsvString = String(accFromCsv.balance); // Ensure it's a string for parseAmount + const parsedBalance = parseAmount(balanceFromCsvString); // Use robust parser + const newAccData: NewAccountData = { name: accFromCsv.name, type: accFromCsv.type, - balance: 0, // Start with 0, balance will be reconstructed by transactions + balance: isNaN(parsedBalance) ? 0 : parsedBalance, // Use parsed balance, default to 0 if NaN currency: accFromCsv.currency, - providerName: accFromCsv.providerName || 'Restored', + providerName: accFromCsv.providerName || 'Restored - ' + accFromCsv.name, category: accFromCsv.category || 'asset', isActive: accFromCsv.isActive !== undefined ? (String(accFromCsv.isActive).toLowerCase() === 'true') : true, lastActivity: accFromCsv.lastActivity || new Date().toISOString(), @@ -1582,7 +1598,7 @@ export default function DataManagementPage() { } } } - toast({ title: "Restore Progress", description: "Accounts (metadata) restored."}); + toast({ title: "Restore Progress", description: "Accounts (metadata & balances from CSV) restored."}); } updateProgress(); @@ -1590,7 +1606,7 @@ export default function DataManagementPage() { const groupsFile = zip.file('goldquest_groups.csv'); if (groupsFile) { const groupsCsv = await groupsFile.async('text'); - const parsedGroups = Papa.parse(groupsCsv, { header: true, skipEmptyLines: true }).data; + const parsedGroups = Papa.parse(groupsCsv, { header: true, skipEmptyLines: true, dynamicTyping: true }).data; for (const group of parsedGroups) { if (group.id && group.name) { const oldCatIds = group.categoryIds ? group.categoryIds.split('|').filter(Boolean) : []; @@ -1617,13 +1633,12 @@ export default function DataManagementPage() { } updateProgress(); - // 6. Transactions (NOW these will update balances from 0) + // 6. Transactions (NOW these will be added WITHOUT modifying balances again) const transactionsFile = zip.file('goldquest_transactions.csv'); if (transactionsFile) { const transactionsCsv = await transactionsFile.async('text'); const parsedTransactions = Papa.parse(transactionsCsv, { header: true, skipEmptyLines: true, dynamicTyping: true }).data; - // Sort transactions by date to ensure correct balance calculation parsedTransactions.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); for (const txCSV of parsedTransactions) { @@ -1635,14 +1650,14 @@ export default function DataManagementPage() { amount: typeof txCSV.amount === 'string' ? parseFloat(txCSV.amount) : txCSV.amount, transactionCurrency: txCSV.transactionCurrency, description: txCSV.description || 'Restored Transaction', - category: txCSV.category || 'Uncategorized', // Ensure category is not empty + category: txCSV.category || 'Uncategorized', accountId: newAccountId, tags: txCSV.tags ? txCSV.tags.split('|').filter(Boolean) : [], originalImportData: txCSV.originalImportData ? JSON.parse(txCSV.originalImportData) : undefined, }; try { - // IMPORTANT: Use skipBalanceModification: false (or omit) so balances are modified - await addTransaction(newTxData, { skipBalanceModification: txCSV.category?.toLowerCase() === 'opening balance' }); + // IMPORTANT: Use skipBalanceModification: true so restored balances from accounts.csv are not altered + await addTransaction(newTxData, { skipBalanceModification: true }); } catch (e: any) { console.error(`Error restoring transaction ${txCSV.description}: ${e.message}`); overallSuccess = false; @@ -1656,7 +1671,7 @@ export default function DataManagementPage() { overallSuccess = false; } } - toast({ title: "Restore Progress", description: "Transactions restored and balances recalculated." }); + toast({ title: "Restore Progress", description: "Transactions restored (history only)."}); } updateProgress(); @@ -1709,14 +1724,14 @@ export default function DataManagementPage() { toast({ title: "Restore Failed", description: restoreError.message || "An unknown error occurred during restore.", variant: "destructive" }); overallSuccess = false; } finally { - setIsRestoring(false); // Reset dedicated restore loading state + setIsRestoring(false); setImportProgress(100); - setIsRestoreConfirmOpen(false); setZipFileForRestore(null); + setIsRestoreConfirmOpen(false); // Ensure dialog closes if (overallSuccess) { toast({ title: "Restore Complete", description: "All data restored successfully from backup.", duration: 7000 }); - await fetchData(); + await fetchData(); // Refresh page data (accounts, etc.) window.dispatchEvent(new Event('storage')); } else { toast({ title: "Restore Partially Complete or Failed", description: "Some data may not have been restored. Check console for errors.", variant: "destructive", duration: 10000 }); @@ -1732,6 +1747,10 @@ export default function DataManagementPage() { if (!user && !isLoadingAuth) { return

Please login to manage data.

; } + if (isInitializing) { + return

Initializing data management...

; + } + return (
@@ -1763,7 +1782,7 @@ export default function DataManagementPage() { {isLoading ? "Processing File..." : "Parse File & Map Columns"}
- {isLoading && importProgress > 0 && importProgress < 100 && ( + {isLoading && importProgress > 0 && importProgress < 100 && !isRestoring && ( )} + {isRestoring && ( +
+ + +
+ )}
@@ -1817,7 +1842,7 @@ export default function DataManagementPage() { { - if (!open) setZipFileForRestore(null); + if (!open && !isRestoring) setZipFileForRestore(null); setIsRestoreConfirmOpen(open); }}> @@ -1960,3 +1985,4 @@ export default function DataManagementPage() {
); } + From 450b681eccde1ff1d9b392eb8b4c97c4ef0b5efb Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Wed, 21 May 2025 17:41:33 +0000 Subject: [PATCH 024/156] Perfeito, deu certo, so mais uma alteracao antes do rollout, altere o icone do Data Management para um icone de database --- src/components/layout/auth-wrapper.tsx | 48 +++++++++++++++++--------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/src/components/layout/auth-wrapper.tsx b/src/components/layout/auth-wrapper.tsx index 6261038..e547c86 100644 --- a/src/components/layout/auth-wrapper.tsx +++ b/src/components/layout/auth-wrapper.tsx @@ -8,7 +8,6 @@ import { SidebarProvider, Sidebar, SidebarHeader, - // SidebarTrigger, // Removed from here SidebarContent, SidebarMenu, SidebarMenuItem, @@ -19,7 +18,7 @@ import { SidebarGroupLabel, } from '@/components/ui/sidebar'; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Landmark, Wallet, ArrowLeftRight, Settings, ChevronDown, TrendingUp, TrendingDown, LayoutList, Upload, Users, LogOut, Network, PieChart, CalendarClock, Archive as ArchiveIcon, SlidersHorizontal, FileText } from 'lucide-react'; +import { Landmark, Wallet, ArrowLeftRight, Settings, ChevronDown, TrendingUp, TrendingDown, LayoutList, Users, LogOut, Network, PieChart, Database, SlidersHorizontal, FileText } from 'lucide-react'; // Added Database import Link from 'next/link'; import { useRouter, usePathname } from 'next/navigation'; import { useState, useEffect } from 'react'; @@ -68,10 +67,10 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { setIsClient(true); setLoadingDivClassName("flex items-center justify-center min-h-screen bg-background text-foreground"); }, []); - + useEffect(() => { const applyTheme = () => { - if (!isClient) return; + if (!isClient) return; const root = document.documentElement; let currentTheme = theme; @@ -82,10 +81,10 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { root.classList.remove('dark', 'light'); root.classList.add(currentTheme); - root.style.colorScheme = currentTheme; + root.style.colorScheme = currentTheme; }; - applyTheme(); + applyTheme(); if (theme === 'system' && typeof window !== 'undefined') { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); @@ -110,7 +109,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { } const firstLoginFlagKey = `hasLoggedInBefore-${user.uid}`; - + if(typeof window !== 'undefined') { const hasLoggedInBefore = localStorage.getItem(firstLoginFlagKey); const preferencesLoadedAndThemeSet = userPreferences && userPreferences.theme; @@ -124,7 +123,6 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { }, [user, isLoadingAuth, router, pathname, isClient, isFirebaseActive, userPreferences]); - const isActive = (path: string) => isClient && pathname === path; const isAnyTransactionRouteActive = isClient && (pathname.startsWith('/transactions') || pathname.startsWith('/revenue') || pathname.startsWith('/expenses') || pathname.startsWith('/transfers')); const isAnyFinancialControlRouteActive = isClient && pathname === '/financial-control'; @@ -138,10 +136,10 @@ export default function AuthWrapper({ children }: AuthWrapperProps) {
Loading authentication...
); } - + if (firebaseError && !isFirebaseActive) { if (pathname !== '/login' && pathname !== '/signup') { - router.push('/login'); + router.push('/login'); return
Firebase not available. Redirecting...
; } } @@ -176,7 +174,6 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { GoldQuest
- {/* SidebarTrigger was here, moved to GlobalHeader for mobile */} @@ -190,7 +187,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { - + @@ -216,7 +213,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { isActive={isAnyTransactionRouteActive} >
- {/* Changed icon */} + Transactions
- + Data Management @@ -308,7 +305,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) {
- + {user?.email ? user.email.substring(0, 2).toUpperCase() : 'U'}
@@ -332,5 +329,24 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { ); } - return
Preparing application...
; + return ( + + + + + The Golden Game + + + +
Preparing application...
+ + + ); } From 59ccdd2d3b37927e617a67e910a9dfdec0ffaf0d Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Wed, 21 May 2025 17:44:52 +0000 Subject: [PATCH 025/156] Vamos melhorar a dashboard um pouco agora, no lugar do card de Top Spending coloco algo relacionado as subscriptions, um grafico de pizza --- src/app/page.tsx | 48 +--- .../dashboard/subscriptions-pie-chart.tsx | 235 ++++++++++++++++++ 2 files changed, 242 insertions(+), 41 deletions(-) create mode 100644 src/components/dashboard/subscriptions-pie-chart.tsx diff --git a/src/app/page.tsx b/src/app/page.tsx index 8cd3d90..60a3b48 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -5,7 +5,8 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; // Added useC import type { FC } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import TotalNetWorthCard from "@/components/dashboard/total-net-worth-card"; -import SpendingsBreakdown from "@/components/dashboard/spendings-breakdown"; +// import SpendingsBreakdown from "@/components/dashboard/spendings-breakdown"; // Removed +import SubscriptionsPieChart from "@/components/dashboard/subscriptions-pie-chart"; // Added import IncomeSourceChart from "@/components/dashboard/income-source-chart"; import IncomeExpensesChart from "@/components/dashboard/income-expenses-chart"; import AssetsChart from "@/components/dashboard/assets-chart"; @@ -75,8 +76,8 @@ export default function DashboardPage() { const handleStorageChange = (event: StorageEvent) => { if (typeof window !== 'undefined' && event.type === 'storage') { const isLikelyOurCustomEvent = event.key === null; - const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'userCategories', 'userTags', 'transactions-']; - const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key.includes(k)); + const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'userCategories', 'userTags', 'transactions-', 'userSubscriptions']; // Added userSubscriptions + const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key!.includes(k)); if (isLikelyOurCustomEvent || isRelevantExternalChange) { console.log("Storage changed on main dashboard, refetching data..."); @@ -142,40 +143,6 @@ export default function DashboardPage() { }, 0); }, [periodTransactions, accounts, preferredCurrency, isLoading]); - - const spendingsBreakdownDataActual = useMemo(() => { - if (isLoading || typeof window === 'undefined' || !categories.length || !periodTransactions.length) return []; - const expenseCategoryTotals: { [key: string]: number } = {}; - - periodTransactions.forEach(tx => { - if (tx.amount < 0 && tx.category !== 'Transfer') { - const account = accounts.find(acc => acc.id === tx.accountId); - if (account && account.includeInNetWorth !== false) { - const categoryName = tx.category || 'Uncategorized'; - const convertedAmount = convertCurrency(Math.abs(tx.amount), tx.transactionCurrency, preferredCurrency); - expenseCategoryTotals[categoryName] = (expenseCategoryTotals[categoryName] || 0) + convertedAmount; - } - } - }); - - return Object.entries(expenseCategoryTotals) - .map(([name, amount]) => { - const { icon: CategoryIcon, color } = getCategoryStyle(name); - - const categoryStyle = getCategoryStyle(name); - const bgColor = categoryStyle.color.split(' ').find(cls => cls.startsWith('bg-')) || 'bg-gray-500 dark:bg-gray-700'; - - return { - name: name.charAt(0).toUpperCase() + name.slice(1), - amount, - icon: , - bgColor: bgColor, - }; - }) - .sort((a, b) => b.amount - a.amount) - .slice(0, 3); - }, [periodTransactions, accounts, categories, preferredCurrency, isLoading]); - const incomeSourceDataActual = useMemo(() => { if (isLoading || typeof window === 'undefined' || !periodTransactions.length) return []; const incomeCategoryTotals: { [key: string]: number } = {}; @@ -253,12 +220,11 @@ export default function DashboardPage() { if (isLoading && typeof window !== 'undefined' && accounts.length === 0) { return (
-
+
-
-
+
@@ -276,7 +242,7 @@ export default function DashboardPage() {
- +
diff --git a/src/components/dashboard/subscriptions-pie-chart.tsx b/src/components/dashboard/subscriptions-pie-chart.tsx new file mode 100644 index 0000000..5da515e --- /dev/null +++ b/src/components/dashboard/subscriptions-pie-chart.tsx @@ -0,0 +1,235 @@ + +'use client'; + +import type { FC } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip, Legend } from 'recharts'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { ChartConfig, ChartContainer, ChartTooltipContent, ChartLegend, ChartLegendContent } from '@/components/ui/chart'; +import { Skeleton } from '@/components/ui/skeleton'; +import { getSubscriptions, type Subscription, type SubscriptionFrequency } from '@/services/subscriptions'; +import { getCategories, type Category as CategoryType, getCategoryStyle } from '@/services/categories'; +import { getUserPreferences } from '@/lib/preferences'; +import { convertCurrency, formatCurrency } from '@/lib/currency'; +import { useDateRange } from '@/contexts/DateRangeContext'; // To get dateRangeLabel if needed +import { isWithinInterval, parseISO, startOfMonth, endOfMonth } from 'date-fns'; + + +export interface SubscriptionPieChartDataPoint { + name: string; // Category name + value: number; // Total monthly equivalent amount for this category + fill: string; // Color for the pie slice +} + +// Helper to calculate monthly equivalent cost +const calculateMonthlyEquivalent = ( + amount: number, + currency: string, + frequency: SubscriptionFrequency, + preferredDisplayCurrency: string, +): number => { + const amountInPreferredCurrency = convertCurrency(amount, currency, preferredDisplayCurrency); + switch (frequency) { + case 'daily': return amountInPreferredCurrency * 30; + case 'weekly': return amountInPreferredCurrency * 4; + case 'bi-weekly': return amountInPreferredCurrency * 2; + case 'monthly': return amountInPreferredCurrency; + case 'quarterly': return amountInPreferredCurrency / 3; + case 'semi-annually': return amountInPreferredCurrency / 6; + case 'annually': return amountInPreferredCurrency / 12; + default: return 0; + } +}; + +const BAR_COLORS = [ + "hsl(var(--chart-1))", + "hsl(var(--chart-2))", + "hsl(var(--chart-3))", + "hsl(var(--chart-4))", + "hsl(var(--chart-5))", +]; + +interface SubscriptionsPieChartProps { + // Props can be added if needed, e.g., to pass pre-fetched data or config + dateRangeLabel: string; // To display in the card description +} + +const SubscriptionsPieChart: FC = ({ dateRangeLabel }) => { + const [subscriptions, setSubscriptions] = useState([]); + const [categories, setCategories] = useState([]); + const [preferredCurrency, setPreferredCurrency] = useState('BRL'); + const [isLoading, setIsLoading] = useState(true); + const { selectedDateRange } = useDateRange(); + + + useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + try { + const prefs = await getUserPreferences(); + setPreferredCurrency(prefs.preferredCurrency); + const [subs, cats] = await Promise.all([ + getSubscriptions(), + getCategories(), + ]); + setSubscriptions(subs); + setCategories(cats); + } catch (error) { + console.error("Failed to fetch data for subscriptions chart:", error); + // Handle error appropriately, maybe set an error state + } finally { + setIsLoading(false); + } + }; + fetchData(); + }, []); + + const chartData = useMemo((): SubscriptionPieChartDataPoint[] => { + if (isLoading || subscriptions.length === 0 || categories.length === 0) { + return []; + } + + const categoryMonthlyTotals: Record = {}; + + subscriptions.forEach(sub => { + if (sub.type === 'expense') { + // Consider if subscriptions should be filtered by selectedDateRange + // For a "current monthly burden" view, we might not filter by date range, + // or we might check if the subscription is active within the range. + // For simplicity here, we'll consider all active expense subscriptions. + // const isActiveInRange = selectedDateRange.from && selectedDateRange.to ? + // isWithinInterval(parseISO(sub.startDate), { start: selectedDateRange.from, end: selectedDateRange.to }) || + // isWithinInterval(parseISO(sub.nextPaymentDate), { start: selectedDateRange.from, end: selectedDateRange.to }) + // : true; // If no date range, assume active + + // if(isActiveInRange) { // If you want to filter by date range + const monthlyCost = calculateMonthlyEquivalent( + sub.amount, + sub.currency, + sub.frequency, + preferredCurrency + ); + categoryMonthlyTotals[sub.category] = (categoryMonthlyTotals[sub.category] || 0) + monthlyCost; + // } + } + }); + + return Object.entries(categoryMonthlyTotals) + .map(([categoryName, totalAmount], index) => { + const categoryDetails = categories.find(c => c.name === categoryName); + // Use category specific color if available, otherwise cycle through BAR_COLORS + let color = BAR_COLORS[index % BAR_COLORS.length]; + if (categoryDetails) { + const style = getCategoryStyle(categoryDetails); + // This is tricky as getCategoryStyle returns Tailwind classes. + // For charts, direct HSL values are better. We'll stick to BAR_COLORS for now. + } + return { + name: categoryName, + value: parseFloat(totalAmount.toFixed(2)), + fill: color, + }; + }) + .filter(item => item.value > 0) // Only include categories with expenses + .sort((a, b) => b.value - a.value); + }, [isLoading, subscriptions, categories, preferredCurrency, selectedDateRange]); + + const chartConfig = useMemo(() => { + return chartData.reduce((acc, item) => { + acc[item.name] = { + label: item.name, + color: item.fill, + }; + return acc; + }, {} as ChartConfig); + }, [chartData]); + + const totalValue = useMemo(() => chartData.reduce((sum, item) => sum + item.value, 0), [chartData]); + + + if (isLoading) { + return ( + + + + + + + + + + ); + } + + if (chartData.length === 0) { + return ( + + + Monthly Subscription Costs + By category for {dateRangeLabel}. + + +

No expense subscription data for this period.

+
+
+ ); + } + + return ( + + + Monthly Subscription Costs + By category for {dateRangeLabel} ({preferredCurrency}). + + + + + + ( +
+ + + {props.payload.name}: {formatCurrency(Number(value), preferredCurrency, preferredCurrency, false)} ( + {totalValue > 0 ? ((Number(value) / totalValue) * 100).toFixed(1) : 0}%) + +
+ )} + /> + } + /> + + {chartData.map((entry) => ( + + ))} + + } verticalAlign="bottom" align="center" iconSize={10} wrapperStyle={{paddingTop: 10}}/> +
+
+
+
+
+ ); +}; + +export default SubscriptionsPieChart; From df41ec97d6b00ad21fa3312a5e1bfc2294129a0d Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Wed, 21 May 2025 17:46:46 +0000 Subject: [PATCH 026/156] Show, so adicione o valor total em algum lugar --- .../dashboard/subscriptions-pie-chart.tsx | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/src/components/dashboard/subscriptions-pie-chart.tsx b/src/components/dashboard/subscriptions-pie-chart.tsx index 5da515e..de3c371 100644 --- a/src/components/dashboard/subscriptions-pie-chart.tsx +++ b/src/components/dashboard/subscriptions-pie-chart.tsx @@ -50,8 +50,7 @@ const BAR_COLORS = [ ]; interface SubscriptionsPieChartProps { - // Props can be added if needed, e.g., to pass pre-fetched data or config - dateRangeLabel: string; // To display in the card description + dateRangeLabel: string; } const SubscriptionsPieChart: FC = ({ dateRangeLabel }) => { @@ -76,7 +75,6 @@ const SubscriptionsPieChart: FC = ({ dateRangeLabel setCategories(cats); } catch (error) { console.error("Failed to fetch data for subscriptions chart:", error); - // Handle error appropriately, maybe set an error state } finally { setIsLoading(false); } @@ -94,15 +92,18 @@ const SubscriptionsPieChart: FC = ({ dateRangeLabel subscriptions.forEach(sub => { if (sub.type === 'expense') { // Consider if subscriptions should be filtered by selectedDateRange - // For a "current monthly burden" view, we might not filter by date range, - // or we might check if the subscription is active within the range. - // For simplicity here, we'll consider all active expense subscriptions. - // const isActiveInRange = selectedDateRange.from && selectedDateRange.to ? - // isWithinInterval(parseISO(sub.startDate), { start: selectedDateRange.from, end: selectedDateRange.to }) || - // isWithinInterval(parseISO(sub.nextPaymentDate), { start: selectedDateRange.from, end: selectedDateRange.to }) - // : true; // If no date range, assume active - - // if(isActiveInRange) { // If you want to filter by date range + // For this chart, we'll consider all active expense subscriptions that start within or before the range end, + // and their next payment date is relevant to the period or they are ongoing. + const subStartDate = parseISO(sub.startDate); + const subNextPaymentDate = parseISO(sub.nextPaymentDate); + + let isActiveInRange = true; // Default to true if no specific date range is set (e.g., "All Time") + if (selectedDateRange.from && selectedDateRange.to) { + isActiveInRange = subStartDate <= selectedDateRange.to; // Starts before or during range + } + + + if(isActiveInRange) { const monthlyCost = calculateMonthlyEquivalent( sub.amount, sub.currency, @@ -110,27 +111,21 @@ const SubscriptionsPieChart: FC = ({ dateRangeLabel preferredCurrency ); categoryMonthlyTotals[sub.category] = (categoryMonthlyTotals[sub.category] || 0) + monthlyCost; - // } + } } }); return Object.entries(categoryMonthlyTotals) .map(([categoryName, totalAmount], index) => { const categoryDetails = categories.find(c => c.name === categoryName); - // Use category specific color if available, otherwise cycle through BAR_COLORS let color = BAR_COLORS[index % BAR_COLORS.length]; - if (categoryDetails) { - const style = getCategoryStyle(categoryDetails); - // This is tricky as getCategoryStyle returns Tailwind classes. - // For charts, direct HSL values are better. We'll stick to BAR_COLORS for now. - } return { name: categoryName, value: parseFloat(totalAmount.toFixed(2)), fill: color, }; }) - .filter(item => item.value > 0) // Only include categories with expenses + .filter(item => item.value > 0) .sort((a, b) => b.value - a.value); }, [isLoading, subscriptions, categories, preferredCurrency, selectedDateRange]); @@ -144,7 +139,9 @@ const SubscriptionsPieChart: FC = ({ dateRangeLabel }, {} as ChartConfig); }, [chartData]); - const totalValue = useMemo(() => chartData.reduce((sum, item) => sum + item.value, 0), [chartData]); + const totalMonthlySubscriptionCost = useMemo(() => { + return chartData.reduce((sum, item) => sum + item.value, 0); + }, [chartData]); if (isLoading) { @@ -166,10 +163,10 @@ const SubscriptionsPieChart: FC = ({ dateRangeLabel Monthly Subscription Costs - By category for {dateRangeLabel}. + No expense subscription data for {dateRangeLabel}. -

No expense subscription data for this period.

+

No expense subscription data.

); @@ -179,7 +176,9 @@ const SubscriptionsPieChart: FC = ({ dateRangeLabel Monthly Subscription Costs - By category for {dateRangeLabel} ({preferredCurrency}). + + Total: {formatCurrency(totalMonthlySubscriptionCost, preferredCurrency, preferredCurrency, false)} for {dateRangeLabel}. + = ({ dateRangeLabel /> {props.payload.name}: {formatCurrency(Number(value), preferredCurrency, preferredCurrency, false)} ( - {totalValue > 0 ? ((Number(value) / totalValue) * 100).toFixed(1) : 0}%) + {totalMonthlySubscriptionCost > 0 ? ((Number(value) / totalMonthlySubscriptionCost) * 100).toFixed(1) : 0}%)
)} @@ -233,3 +232,4 @@ const SubscriptionsPieChart: FC = ({ dateRangeLabel }; export default SubscriptionsPieChart; + From 7a9089197d897ac80be52240391e487a85cf439e Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Wed, 21 May 2025 17:49:27 +0000 Subject: [PATCH 027/156] So assim ficou muito feio kkk, coloque de um jeito diferente e diminua o titulo, pode ser so subscriptions --- src/app/organization/page.tsx | 90 +++++++++++++------ .../dashboard/subscriptions-pie-chart.tsx | 22 ++--- 2 files changed, 70 insertions(+), 42 deletions(-) diff --git a/src/app/organization/page.tsx b/src/app/organization/page.tsx index 81182f5..0f38dc4 100644 --- a/src/app/organization/page.tsx +++ b/src/app/organization/page.tsx @@ -91,7 +91,7 @@ export default function OrganizationPage() { if (typeof window !== 'undefined' && event.type === 'storage') { const isLikelyOurCustomEvent = event.key === null; const relevantKeysForThisPage = ['userCategories', 'userTags', 'userGroups']; - const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key.includes(k)); + const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key && event.key.includes(k)); if (isLikelyOurCustomEvent || isRelevantExternalChange) { console.log(`Storage change for organization page (key: ${event.key || 'custom'}), refetching data...`); @@ -115,7 +115,6 @@ export default function OrganizationPage() { setIsAddCategoryDialogOpen(false); toast({ title: "Success", description: `Category "${categoryName}" added.` }); window.dispatchEvent(new Event('storage')); - //fetchData(); // Let the storage event handle the refetch } catch (err: any) { console.error("Failed to add category:", err); toast({ title: "Error Adding Category", description: err.message || "Could not add category.", variant: "destructive" }); @@ -128,7 +127,6 @@ export default function OrganizationPage() { setIsEditCategoryDialogOpen(false); setSelectedCategory(null); toast({ title: "Success", description: `Category updated to "${newName}".` }); window.dispatchEvent(new Event('storage')); - //fetchData(); // Let the storage event handle the refetch } catch (err: any) { console.error("Failed to update category:", err); toast({ title: "Error Updating Category", description: err.message || "Could not update category.", variant: "destructive" }); @@ -140,7 +138,6 @@ export default function OrganizationPage() { await deleteCategory(selectedCategory.id); toast({ title: "Category Deleted", description: `Category "${selectedCategory.name}" removed.` }); window.dispatchEvent(new Event('storage')); - //fetchData(); // Let the storage event handle the refetch } catch (err: any) { console.error("Failed to delete category:", err); toast({ title: "Error Deleting Category", description: err.message || "Could not delete category.", variant: "destructive" }); @@ -157,7 +154,6 @@ export default function OrganizationPage() { setIsAddTagDialogOpen(false); toast({ title: "Success", description: `Tag "${tagName}" added.` }); window.dispatchEvent(new Event('storage')); - //fetchData(); // Let the storage event handle the refetch } catch (err: any) { console.error("Failed to add tag:", err); toast({ title: "Error Adding Tag", description: err.message || "Could not add tag.", variant: "destructive" }); @@ -170,7 +166,6 @@ export default function OrganizationPage() { setIsEditTagDialogOpen(false); setSelectedTag(null); toast({ title: "Success", description: `Tag updated to "${newName}".` }); window.dispatchEvent(new Event('storage')); - //fetchData(); // Let the storage event handle the refetch } catch (err: any) { console.error("Failed to update tag:", err); toast({ title: "Error Updating Tag", description: err.message || "Could not update tag.", variant: "destructive" }); @@ -182,7 +177,6 @@ export default function OrganizationPage() { await deleteTag(selectedTag.id); toast({ title: "Tag Deleted", description: `Tag "${selectedTag.name}" removed.` }); window.dispatchEvent(new Event('storage')); - //fetchData(); // Let the storage event handle the refetch } catch (err: any) { console.error("Failed to delete tag:", err); toast({ title: "Error Deleting Tag", description: err.message || "Could not delete tag.", variant: "destructive" }); @@ -199,7 +193,6 @@ export default function OrganizationPage() { setIsAddGroupDialogOpen(false); toast({ title: "Success", description: `Group "${groupName}" added.` }); window.dispatchEvent(new Event('storage')); - //fetchData(); // Let the storage event handle the refetch } catch (err: any) { console.error("Failed to add group:", err); toast({ title: "Error Adding Group", description: err.message || "Could not add group.", variant: "destructive" }); @@ -220,7 +213,6 @@ export default function OrganizationPage() { setSelectedGroupForEdit(null); toast({ title: "Success", description: `Group name updated to "${newName}".` }); window.dispatchEvent(new Event('storage')); - //fetchData(); // Let the storage event handle the refetch } catch (err: any) { console.error("Failed to update group name:", err); toast({ title: "Error Updating Group Name", description: err.message || "Could not update group name.", variant: "destructive" }); @@ -245,7 +237,6 @@ export default function OrganizationPage() { setSelectedGroupForCategoryManagement(null); toast({ title: "Success", description: "Categories in group updated." }); window.dispatchEvent(new Event('storage')); - //fetchData(); // Let the storage event handle the refetch } } catch (err: any) { console.error("Failed to update group categories:", err); @@ -267,7 +258,6 @@ export default function OrganizationPage() { await deleteGroup(selectedGroupForDeletion.id); toast({ title: "Group Deleted", description: `Group "${selectedGroupForDeletion.name}" removed.` }); window.dispatchEvent(new Event('storage')); - //fetchData(); // Let the storage event handle the refetch } catch (err:any) { console.error("Failed to delete group:", err); toast({ title: "Error Deleting Group", description: err.message || "Could not delete group.", variant: "destructive" }); @@ -327,22 +317,40 @@ export default function OrganizationPage() { View Details - -
+
{ e.stopPropagation(); openManageGroupCategoriesDialog(group); }} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.stopPropagation(); openManageGroupCategoriesDialog(group); }}} + > Manage Categories - +
{ if (!isOpen) setSelectedGroupForDeletion(null); }} > - - +
{selectedGroupForDeletion?.id === group.id && ( @@ -448,20 +456,32 @@ export default function OrganizationPage() {
- {category.name} + {category.name}
- +
{e.preventDefault(); e.stopPropagation(); openEditCategoryDialog(category);}} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') {e.preventDefault(); e.stopPropagation(); openEditCategoryDialog(category);}}} + > + Edit +
{ if (!isOpen) setSelectedCategory(null); }} > - - + {e.preventDefault(); e.stopPropagation();}}> +
{e.preventDefault(); e.stopPropagation(); openDeleteCategoryDialog(category);}} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') {e.preventDefault(); e.stopPropagation(); openDeleteCategoryDialog(category);}}} + > + Delete +
{selectedCategory?.id === category.id && ( @@ -520,17 +540,29 @@ export default function OrganizationPage() { {tag.name}
- +
{e.preventDefault(); e.stopPropagation(); openEditTagDialog(tag);}} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') {e.preventDefault(); e.stopPropagation(); openEditTagDialog(tag);}}} + > + Edit +
{ if (!isOpen) setSelectedTag(null); }} > - - + {e.preventDefault(); e.stopPropagation();}}> +
{e.preventDefault(); e.stopPropagation(); openDeleteTagDialog(tag);}} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') {e.preventDefault(); e.stopPropagation(); openDeleteTagDialog(tag);}}} + > + Delete +
{selectedTag?.id === tag.id && ( diff --git a/src/components/dashboard/subscriptions-pie-chart.tsx b/src/components/dashboard/subscriptions-pie-chart.tsx index de3c371..ec3ba48 100644 --- a/src/components/dashboard/subscriptions-pie-chart.tsx +++ b/src/components/dashboard/subscriptions-pie-chart.tsx @@ -65,8 +65,10 @@ const SubscriptionsPieChart: FC = ({ dateRangeLabel const fetchData = async () => { setIsLoading(true); try { - const prefs = await getUserPreferences(); - setPreferredCurrency(prefs.preferredCurrency); + if (typeof window !== 'undefined') { + const prefs = await getUserPreferences(); + setPreferredCurrency(prefs.preferredCurrency); + } const [subs, cats] = await Promise.all([ getSubscriptions(), getCategories(), @@ -91,18 +93,12 @@ const SubscriptionsPieChart: FC = ({ dateRangeLabel subscriptions.forEach(sub => { if (sub.type === 'expense') { - // Consider if subscriptions should be filtered by selectedDateRange - // For this chart, we'll consider all active expense subscriptions that start within or before the range end, - // and their next payment date is relevant to the period or they are ongoing. const subStartDate = parseISO(sub.startDate); - const subNextPaymentDate = parseISO(sub.nextPaymentDate); - - let isActiveInRange = true; // Default to true if no specific date range is set (e.g., "All Time") + let isActiveInRange = true; if (selectedDateRange.from && selectedDateRange.to) { - isActiveInRange = subStartDate <= selectedDateRange.to; // Starts before or during range + isActiveInRange = subStartDate <= selectedDateRange.to; } - if(isActiveInRange) { const monthlyCost = calculateMonthlyEquivalent( sub.amount, @@ -162,7 +158,7 @@ const SubscriptionsPieChart: FC = ({ dateRangeLabel return ( - Monthly Subscription Costs + Subscriptions No expense subscription data for {dateRangeLabel}. @@ -175,9 +171,9 @@ const SubscriptionsPieChart: FC = ({ dateRangeLabel return ( - Monthly Subscription Costs + Subscriptions - Total: {formatCurrency(totalMonthlySubscriptionCost, preferredCurrency, preferredCurrency, false)} for {dateRangeLabel}. + Total Monthly: {formatCurrency(totalMonthlySubscriptionCost, preferredCurrency, preferredCurrency, false)} ({dateRangeLabel}) From 05d92be081f70070d500300eee79bf10c691d852 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Wed, 21 May 2025 17:55:32 +0000 Subject: [PATCH 028/156] I see this error with the app, reported by NextJS, please fix it. The error is reported as HTML but presented visually to the user). A > before the line number in the error source usually indicates the line of interest: > Console Error: Error: set failed: value argument contains undefined in property 'users.BuiLVIXnNKZBiJYb7GD8P14suYF3.subscriptions.-OQnxcmvteAmvSQPhj2I.accountId'. Error source: src/services/subscriptions.ts (115:13) @ addSubscription > > 113 | > 114 | try { > > 115 | await set(newSubscriptionRef, dataToSave); > | ^ > 116 | return newSubscription; > 117 | } catch (error) { > 118 | console.error("Error adding subscription to Firebase:", error); > > Call Stack > 10 > > Show 7 ignore-listed frame(s) > addSubscription > src/services/subscriptions.ts (115:13) > handleSubscriptionAdded > src/app/financial-control/page.tsx (167:30) > handleFormSubmit > src/components/subscriptions/add-subscription-form.tsx (108:11) --- src/services/subscriptions.ts | 85 ++++++++++++++++++++--------------- 1 file changed, 49 insertions(+), 36 deletions(-) diff --git a/src/services/subscriptions.ts b/src/services/subscriptions.ts index 07f8528..3332f04 100644 --- a/src/services/subscriptions.ts +++ b/src/services/subscriptions.ts @@ -15,22 +15,23 @@ export interface Subscription { currency: string; type: SubscriptionType; category: string; - accountId?: string; // Optional: Link to an account - groupId: string | null; // Optional: Link to a group, can be null + accountId?: string | null; // Changed to allow null explicitly for Firebase + groupId: string | null; startDate: string; // ISO string: YYYY-MM-DD frequency: SubscriptionFrequency; nextPaymentDate: string; // ISO string: YYYY-MM-DD - notes?: string; + notes?: string | null; // Changed to allow null tags?: string[]; - lastPaidMonth?: string | null; // YYYY-MM format, or null if not paid for the current cycle - description?: string; - createdAt?: object; // For server timestamp - updatedAt?: object; // For server timestamp + lastPaidMonth?: string | null; + description?: string | null; // Changed to allow null + createdAt?: object | string; + updatedAt?: object | string; } export type NewSubscriptionData = Omit & { lastPaidMonth?: string | null; - groupId?: string | null; // Ensure groupId is optional and can be null here too + groupId?: string | null; + accountId?: string | null; // Ensure this can also be null here for consistency }; export function getSubscriptionsRefPath(currentUser: User | null) { @@ -57,23 +58,23 @@ export async function getSubscriptions(): Promise { if (snapshot.exists()) { const subscriptionsData = snapshot.val(); return Object.entries(subscriptionsData).map(([id, data]) => { - const subData = data as Partial>; // Treat as partial initially + const subData = data as Partial>; return { id, name: subData.name || 'Unnamed Subscription', amount: typeof subData.amount === 'number' ? subData.amount : 0, - currency: subData.currency || 'USD', // Default currency - type: subData.type || 'expense', // Default type + currency: subData.currency || 'USD', + type: subData.type || 'expense', category: subData.category || 'Uncategorized', - accountId: subData.accountId, - groupId: subData.groupId === undefined ? null : subData.groupId, // Ensure groupId is null if undefined - startDate: typeof subData.startDate === 'string' && subData.startDate ? subData.startDate : new Date().toISOString().split('T')[0], // Default if missing/invalid - frequency: subData.frequency || 'monthly', // Default frequency - nextPaymentDate: typeof subData.nextPaymentDate === 'string' && subData.nextPaymentDate ? subData.nextPaymentDate : new Date().toISOString().split('T')[0], // Default if missing/invalid - notes: subData.notes, + accountId: subData.accountId === undefined ? null : subData.accountId, + groupId: subData.groupId === undefined ? null : subData.groupId, + startDate: typeof subData.startDate === 'string' && subData.startDate ? subData.startDate : new Date().toISOString().split('T')[0], + frequency: subData.frequency || 'monthly', + nextPaymentDate: typeof subData.nextPaymentDate === 'string' && subData.nextPaymentDate ? subData.nextPaymentDate : new Date().toISOString().split('T')[0], + notes: subData.notes === undefined ? null : subData.notes, tags: subData.tags || [], - lastPaidMonth: subData.lastPaidMonth || null, - description: subData.description, + lastPaidMonth: subData.lastPaidMonth === undefined ? null : subData.lastPaidMonth, + description: subData.description === undefined ? null : subData.description, createdAt: subData.createdAt, updatedAt: subData.updatedAt, }; @@ -99,17 +100,34 @@ export async function addSubscription(subscriptionData: NewSubscriptionData): Pr throw new Error("Failed to generate a new subscription ID."); } - const newSubscription: Subscription = { - ...subscriptionData, - id: newSubscriptionRef.key, - lastPaidMonth: subscriptionData.lastPaidMonth || null, + // Prepare data for saving, ensuring undefined optional fields become null + const dataToSave: Omit = { + name: subscriptionData.name, + amount: subscriptionData.amount, + currency: subscriptionData.currency, + type: subscriptionData.type, + category: subscriptionData.category, + accountId: subscriptionData.accountId === undefined ? null : subscriptionData.accountId, groupId: subscriptionData.groupId === undefined || subscriptionData.groupId === "" ? null : subscriptionData.groupId, + startDate: subscriptionData.startDate, + frequency: subscriptionData.frequency, + nextPaymentDate: subscriptionData.nextPaymentDate, + notes: subscriptionData.notes === undefined ? null : subscriptionData.notes, + tags: subscriptionData.tags || [], + lastPaidMonth: subscriptionData.lastPaidMonth === undefined ? null : subscriptionData.lastPaidMonth, + description: subscriptionData.description === undefined ? null : subscriptionData.description, createdAt: serverTimestamp(), updatedAt: serverTimestamp(), }; - const dataToSave = { ...newSubscription } as any; - delete dataToSave.id; // Firebase key is the ID + const newSubscription: Subscription = { + id: newSubscriptionRef.key, + ...dataToSave, // Use the processed dataToSave which has nulls for undefined + // serverTimestamp() returns an object, which is fine for DB, but for immediate state, we might convert/approximate + createdAt: new Date().toISOString(), // Approximate for immediate use + updatedAt: new Date().toISOString(), // Approximate for immediate use + }; + try { await set(newSubscriptionRef, dataToSave); @@ -130,7 +148,6 @@ export async function updateSubscription(updatedSubscription: Subscription): Pro const subscriptionRefPath = getSingleSubscriptionRefPath(currentUser, id); const subscriptionRef = ref(database, subscriptionRefPath); - // Build the update object selectively to avoid sending undefined values const dataToUpdate: Partial> & { updatedAt: object } = { name: updatedSubscription.name, amount: updatedSubscription.amount, @@ -142,27 +159,23 @@ export async function updateSubscription(updatedSubscription: Subscription): Pro nextPaymentDate: updatedSubscription.nextPaymentDate, tags: updatedSubscription.tags || [], lastPaidMonth: updatedSubscription.lastPaidMonth === undefined ? null : updatedSubscription.lastPaidMonth, - description: updatedSubscription.description || null, // Ensure description is null if empty + description: updatedSubscription.description === undefined ? null : updatedSubscription.description, updatedAt: serverTimestamp(), + accountId: updatedSubscription.accountId === undefined ? null : updatedSubscription.accountId, + groupId: updatedSubscription.groupId === undefined || updatedSubscription.groupId === "" ? null : updatedSubscription.groupId, + notes: updatedSubscription.notes === undefined ? null : updatedSubscription.notes, }; - // Handle optional fields correctly, ensuring they are set to null if not provided - // to remove them from Firebase if they were previously set. - dataToUpdate.accountId = updatedSubscription.accountId || null; // set to null if undefined or empty - dataToUpdate.groupId = updatedSubscription.groupId === undefined || updatedSubscription.groupId === "" ? null : updatedSubscription.groupId; - dataToUpdate.notes = updatedSubscription.notes || null; - - try { await update(subscriptionRef, dataToUpdate); - // Return a consistent object reflecting what was attempted to be saved return { - ...updatedSubscription, + ...updatedSubscription, // Start with the input data accountId: dataToUpdate.accountId === null ? undefined : dataToUpdate.accountId, groupId: dataToUpdate.groupId, notes: dataToUpdate.notes === null ? undefined : dataToUpdate.notes, description: dataToUpdate.description === null ? undefined : dataToUpdate.description, lastPaidMonth: dataToUpdate.lastPaidMonth, + updatedAt: new Date().toISOString(), // Approximate for local state }; } catch (error) { console.error("Error updating subscription in Firebase:", error); From ecb3d6ec2347379d387d1ef50eb868cb60bf371e Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Wed, 21 May 2025 18:03:41 +0000 Subject: [PATCH 029/156] Otimo, agora um ultimo ajuste, eu so consigo acessar as minhas transacoes nesse navegador, acho que elas estao sendo armazenadas no local Storage. Mas sao somente as Transacoes, os valores das contas e a aba Financial Control esta puxando em todas, vamos corrigir apenas o salvamento das TRANSACOES --- src/services/transactions.tsx | 190 ++++++++++++++-------------------- 1 file changed, 78 insertions(+), 112 deletions(-) diff --git a/src/services/transactions.tsx b/src/services/transactions.tsx index c274868..2f40e50 100644 --- a/src/services/transactions.tsx +++ b/src/services/transactions.tsx @@ -2,7 +2,7 @@ 'use client'; import { database, auth } from '@/lib/firebase'; -import { ref, set, get, push, remove, update, serverTimestamp } from 'firebase/database'; +import { ref, set, get, push, remove, update, serverTimestamp, query, orderByChild, limitToLast } from 'firebase/database'; import type { User } from 'firebase/auth'; import { getAccounts as getAllAccounts, updateAccount as updateAccountInDb, type Account } from './account-sync'; import { convertCurrency } from '@/lib/currency'; @@ -59,12 +59,11 @@ function getSingleTransactionRefPath(currentUser: User | null, accountId: string } async function modifyAccountBalance(accountId: string, amountInTransactionCurrency: number, transactionCurrency: string, operation: 'add' | 'subtract') { - const accounts = await getAllAccounts(); // This fetches from Firebase/localStorage as per account-sync + const accounts = await getAllAccounts(); const accountToUpdate = accounts.find(acc => acc.id === accountId); if (accountToUpdate) { let amountInAccountCurrency = amountInTransactionCurrency; - // Ensure both currencies are defined and not empty strings before attempting conversion if (transactionCurrency && accountToUpdate.currency && transactionCurrency.toUpperCase() !== accountToUpdate.currency.toUpperCase()) { amountInAccountCurrency = convertCurrency( amountInTransactionCurrency, @@ -75,72 +74,65 @@ async function modifyAccountBalance(accountId: string, amountInTransactionCurren const balanceChange = operation === 'add' ? amountInAccountCurrency : -amountInAccountCurrency; const newBalance = parseFloat((accountToUpdate.balance + balanceChange).toFixed(2)); - await updateAccountInDb({ // This updates Firebase/localStorage as per account-sync + await updateAccountInDb({ ...accountToUpdate, balance: newBalance, lastActivity: new Date().toISOString(), }); - // console.log(`Account ${accountToUpdate.name} balance updated by ${balanceChange} ${accountToUpdate.currency}. New balance: ${newBalance}`); } else { console.warn(`Account ID ${accountId} not found for balance update.`); } } -async function _getTransactionsFromLocalStorage(accountId: string): Promise { - const currentUser = auth.currentUser; - if (!currentUser?.uid) return []; - const key = `transactions-${accountId}-${currentUser.uid}`; - const data = localStorage.getItem(key); - try { - return data ? JSON.parse(data) : []; - } catch (e) { - console.error("Error parsing transactions from localStorage for account:", accountId, e); - return []; - } -} - -async function _saveTransactionsToLocalStorage(accountId: string, transactions: Transaction[]): Promise { - const currentUser = auth.currentUser; - if (!currentUser?.uid) return; - const key = `transactions-${accountId}-${currentUser.uid}`; - localStorage.setItem(key, JSON.stringify(transactions)); -} - export async function getTransactions( accountId: string, options?: { limit?: number } ): Promise { const currentUser = auth.currentUser; - if (!currentUser?.uid) { + if (!currentUser?.uid) { console.warn("getTransactions called without authenticated user. Returning empty array."); return []; } - const storageKey = `transactions-${accountId}-${currentUser.uid}`; - const data = localStorage.getItem(storageKey); - - if (data) { - try { - const allAppAccounts = await getAllAccounts(); - const transactionsArray = (JSON.parse(data) as Transaction[]) - .map(tx => ({ - ...tx, - tags: tx.tags || [], - category: tx.category || 'Uncategorized', - transactionCurrency: tx.transactionCurrency || allAppAccounts.find(a=>a.id === tx.accountId)?.currency || 'USD' - })) - .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); - - if (options?.limit && options.limit > 0) { - return transactionsArray.slice(0, options.limit); - } - return transactionsArray; - } catch(e) { - console.error("Error parsing transactions from localStorage during getTransactions for account:", accountId, e); - localStorage.removeItem(storageKey); - return []; - } + const transactionsRefPath = getTransactionsRefPath(currentUser, accountId); + const accountTransactionsRef = ref(database, transactionsRefPath); + + try { + const dataQuery = options?.limit && options.limit > 0 + ? query(accountTransactionsRef, orderByChild('date'), limitToLast(options.limit)) + : query(accountTransactionsRef, orderByChild('date')); + + const snapshot = await get(dataQuery); + if (snapshot.exists()) { + const transactionsData = snapshot.val(); + const allAppAccounts = await getAllAccounts(); // Needed for fallback currency + const transactionsArray = Object.entries(transactionsData) + .map(([id, data]) => { + const txData = data as Omit; + return { + id, + ...txData, + tags: txData.tags || [], + category: txData.category || 'Uncategorized', + transactionCurrency: txData.transactionCurrency || allAppAccounts.find(a => a.id === txData.accountId)?.currency || 'USD', + // Convert Firebase server timestamps to ISO strings if they exist + createdAt: txData.createdAt && typeof txData.createdAt === 'object' ? new Date().toISOString() : txData.createdAt as string, + updatedAt: txData.updatedAt && typeof txData.updatedAt === 'object' ? new Date().toISOString() : txData.updatedAt as string, + }; + }) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); // Sort descending by date + + // If a limit was applied by Firebase, it's already handled. + // If no limit option or limit was larger than actual data, slice here if needed (though Firebase limit is more efficient) + // This client-side slice is mainly for consistency if options.limit was used but Firebase query didn't support it as expected. + return options?.limit && options.limit > 0 && transactionsArray.length > options.limit + ? transactionsArray.slice(0, options.limit) + : transactionsArray; + } + return []; + } catch (error) { + console.error("Error fetching transactions from Firebase:", error); + throw error; // Re-throw other errors } - return []; } export async function addTransaction( @@ -149,6 +141,9 @@ export async function addTransaction( ): Promise { const currentUser = auth.currentUser; const { accountId, amount, transactionCurrency, category } = transactionData; + if (!currentUser?.uid) { + throw new Error("User not authenticated. Cannot add transaction."); + } const transactionsRefPath = getTransactionsRefPath(currentUser, accountId); const accountTransactionsRef = ref(database, transactionsRefPath); const newTransactionRef = push(accountTransactionsRef); @@ -171,21 +166,15 @@ export async function addTransaction( }; const dataToSave = { ...newTransaction } as any; - delete dataToSave.id; + delete dataToSave.id; // Firebase key is the ID try { await set(newTransactionRef, dataToSave); - // Conditionally modify balance if (category?.toLowerCase() !== 'opening balance' && !options?.skipBalanceModification) { await modifyAccountBalance(accountId, amount, transactionCurrency, 'add'); } - - const storedTransactions = await _getTransactionsFromLocalStorage(accountId); - const newTxForStorage = { ...newTransaction, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; - storedTransactions.push(newTxForStorage); - await _saveTransactionsToLocalStorage(accountId, storedTransactions); - - return newTransaction; + // Return the transaction with a client-side timestamp approximation for immediate UI use + return { ...newTransaction, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString() }; } catch (error) { console.error("Error adding transaction to Firebase:", error); throw error; @@ -194,7 +183,10 @@ export async function addTransaction( export async function updateTransaction(updatedTransaction: Transaction): Promise { const currentUser = auth.currentUser; - const { id, accountId, amount, transactionCurrency } = updatedTransaction; + const { id, accountId, amount, transactionCurrency, category } = updatedTransaction; + if (!currentUser?.uid) { + throw new Error("User not authenticated. Cannot update transaction."); + } const transactionRefPath = getSingleTransactionRefPath(currentUser, accountId, id); const transactionRef = ref(database, transactionRefPath); @@ -220,30 +212,15 @@ export async function updateTransaction(updatedTransaction: Transaction): Promis try { await update(transactionRef, dataToUpdateFirebase); - await modifyAccountBalance(accountId, originalTransactionDataFromDB.amount, originalDbTxCurrency, 'subtract'); - await modifyAccountBalance(accountId, amount, transactionCurrency, 'add'); - - const storedTransactions = await _getTransactionsFromLocalStorage(accountId); - const transactionIndex = storedTransactions.findIndex(t => t.id === id); - if (transactionIndex !== -1) { - const originalStoredCreatedAt = storedTransactions[transactionIndex].createdAt; - storedTransactions[transactionIndex] = { - ...updatedTransaction, - createdAt: updatedTransaction.createdAt || originalStoredCreatedAt || new Date().toISOString(), - updatedAt: new Date().toISOString(), - }; - await _saveTransactionsToLocalStorage(accountId, storedTransactions); - } else { - console.warn(`Transaction ${id} updated in DB but not found in localStorage cache for account ${accountId}. Adding it.`); - storedTransactions.push({ - ...updatedTransaction, - createdAt: updatedTransaction.createdAt || new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - await _saveTransactionsToLocalStorage(accountId, storedTransactions); + if (category?.toLowerCase() !== 'opening balance') { + await modifyAccountBalance(accountId, originalTransactionDataFromDB.amount, originalDbTxCurrency, 'subtract'); + await modifyAccountBalance(accountId, amount, transactionCurrency, 'add'); } - return updatedTransaction; + return { + ...updatedTransaction, + updatedAt: new Date().toISOString(), // Approximate for local state + }; } catch (error) { console.error("Error updating transaction in Firebase:", error); throw error; @@ -252,6 +229,9 @@ export async function updateTransaction(updatedTransaction: Transaction): Promis export async function deleteTransaction(transactionId: string, accountId: string): Promise { const currentUser = auth.currentUser; + if (!currentUser?.uid) { + throw new Error("User not authenticated. Cannot delete transaction."); + } const transactionRefPath = getSingleTransactionRefPath(currentUser, accountId, transactionId); const transactionRef = ref(database, transactionRefPath); @@ -266,12 +246,9 @@ export async function deleteTransaction(transactionId: string, accountId: string try { await remove(transactionRef); - await modifyAccountBalance(accountId, transactionToDelete.amount, txCurrency, 'subtract'); - - let storedTransactions = await _getTransactionsFromLocalStorage(accountId); - storedTransactions = storedTransactions.filter(t => t.id !== transactionId); - await _saveTransactionsToLocalStorage(accountId, storedTransactions); - + if (transactionToDelete.category?.toLowerCase() !== 'opening balance') { + await modifyAccountBalance(accountId, transactionToDelete.amount, txCurrency, 'subtract'); + } } catch (error) { console.error("Error deleting transaction from Firebase:", error); throw error; @@ -287,35 +264,23 @@ export async function clearAllSessionTransactions(): Promise { console.warn("Attempting to clear ALL user data for user:", currentUser.uid); try { - const userFirebaseTransactionsBasePath = `users/${currentUser.uid}/transactions`; - const categoriesPath = getCategoriesRefPath(currentUser); - const tagsPath = getTagsRefPath(currentUser); - const groupsPath = getGroupsRefPath(currentUser); - const subscriptionsPath = getSubscriptionsRefPath(currentUser); - const loansPath = getLoansRefPath(currentUser); - const creditCardsPath = getCreditCardsRefPath(currentUser); - const budgetsPath = getBudgetsRefPath(currentUser); - const accountsPath = `users/${currentUser.uid}/accounts`; - - await Promise.all([ - remove(ref(database, userFirebaseTransactionsBasePath)), - remove(ref(database, categoriesPath)), - remove(ref(database, tagsPath)), - remove(ref(database, groupsPath)), - remove(ref(database, subscriptionsPath)), - remove(ref(database, loansPath)), - remove(ref(database, creditCardsPath)), - remove(ref(database, budgetsPath)), - remove(ref(database, accountsPath)) - ]); + const userRootPath = `users/${currentUser.uid}`; + await remove(ref(database, userRootPath)); // This removes all data under users/{uid} + // Clear related localStorage items (though Firebase is now primary, good for cleanup) const allKeys = Object.keys(localStorage); allKeys.forEach(key => { if (key.startsWith(`transactions-`) && key.endsWith(`-${currentUser.uid}`)) { localStorage.removeItem(key); } + if (key.startsWith(`user`) && key.endsWith(`-${currentUser.uid}`)) { + localStorage.removeItem(key); + } + if (key === `userPreferences-${currentUser.uid}`) { // From AuthContext for theme/currency + localStorage.removeItem(key); + } }); - + // Remove other potential user-specific keys if they exist localStorage.removeItem(`userAccounts-${currentUser.uid}`); localStorage.removeItem(`userCategories-${currentUser.uid}`); localStorage.removeItem(`userTags-${currentUser.uid}`); @@ -324,7 +289,7 @@ export async function clearAllSessionTransactions(): Promise { localStorage.removeItem(`userLoans-${currentUser.uid}`); localStorage.removeItem(`userCreditCards-${currentUser.uid}`); localStorage.removeItem(`userBudgets-${currentUser.uid}`); - localStorage.removeItem(`userPreferences-${currentUser.uid}`); + console.log("All user data cleared from Firebase and localStorage for user:", currentUser.uid); } catch (error) { @@ -332,3 +297,4 @@ export async function clearAllSessionTransactions(): Promise { throw error; } } + \ No newline at end of file From 6116ccc078a900c9add2823f3e551a87f51c1e64 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Wed, 21 May 2025 18:12:17 +0000 Subject: [PATCH 030/156] Voce consegue criar uma landing page de apresentacao do nosso app? --- src/app/welcome/page.tsx | 202 +++++++++++++++++++++++++ src/components/layout/auth-wrapper.tsx | 79 +++++----- 2 files changed, 243 insertions(+), 38 deletions(-) create mode 100644 src/app/welcome/page.tsx diff --git a/src/app/welcome/page.tsx b/src/app/welcome/page.tsx new file mode 100644 index 0000000..16da555 --- /dev/null +++ b/src/app/welcome/page.tsx @@ -0,0 +1,202 @@ + +'use client'; + +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { BarChart2, CheckCircle, ShieldCheck, Users, Database, Goal, TrendingUp, Palette, Zap } from 'lucide-react'; + +const LogoIcon = () => ( + + + + + + + + + + + +); + + +export default function WelcomePage() { + return ( +
+ {/* Header */} +
+
+ +
+
+ + {/* Hero Section */} +
+
+
+
+ +
+

+ Master Your Finances with The Golden Game +

+

+ The all-in-one platform to track accounts, manage expenses, plan budgets, control subscriptions, and achieve your financial goals. Simple, intuitive, and powerful. +

+ + + +
+
+ + {/* Features Section */} +
+
+
+

Everything You Need in One Place

+

Discover the tools that will transform how you manage your money.

+
+
+ } + title="Comprehensive Tracking" + description="Monitor all your accounts, transactions, income, and expenses with insightful charts and reports." + /> + } + title="Smart Budgeting" + description="Create custom budgets for categories or groups, track your spending, and stay on top of your financial goals." + /> + } + title="Secure & Private" + description="Your financial data is important. We prioritize security with Firebase, ensuring your information is safe." + /> + } + title="Subscription Management" + description="Never lose track of recurring payments. Manage all your subscriptions and see their impact on your cash flow." + /> + } + title="Data Import & Export" + description="Easily import data from other services or export your GoldQuest data for backup and peace of mind." + /> + } + title="Achieve Your Targets" + description="Set financial targets, track your progress, and make informed decisions to reach your aspirations." + /> +
+
+
+ + {/* How it Works Section - Placeholder */} +
+
+

Simple to Start, Powerful to Use

+

+ Get up and running in minutes. The Golden Game is designed for clarity and ease of use. +

+
+ } /> + } /> + } /> +
+
+
+ + + {/* Call to Action Section */} +
+
+

Ready to Win The Golden Game of Finance?

+

+ Take the first step towards financial freedom. It's free to get started! +

+ + + +
+
+
+ + {/* Footer */} +
+
+
+ + The Golden Game +
+

© {new Date().getFullYear()} The Golden Game. All rights reserved.

+

Your Journey to Financial Mastery Starts Here.

+
+
+
+ ); +} + +interface FeatureCardProps { + icon: React.ReactNode; + title: string; + description: string; +} + +function FeatureCard({ icon, title, description }: FeatureCardProps) { + return ( + + +
+ {icon} +
+ {title} +
+ +

{description}

+
+
+ ); +} + +interface StepCardProps { + number: string; + title: string; + description: string; + icon: React.ReactNode; +} + +function StepCard({ number, title, description, icon }: StepCardProps) { + return ( +
+
+
+ {icon} +
+

{number}. {title}

+
+

{description}

+
+ ); +} + diff --git a/src/components/layout/auth-wrapper.tsx b/src/components/layout/auth-wrapper.tsx index e547c86..e6daca0 100644 --- a/src/components/layout/auth-wrapper.tsx +++ b/src/components/layout/auth-wrapper.tsx @@ -18,7 +18,7 @@ import { SidebarGroupLabel, } from '@/components/ui/sidebar'; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Landmark, Wallet, ArrowLeftRight, Settings, ChevronDown, TrendingUp, TrendingDown, LayoutList, Users, LogOut, Network, PieChart, Database, SlidersHorizontal, FileText } from 'lucide-react'; // Added Database +import { Landmark, Wallet, ArrowLeftRight, Settings, ChevronDown, TrendingUp, TrendingDown, LayoutList, Users, LogOut, Network, PieChart, Database, SlidersHorizontal, FileText, ArchiveIcon } from 'lucide-react'; import Link from 'next/link'; import { useRouter, usePathname } from 'next/navigation'; import { useState, useEffect } from 'react'; @@ -36,15 +36,15 @@ const LogoIcon = () => ( xmlns="http://www.w3.org/2000/svg" className="mr-2 text-primary" > - - - - - - - - - + + + + + + + + + ); @@ -114,7 +114,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { const hasLoggedInBefore = localStorage.getItem(firstLoginFlagKey); const preferencesLoadedAndThemeSet = userPreferences && userPreferences.theme; - if (!hasLoggedInBefore && !preferencesLoadedAndThemeSet && pathname !== '/preferences') { + if (!hasLoggedInBefore && !preferencesLoadedAndThemeSet && pathname !== '/preferences' && pathname !== '/welcome') { localStorage.setItem(firstLoginFlagKey, 'true'); router.push('/preferences'); } @@ -138,7 +138,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { } if (firebaseError && !isFirebaseActive) { - if (pathname !== '/login' && pathname !== '/signup') { + if (pathname !== '/login' && pathname !== '/signup' && pathname !== '/welcome') { router.push('/login'); return
Firebase not available. Redirecting...
; } @@ -146,22 +146,22 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { if (!isAuthenticated && isFirebaseActive) { - if (pathname !== '/signup' && pathname !== '/login') { + if (pathname !== '/signup' && pathname !== '/login' && pathname !== '/welcome') { return ; } - return <>{children}; + return <>{children}; } - if(!isFirebaseActive && pathname !== '/login' && pathname !== '/signup') { + if(!isFirebaseActive && pathname !== '/login' && pathname !== '/signup' && pathname !== '/welcome') { router.push('/login'); return
Redirecting to login...
; } - if (isAuthenticated || (!isFirebaseActive && (pathname === '/login' || pathname === '/signup'))) { - if (!isAuthenticated && (pathname !== '/login' && pathname !== '/signup')) { + if (isAuthenticated || (!isFirebaseActive && (pathname === '/login' || pathname === '/signup' || pathname === '/welcome'))) { + if (!isAuthenticated && (pathname !== '/login' && pathname !== '/signup' && pathname !== '/welcome')) { return ; } - if (pathname === '/login' || pathname === '/signup') { + if (pathname === '/login' || pathname === '/signup' || pathname === '/welcome') { return <>{children}; } @@ -172,7 +172,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) {
- GoldQuest + The Golden Game
@@ -187,9 +187,8 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { - - + @@ -197,6 +196,8 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { + + @@ -329,24 +330,26 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { ); } + // Fallback for scenarios where nothing else matches (e.g., during extreme initial loading before isClient is true) + // Or if AuthWrapper logic leads to an unhandled state. return ( - - - - - The Golden Game - - - -
Preparing application...
- + + + + + The Golden Game + + + +
Preparing application...
+ ); } + From ce1bc2e82a0f97e9685f9aa93e6a2edf6ac7488b Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Wed, 21 May 2025 18:15:45 +0000 Subject: [PATCH 031/156] Na verdade o nome do app sera GoldQuest e faca ela mais remetendo ao jogo, meio que um game para a freedom --- src/app/layout.tsx | 17 +++---- src/app/login/page.tsx | 1 - src/app/signup/page.tsx | 7 ++- src/app/welcome/page.tsx | 70 +++++++++++++------------- src/components/layout/auth-wrapper.tsx | 11 ++-- 5 files changed, 49 insertions(+), 57 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index a919488..0fdaec0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,8 +4,8 @@ import { Oxanium } from 'next/font/google'; import './globals.css'; import { cn } from '@/lib/utils'; import { Toaster } from '@/components/ui/toaster'; -import AuthWrapper from '@/components/layout/auth-wrapper'; // This component handles theme application -import { AuthProvider } from '@/contexts/AuthContext'; // This provides the theme state +import AuthWrapper from '@/components/layout/auth-wrapper'; +import { AuthProvider } from '@/contexts/AuthContext'; const oxanium = Oxanium({ variable: '--font-oxanium', @@ -13,8 +13,8 @@ const oxanium = Oxanium({ }); export const metadata: Metadata = { - title: 'GoldQuest', - description: 'Simple personal finance management', + title: 'GoldQuest - Your Financial Adventure', + description: 'Embark on your GoldQuest to master personal finances, track investments, and achieve your financial goals.', }; export default function RootLayout({ @@ -31,14 +31,13 @@ export default function RootLayout({ - {/* AuthProvider provides theme via AuthContext */} - {children} {/* AuthWrapper applies theme to */} + + {children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index cd8570f..472a8fc 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -126,4 +126,3 @@ export default function LoginPage() {
); } - diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index b215f28..ff47d9c 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -48,8 +48,8 @@ export default function SignupPage() {
- Create Account - Enter your email and password to sign up for GoldQuest. + Create GoldQuest Account + Enter your email and password to start your GoldQuest.
@@ -90,7 +90,7 @@ export default function SignupPage() { Already have an account? Login @@ -101,4 +101,3 @@ export default function SignupPage() {
); } - diff --git a/src/app/welcome/page.tsx b/src/app/welcome/page.tsx index 16da555..a1f7c63 100644 --- a/src/app/welcome/page.tsx +++ b/src/app/welcome/page.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { BarChart2, CheckCircle, ShieldCheck, Users, Database, Goal, TrendingUp, Palette, Zap } from 'lucide-react'; +import { BarChart2, CheckCircle, ShieldCheck, Users, Database, Goal, TrendingUp, Palette, Zap, Map, Treasure, Shield, BookOpen } from 'lucide-react'; const LogoIcon = () => ( - The Golden Game + GoldQuest
@@ -59,13 +59,13 @@ export default function WelcomePage() {

- Master Your Finances with The Golden Game + Embark on Your GoldQuest to Financial Freedom!

- The all-in-one platform to track accounts, manage expenses, plan budgets, control subscriptions, and achieve your financial goals. Simple, intuitive, and powerful. + Your all-in-one treasure map to track accounts, conquer expenses, forge budgets, and achieve legendary financial goals. Simple, intuitive, and empowering.

- +
@@ -74,55 +74,54 @@ export default function WelcomePage() {
-

Everything You Need in One Place

-

Discover the tools that will transform how you manage your money.

+

Your Arsenal for the Financial Frontier

+

Discover the legendary tools that will transform your wealth-building journey.

} - title="Comprehensive Tracking" - description="Monitor all your accounts, transactions, income, and expenses with insightful charts and reports." + icon={} + title="Chart Your Financial Map" + description="Navigate all your accounts, transactions, income, and expenses with insightful charts and reports. Know where your gold flows." /> } - title="Smart Budgeting" - description="Create custom budgets for categories or groups, track your spending, and stay on top of your financial goals." + icon={} + title="Forge Powerful Budgets" + description="Craft custom budgets for categories or groups, track your spending, and stay on course to conquer your financial milestones." /> } - title="Secure & Private" - description="Your financial data is important. We prioritize security with Firebase, ensuring your information is safe." + icon={} + title="Fortified & Private Vault" + description="Your financial treasures are sacred. We prioritize security with Firebase, ensuring your information is heavily guarded." /> } - title="Subscription Management" - description="Never lose track of recurring payments. Manage all your subscriptions and see their impact on your cash flow." + title="Master Your Guild Subscriptions" + description="Never lose track of recurring tributes. Manage all your subscriptions and see their impact on your treasure chest." /> } - title="Data Import & Export" - description="Easily import data from other services or export your GoldQuest data for backup and peace of mind." + title="Ancient Scrolls: Import & Export" + description="Easily import lore from other realms (services) or export your GoldQuest saga for backup and peace of mind." /> } - title="Achieve Your Targets" - description="Set financial targets, track your progress, and make informed decisions to reach your aspirations." + icon={} + title="Claim Your Financial Destiny" + description="Set legendary targets, track your epic progress, and make wise decisions to reach your financial aspirations." />
- {/* How it Works Section - Placeholder */}
-

Simple to Start, Powerful to Use

+

Begin Your Adventure in Minutes

- Get up and running in minutes. The Golden Game is designed for clarity and ease of use. + GoldQuest is designed for clarity and ease of use. Your path to financial mastery is clear.

- } /> - } /> - } /> + } /> + } /> + } />
@@ -131,12 +130,12 @@ export default function WelcomePage() { {/* Call to Action Section */}
-

Ready to Win The Golden Game of Finance?

+

Ready to Claim Your Financial Riches?

- Take the first step towards financial freedom. It's free to get started! + Take the first step on your GoldQuest. It's free to start your epic journey!

- +
@@ -147,10 +146,10 @@ export default function WelcomePage() {
- The Golden Game + GoldQuest
-

© {new Date().getFullYear()} The Golden Game. All rights reserved.

-

Your Journey to Financial Mastery Starts Here.

+

© {new Date().getFullYear()} GoldQuest. All rights reserved.

+

Your Epic Journey to Financial Mastery Starts Here.

@@ -199,4 +198,3 @@ function StepCard({ number, title, description, icon }: StepCardProps) {
); } - diff --git a/src/components/layout/auth-wrapper.tsx b/src/components/layout/auth-wrapper.tsx index e6daca0..4389f62 100644 --- a/src/components/layout/auth-wrapper.tsx +++ b/src/components/layout/auth-wrapper.tsx @@ -18,7 +18,7 @@ import { SidebarGroupLabel, } from '@/components/ui/sidebar'; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Landmark, Wallet, ArrowLeftRight, Settings, ChevronDown, TrendingUp, TrendingDown, LayoutList, Users, LogOut, Network, PieChart, Database, SlidersHorizontal, FileText, ArchiveIcon } from 'lucide-react'; +import { Landmark, Wallet, ArrowLeftRight, Settings, ChevronDown, TrendingUp, TrendingDown, LayoutList, Users, LogOut, Network, PieChart, Database, SlidersHorizontal, FileText, ArchiveIcon, MapIcon } from 'lucide-react'; import Link from 'next/link'; import { useRouter, usePathname } from 'next/navigation'; import { useState, useEffect } from 'react'; @@ -172,7 +172,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) {
- The Golden Game + GoldQuest
@@ -330,15 +330,13 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { ); } - // Fallback for scenarios where nothing else matches (e.g., during extreme initial loading before isClient is true) - // Or if AuthWrapper logic leads to an unhandled state. return ( - The Golden Game - + GoldQuest + ); } - From da60ce5cc1b9629f958d1f30e57848943b1905a8 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Wed, 21 May 2025 18:19:01 +0000 Subject: [PATCH 032/156] I see this error with the app, reported by NextJS, please fix it. The error is reported as HTML but presented visually to the user). A > before the line number in the error source usually indicates the line of interest: > ./src/app/welcome/page.tsx:33:6 > Parsing ecmascript source code failed > 31 | export default function WelcomePage() { > 32 | return ( > > 33 |
> | ^^^ > 34 | {/* Header */} > 35 |
> 36 |
> > Unexpected token `div`. Expected jsx identifier > > --- src/app/welcome/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/welcome/page.tsx b/src/app/welcome/page.tsx index a1f7c63..556d1a7 100644 --- a/src/app/welcome/page.tsx +++ b/src/app/welcome/page.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { BarChart2, CheckCircle, ShieldCheck, Users, Database, Goal, TrendingUp, Palette, Zap, Map, Treasure, Shield, BookOpen } from 'lucide-react'; +import { BarChart2, CheckCircle, ShieldCheck, Users, Database, Goal, TrendingUp, Palette, Zap, Map, Gem, Shield, BookOpen } from 'lucide-react'; // Changed Treasure to Gem const LogoIcon = () => ( } + icon={} {/* Changed Treasure to Gem */} title="Claim Your Financial Destiny" description="Set legendary targets, track your epic progress, and make wise decisions to reach your financial aspirations." /> From 73d91a45a1dd5f4a8c29dc574b45a78ac9b7e04d Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Wed, 21 May 2025 18:24:49 +0000 Subject: [PATCH 033/156] I see this error with the app, reported by NextJS, please fix it. The error is reported as HTML but presented visually to the user). A > before the line number in the error source usually indicates the line of interest: > ./src/app/welcome/page.tsx:33:6 > Parsing ecmascript source code failed > 31 | export default function WelcomePage() { > 32 | return ( > > 33 |
> | ^^^ > 34 | {/* Header */} > 35 |
> 36 |
> > Unexpected token `div`. Expected jsx identifier > > --- src/app/welcome/page.tsx | 91 ++++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 46 deletions(-) diff --git a/src/app/welcome/page.tsx b/src/app/welcome/page.tsx index 556d1a7..6bae0ca 100644 --- a/src/app/welcome/page.tsx +++ b/src/app/welcome/page.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { BarChart2, CheckCircle, ShieldCheck, Users, Database, Goal, TrendingUp, Palette, Zap, Map, Gem, Shield, BookOpen } from 'lucide-react'; // Changed Treasure to Gem +import { BarChart2, Users, Database, Goal, Map, Gem, Shield, BookOpen, Zap, TrendingUp } from 'lucide-react'; // Removed Palette as it's not used, kept others const LogoIcon = () => ( ( ); +interface FeatureCardProps { + icon: React.ReactNode; + title: string; + description: string; +} + +function FeatureCard({ icon, title, description }: FeatureCardProps) { + return ( + + +
+ {icon} +
+ {title} +
+ +

{description}

+
+
+ ); +} + +interface StepCardProps { + number: string; + title: string; + description: string; + icon: React.ReactNode; +} + +function StepCard({ number, title, description, icon }: StepCardProps) { + return ( +
+
+
+ {icon} +
+

{number}. {title}

+
+

{description}

+
+ ); +} export default function WelcomePage() { return ( @@ -104,7 +146,7 @@ export default function WelcomePage() { description="Easily import lore from other realms (services) or export your GoldQuest saga for backup and peace of mind." /> } {/* Changed Treasure to Gem */} + icon={} title="Claim Your Financial Destiny" description="Set legendary targets, track your epic progress, and make wise decisions to reach your financial aspirations." /> @@ -126,7 +168,6 @@ export default function WelcomePage() {
- {/* Call to Action Section */}
@@ -155,46 +196,4 @@ export default function WelcomePage() {
); } - -interface FeatureCardProps { - icon: React.ReactNode; - title: string; - description: string; -} - -function FeatureCard({ icon, title, description }: FeatureCardProps) { - return ( - - -
- {icon} -
- {title} -
- -

{description}

-
-
- ); -} - -interface StepCardProps { - number: string; - title: string; - description: string; - icon: React.ReactNode; -} - -function StepCard({ number, title, description, icon }: StepCardProps) { - return ( -
-
-
- {icon} -
-

{number}. {title}

-
-

{description}

-
- ); -} + \ No newline at end of file From 40faec1fce12081efd5ed73d1b1480736cc141b0 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Wed, 21 May 2025 18:26:58 +0000 Subject: [PATCH 034/156] Ai ficou demais o texto, muito gamificado kkkk. Faca mais sutil ainda e um app para adultos --- src/app/welcome/page.tsx | 56 ++++++++++++++++++++-------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/app/welcome/page.tsx b/src/app/welcome/page.tsx index 6bae0ca..f50d4bf 100644 --- a/src/app/welcome/page.tsx +++ b/src/app/welcome/page.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { BarChart2, Users, Database, Goal, Map, Gem, Shield, BookOpen, Zap, TrendingUp } from 'lucide-react'; // Removed Palette as it's not used, kept others +import { BarChart2, Users, Database, Goal, Map, Gem, Shield, BookOpen, Zap, TrendingUp } from 'lucide-react'; const LogoIcon = () => (

- Embark on Your GoldQuest to Financial Freedom! + Take Control of Your Finances with GoldQuest

- Your all-in-one treasure map to track accounts, conquer expenses, forge budgets, and achieve legendary financial goals. Simple, intuitive, and empowering. + Your comprehensive solution for tracking accounts, managing expenses, creating budgets, and achieving your financial goals. Simple, intuitive, and empowering.

- +
@@ -116,39 +116,39 @@ export default function WelcomePage() {
-

Your Arsenal for the Financial Frontier

-

Discover the legendary tools that will transform your wealth-building journey.

+

Key Features for Your Financial Success

+

Discover the tools designed to help you manage your finances effectively.

} - title="Chart Your Financial Map" - description="Navigate all your accounts, transactions, income, and expenses with insightful charts and reports. Know where your gold flows." + title="Comprehensive Financial Overview" + description="Navigate all your accounts, transactions, income, and expenses with insightful charts and reports. Understand your financial landscape." /> } - title="Forge Powerful Budgets" - description="Craft custom budgets for categories or groups, track your spending, and stay on course to conquer your financial milestones." + title="Effective Budget Management" + description="Craft custom budgets for categories or groups, track your spending, and stay on course to achieve your financial milestones." /> } - title="Fortified & Private Vault" - description="Your financial treasures are sacred. We prioritize security with Firebase, ensuring your information is heavily guarded." + title="Secure & Private Data" + description="Your financial information is important. We prioritize security with Firebase, ensuring your data is well-protected." /> } - title="Master Your Guild Subscriptions" - description="Never lose track of recurring tributes. Manage all your subscriptions and see their impact on your treasure chest." + title="Subscription Management" + description="Keep track of all your recurring payments and understand their impact on your cash flow." /> } - title="Ancient Scrolls: Import & Export" - description="Easily import lore from other realms (services) or export your GoldQuest saga for backup and peace of mind." + title="Data Import & Export" + description="Easily import data from other services or export your GoldQuest data for backup and peace of mind." /> } - title="Claim Your Financial Destiny" - description="Set legendary targets, track your epic progress, and make wise decisions to reach your financial aspirations." + title="Achieve Your Financial Goals" + description="Set clear financial targets, track your progress, and make informed decisions to reach your aspirations." />
@@ -156,14 +156,14 @@ export default function WelcomePage() {
-

Begin Your Adventure in Minutes

+

Get Started in Minutes

- GoldQuest is designed for clarity and ease of use. Your path to financial mastery is clear. + GoldQuest is designed for clarity and ease of use. Your path to financial clarity is straightforward.

- } /> - } /> - } /> + } /> + } /> + } />
@@ -171,12 +171,12 @@ export default function WelcomePage() { {/* Call to Action Section */}
-

Ready to Claim Your Financial Riches?

+

Ready to Improve Your Financial Health?

- Take the first step on your GoldQuest. It's free to start your epic journey! + Take the first step with GoldQuest. It's free to start.

- +
@@ -190,10 +190,10 @@ export default function WelcomePage() { GoldQuest

© {new Date().getFullYear()} GoldQuest. All rights reserved.

-

Your Epic Journey to Financial Mastery Starts Here.

+

Your Journey to Financial Clarity Starts Here.

); } - \ No newline at end of file + From 84fa38dfca86bbb14fb4010b1e2da0946205b2ed Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Wed, 21 May 2025 19:04:11 +0000 Subject: [PATCH 035/156] Ok, pode aplicar --- src/app/accounts/[accountId]/page.tsx | 35 ++++-- src/app/accounts/page.tsx | 76 ++++++------ src/app/categories/[categoryId]/page.tsx | 44 ++++--- src/app/dashboard/page.tsx | 34 ++++-- src/app/investments/page.tsx | 145 ++++++++++++----------- src/app/organization/page.tsx | 34 ++++-- src/app/tags/[tagId]/page.tsx | 44 ++++--- src/app/transfers/page.tsx | 49 ++++---- 8 files changed, 259 insertions(+), 202 deletions(-) diff --git a/src/app/accounts/[accountId]/page.tsx b/src/app/accounts/[accountId]/page.tsx index fb40e27..9da0a28 100644 --- a/src/app/accounts/[accountId]/page.tsx +++ b/src/app/accounts/[accountId]/page.tsx @@ -26,6 +26,7 @@ import MonthlySummarySidebar from '@/components/transactions/monthly-summary-sid import SpendingChart from '@/components/dashboard/spending-chart'; import { useDateRange } from '@/contexts/DateRangeContext'; import Link from 'next/link'; +import { useAuthContext } from '@/contexts/AuthContext'; const formatDate = (dateString: string): string => { try { @@ -42,6 +43,7 @@ export default function AccountDetailPage() { const params = useParams(); const router = useRouter(); const accountId = typeof params.accountId === 'string' ? params.accountId : undefined; + const { user, isLoadingAuth } = useAuthContext(); const [account, setAccount] = useState(null); const [transactions, setTransactions] = useState([]); @@ -63,9 +65,10 @@ export default function AccountDetailPage() { const fetchData = useCallback(async () => { - if (!accountId || typeof window === 'undefined') { + if (!user || isLoadingAuth || typeof window === 'undefined' || !accountId) { setIsLoading(false); - if(!accountId) setError("Account ID is missing."); + if(!accountId && !isLoadingAuth && user) setError("Account ID is missing."); + else if (!user && !isLoadingAuth) setError("Please log in to view account details."); return; } setIsLoading(true); @@ -102,15 +105,22 @@ export default function AccountDetailPage() { } finally { setIsLoading(false); } - }, [accountId, toast]); + }, [accountId, toast, user, isLoadingAuth]); useEffect(() => { - fetchData(); - }, [fetchData]); + if (user && !isLoadingAuth) { + fetchData(); + } else if (!isLoadingAuth && !user) { + setIsLoading(false); + setAccount(null); + setTransactions([]); + setError("Please log in to view account details."); + } + }, [fetchData, user, isLoadingAuth]); useEffect(() => { const handleStorageChange = (event: StorageEvent) => { - if (typeof window !== 'undefined' && event.type === 'storage') { + if (typeof window !== 'undefined' && event.type === 'storage' && user && !isLoadingAuth) { const isLikelyOurCustomEvent = event.key === null; const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'userCategories', 'userTags', `transactions-${accountId}`]; const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key && event.key.includes(k)); @@ -132,7 +142,7 @@ export default function AccountDetailPage() { window.removeEventListener('storage', handleStorageChange); } }; - }, [accountId, fetchData]); + }, [accountId, fetchData, user, isLoadingAuth]); const filteredTransactions = useMemo(() => { @@ -328,7 +338,7 @@ export default function AccountDetailPage() { return 'All Time'; }, [selectedDateRange]); - if (isLoading && !account) { + if (isLoadingAuth || (isLoading && !account)) { return (
@@ -569,7 +579,7 @@ export default function AccountDetailPage() { categories={allCategories} tags={allTags} onTransactionAdded={handleUpdateTransaction} - onTransferAdded={handleTransferAdded} // Ensure this is passed for consistency, though less likely used in single-account edit + onTransferAdded={handleTransferAdded} isLoading={isLoading} initialData={{ ...selectedTransaction, @@ -610,9 +620,9 @@ export default function AccountDetailPage() { clonedTransactionData || (transactionTypeToAdd !== 'transfer' && account ? { accountId: account.id, transactionCurrency: account.currency, date: new Date() } - : (transactionTypeToAdd === 'transfer' && account // If adding a transfer and current account exists - ? { fromAccountId: account.id, transactionCurrency: account.currency, date: new Date(), toAccountCurrency: accounts.find(a => a.id !== account.id)?.currency || account.currency } - : {date: new Date()}) // Fallback for other cases or if account not found + : (transactionTypeToAdd === 'transfer' && account + ? { fromAccountId: account.id, transactionCurrency: account.currency, date: new Date(), toAccountCurrency: allAccounts.find(a => a.id !== account.id)?.currency || account.currency } + : {date: new Date()}) ) } /> @@ -622,4 +632,3 @@ export default function AccountDetailPage() {
); } - diff --git a/src/app/accounts/page.tsx b/src/app/accounts/page.tsx index 498c59e..769f131 100644 --- a/src/app/accounts/page.tsx +++ b/src/app/accounts/page.tsx @@ -22,9 +22,11 @@ import { format as formatDateFns, parseISO, compareAsc, startOfDay, isSameDay, e import Link from 'next/link'; import AccountBalanceHistoryChart from '@/components/accounts/account-balance-history-chart'; import { useDateRange } from '@/contexts/DateRangeContext'; +import { useAuthContext } from '@/contexts/AuthContext'; export default function AccountsPage() { + const { user, isLoadingAuth } = useAuthContext(); const [allAccounts, setAllAccounts] = useState([]); const [allTransactions, setAllTransactions] = useState([]); const [isLoading, setIsLoading] = useState(true); @@ -39,9 +41,9 @@ export default function AccountsPage() { const fetchAllData = useCallback(async () => { - if (typeof window === 'undefined') { + if (!user || isLoadingAuth || typeof window === 'undefined') { setIsLoading(false); - setError("Account data can only be loaded on the client."); + if (!user && !isLoadingAuth) setError("Please log in to view accounts."); return; } @@ -63,25 +65,32 @@ export default function AccountsPage() { setAllTransactions([]); } - } catch (err) { + } catch (err: any) { console.error("Failed to fetch accounts or transactions:", err); - setError("Could not load data. Please ensure local storage is accessible and try again."); + setError("Could not load data. Please ensure local storage is accessible and try again. Details: " + err.message); toast({ title: "Error", - description: "Failed to load accounts or transactions.", + description: "Failed to load accounts or transactions. Details: " + err.message, variant: "destructive", }); } finally { setIsLoading(false); } - }, [toast]); + }, [toast, user, isLoadingAuth]); useEffect(() => { - fetchAllData(); + if (user && !isLoadingAuth) { + fetchAllData(); + } else if (!isLoadingAuth && !user) { + setIsLoading(false); + setAllAccounts([]); + setAllTransactions([]); + setError("Please log in to view accounts."); + } const handleStorageChange = (event: StorageEvent) => { - if (event.type === 'storage') { + if (event.type === 'storage' && user && !isLoadingAuth) { const isLikelyOurCustomEvent = event.key === null; const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'transactions-']; const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key!.includes(k)); @@ -103,7 +112,7 @@ export default function AccountsPage() { window.removeEventListener('storage', handleStorageChange); } }; - }, [fetchAllData]); + }, [fetchAllData, user, isLoadingAuth]); const handleAccountAdded = async (newAccountData: NewAccountData) => { try { @@ -115,11 +124,11 @@ export default function AccountsPage() { description: `Account "${newAccountData.name}" added successfully.`, }); window.dispatchEvent(new Event('storage')); - } catch (err) { + } catch (err: any) { console.error("Failed to add account:", err); toast({ title: "Error", - description: "Could not add the account.", + description: "Could not add the account. Details: " + err.message, variant: "destructive", }); } @@ -135,11 +144,11 @@ export default function AccountsPage() { description: `Account "${updatedAccountData.name}" updated successfully.`, }); window.dispatchEvent(new Event('storage')); - } catch (err) { + } catch (err: any) { console.error("Failed to update account:", err); toast({ title: "Error", - description: "Could not update the account.", + description: "Could not update the account. Details: " + err.message, variant: "destructive", }); } @@ -153,11 +162,11 @@ export default function AccountsPage() { description: `Account removed successfully.`, }); window.dispatchEvent(new Event('storage')); - } catch (err) { + } catch (err: any) { console.error("Failed to delete account:", err); toast({ title: "Error", - description: "Could not delete the account.", + description: "Could not delete the account. Details: " + err.message, variant: "destructive", }); } @@ -194,57 +203,52 @@ export default function AccountsPage() { const maxTxDateOverall = allTxDates.length > 0 ? allTxDates.reduce((max, d) => d > max ? d : max, allTxDates[0]) : new Date(); const chartStartDate = startOfDay(selectedDateRange.from || minTxDateOverall); - const chartEndDate = endOfDay(selectedDateRange.to || maxTxDateOverall); // Use endOfDay for the chart's actual end + const chartEndDate = endOfDay(selectedDateRange.to || maxTxDateOverall); - // Calculate balance for each account AT THE START of chartStartDate - const balanceAtChartStart: { [accountId: string]: number } = {}; + const initialChartBalances: { [accountId: string]: number } = {}; relevantAccounts.forEach(acc => { - let currentBalance = acc.balance; // Current balance in account's native currency + let balanceAtChartStart = acc.balance; allTransactions .filter(tx => { const txDate = parseISO(tx.date.includes('T') ? tx.date : tx.date + 'T00:00:00Z'); - return tx.accountId === acc.id && txDate >= chartStartDate; // Transactions ON or AFTER chart start + return tx.accountId === acc.id && txDate >= chartStartDate; }) .forEach(tx => { - // Subtract these from current balance to roll back to chartStartDate - currentBalance -= convertCurrency(tx.amount, tx.transactionCurrency, acc.currency); + const amountInAccountCurrency = convertCurrency(tx.amount, tx.transactionCurrency, acc.currency); + balanceAtChartStart -= amountInAccountCurrency; }); - balanceAtChartStart[acc.id] = currentBalance; + initialChartBalances[acc.id] = balanceAtChartStart; }); + const chartDatesSet = new Set(); - chartDatesSet.add(formatDateFns(chartStartDate, 'yyyy-MM-dd')); // Ensure chart start date is included + chartDatesSet.add(formatDateFns(chartStartDate, 'yyyy-MM-dd')); allTransactions.forEach(tx => { const txDate = parseISO(tx.date.includes('T') ? tx.date : tx.date + 'T00:00:00Z'); if (txDate >= chartStartDate && txDate <= chartEndDate) { chartDatesSet.add(formatDateFns(startOfDay(txDate), 'yyyy-MM-dd')); } }); - chartDatesSet.add(formatDateFns(chartEndDate, 'yyyy-MM-dd')); // Ensure chart end date is included + chartDatesSet.add(formatDateFns(chartEndDate, 'yyyy-MM-dd')); const sortedUniqueChartDates = Array.from(chartDatesSet) .map(d => parseISO(d)) .sort(compareAsc) - .filter(date => date <= chartEndDate); // Make sure we don't go beyond chartEndDate + .filter(date => date <= chartEndDate); if (sortedUniqueChartDates.length === 0) { const dataPoint: any = { date: formatDateFns(chartStartDate, 'yyyy-MM-dd') }; relevantAccounts.forEach(acc => { - dataPoint[acc.name] = convertCurrency(balanceAtChartStart[acc.id] || 0, acc.currency, preferredCurrency); + dataPoint[acc.name] = convertCurrency(initialChartBalances[acc.id] || 0, acc.currency, preferredCurrency); }); return { data: [dataPoint], accountNames: relevantAccounts.map(a => a.name), chartConfig }; } const historicalData: Array<{ date: string, [key: string]: any }> = []; - const runningBalancesInAccountCurrency = { ...balanceAtChartStart }; // Balances are in account's native currency + const runningBalancesInAccountCurrency = { ...initialChartBalances }; - sortedUniqueChartDates.forEach((currentDisplayDate, index) => { - // If it's not the very first date in our sorted list, the runningBalances - // already reflect the start of currentDisplayDate (end of previousDisplayDate). - // For the very first date (chartStartDate), runningBalances are already set correctly. - - // Apply transactions FOR currentDisplayDate + sortedUniqueChartDates.forEach((currentDisplayDate) => { allTransactions .filter(tx => { const txDate = parseISO(tx.date.includes('T') ? tx.date : tx.date + 'T00:00:00Z'); @@ -258,14 +262,12 @@ export default function AccountsPage() { } }); - // Create snapshot for the END of currentDisplayDate const dateStr = formatDateFns(currentDisplayDate, 'yyyy-MM-dd'); const dailySnapshot: { date: string, [key: string]: any } = { date: dateStr }; relevantAccounts.forEach(acc => { dailySnapshot[acc.name] = convertCurrency(runningBalancesInAccountCurrency[acc.id] || 0, acc.currency, preferredCurrency); }); - // Add or update the snapshot for this date const existingEntryIndex = historicalData.findIndex(hd => hd.date === dateStr); if (existingEntryIndex !== -1) { historicalData[existingEntryIndex] = dailySnapshot; @@ -274,7 +276,6 @@ export default function AccountsPage() { } }); - // Ensure data is sorted by date for the chart, especially if chartStartDate/EndDate points were manually added out of order initially historicalData.sort((a,b) => compareAsc(parseISO(a.date), parseISO(b.date))); @@ -529,3 +530,4 @@ export default function AccountsPage() {
); } + diff --git a/src/app/categories/[categoryId]/page.tsx b/src/app/categories/[categoryId]/page.tsx index 403ee29..10ab1c3 100644 --- a/src/app/categories/[categoryId]/page.tsx +++ b/src/app/categories/[categoryId]/page.tsx @@ -1,3 +1,4 @@ + 'use client'; import { useState, useEffect, useMemo, useCallback } from 'react'; @@ -23,6 +24,7 @@ import { useToast } from '@/hooks/use-toast'; import type { AddTransactionFormData } from '@/components/transactions/add-transaction-form'; import MonthlySummarySidebar from '@/components/transactions/monthly-summary-sidebar'; import { useDateRange } from '@/contexts/DateRangeContext'; +import { useAuthContext } from '@/contexts/AuthContext'; const formatDate = (dateString: string): string => { try { @@ -39,6 +41,7 @@ export default function CategoryDetailPage() { const params = useParams(); const router = useRouter(); const categoryId = typeof params.categoryId === 'string' ? params.categoryId : undefined; + const { user, isLoadingAuth } = useAuthContext(); const [category, setCategory] = useState(null); const [transactions, setTransactions] = useState([]); @@ -59,9 +62,10 @@ export default function CategoryDetailPage() { const [clonedTransactionData, setClonedTransactionData] = useState | undefined>(undefined); const fetchData = useCallback(async () => { - if (!categoryId || typeof window === 'undefined') { + if (!user || isLoadingAuth || typeof window === 'undefined' || !categoryId) { setIsLoading(false); - if(!categoryId) setError("Category ID is missing."); + if(!categoryId && !isLoadingAuth && user) setError("Category ID is missing."); + else if (!user && !isLoadingAuth) setError("Please log in to view category details."); return; } setIsLoading(true); @@ -105,15 +109,22 @@ export default function CategoryDetailPage() { } finally { setIsLoading(false); } - }, [categoryId, toast]); + }, [categoryId, toast, user, isLoadingAuth]); useEffect(() => { - fetchData(); - }, [fetchData]); + if (user && !isLoadingAuth) { + fetchData(); + } else if (!isLoadingAuth && !user) { + setIsLoading(false); + setCategory(null); + setTransactions([]); + setError("Please log in to view category details."); + } + }, [fetchData, user, isLoadingAuth]); useEffect(() => { const handleStorageChange = (event: StorageEvent) => { - if (event.type === 'storage') { + if (event.type === 'storage' && user && !isLoadingAuth) { const isLikelyOurCustomEvent = event.key === null; const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'userCategories', 'userTags', 'transactions-']; const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key!.includes(k)); @@ -135,7 +146,7 @@ export default function CategoryDetailPage() { window.removeEventListener('storage', handleStorageChange); } }; - }, [categoryId, fetchData]); + }, [categoryId, fetchData, user, isLoadingAuth]); const filteredTransactions = useMemo(() => { @@ -170,7 +181,6 @@ export default function CategoryDetailPage() { setIsEditDialogOpen(false); setSelectedTransaction(null); toast({ title: "Success", description: `Transaction "${transactionToUpdate.description}" updated.` }); - // await fetchData(); // Re-fetch data for immediate UI update // Let storage event handle it window.dispatchEvent(new Event('storage')); } catch (err: any) { console.error("Failed to update transaction:", err); @@ -190,7 +200,6 @@ export default function CategoryDetailPage() { try { await deleteTransaction(selectedTransaction.id, selectedTransaction.accountId); toast({ title: "Transaction Deleted", description: `Transaction "${selectedTransaction.description}" removed.` }); - // await fetchData(); // Re-fetch data for immediate UI update // Let storage event handle it window.dispatchEvent(new Event('storage')); } catch (err: any) { console.error("Failed to delete transaction:", err); @@ -208,7 +217,6 @@ export default function CategoryDetailPage() { toast({ title: "Success", description: `${data.amount > 0 ? 'Income' : 'Expense'} added successfully.` }); setIsAddTransactionDialogOpen(false); setClonedTransactionData(undefined); - // await fetchData(); // Re-fetch data for immediate UI update // Let storage event handle it window.dispatchEvent(new Event('storage')); } catch (error: any) { console.error("Failed to add transaction:", error); @@ -216,9 +224,8 @@ export default function CategoryDetailPage() { } }; - const handleTransferAdded = async (data: { fromAccountId: string; toAccountId: string; amount: number; date: Date; description?: string; tags?: string[]; transactionCurrency: string; }) => { + const handleTransferAdded = async (data: { fromAccountId: string; toAccountId: string; amount: number; date: Date; description?: string; tags?: string[]; transactionCurrency: string; toAccountAmount: number; toAccountCurrency: string; }) => { try { - const transferAmount = Math.abs(data.amount); const formattedDate = formatDateFns(data.date, 'yyyy-MM-dd'); const currentAccounts = await getAccounts(); const fromAccountName = currentAccounts.find(a=>a.id === data.fromAccountId)?.name || 'Unknown'; @@ -227,7 +234,7 @@ export default function CategoryDetailPage() { await addTransaction({ accountId: data.fromAccountId, - amount: -transferAmount, + amount: -Math.abs(data.amount), transactionCurrency: data.transactionCurrency, date: formattedDate, description: desc, @@ -237,8 +244,8 @@ export default function CategoryDetailPage() { await addTransaction({ accountId: data.toAccountId, - amount: transferAmount, - transactionCurrency: data.transactionCurrency, + amount: Math.abs(data.toAccountAmount), + transactionCurrency: data.toAccountCurrency, date: formattedDate, description: desc, category: 'Transfer', @@ -248,7 +255,6 @@ export default function CategoryDetailPage() { toast({ title: "Success", description: "Transfer recorded successfully." }); setIsAddTransactionDialogOpen(false); setClonedTransactionData(undefined); - // await fetchData(); // Re-fetch data for immediate UI update // Let storage event handle it window.dispatchEvent(new Event('storage')); } catch (error: any) { console.error("Failed to add transfer:", error); @@ -309,7 +315,7 @@ export default function CategoryDetailPage() { return 'All Time'; }, [selectedDateRange]); - if (isLoading && !category) { + if (isLoadingAuth || (isLoading && !category)) { return (
@@ -425,7 +431,7 @@ export default function CategoryDetailPage() { {account?.name || 'Unknown Account'}
- {transaction.tags?.map(tagItem => { // Renamed tag to tagItem to avoid conflict + {transaction.tags?.map(tagItem => { const { color: tagColor } = getTagStyle(tagItem); return ( @@ -485,6 +491,7 @@ export default function CategoryDetailPage() { categories={allCategories} tags={allTags} onTransactionAdded={handleUpdateTransaction} + onTransferAdded={handleTransferAdded} isLoading={isLoading} initialData={{ ...selectedTransaction, @@ -518,3 +525,4 @@ export default function CategoryDetailPage() {
); } + diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index f918e88..567c219 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -23,9 +23,11 @@ import AddTransactionForm from '@/components/transactions/add-transaction-form'; import type { AddTransactionFormData } from '@/components/transactions/add-transaction-form'; import { useToast } from "@/hooks/use-toast"; import { useDateRange } from '@/contexts/DateRangeContext'; +import { useAuthContext } from '@/contexts/AuthContext'; export default function DashboardPage() { + const { user, isLoadingAuth } = useAuthContext(); const [preferredCurrency, setPreferredCurrency] = useState('BRL'); const [lastUpdated, setLastUpdated] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -44,8 +46,11 @@ export default function DashboardPage() { const fetchData = useCallback(async () => { - if (typeof window === 'undefined') { + if (!user || isLoadingAuth || typeof window === 'undefined') { setIsLoading(false); + if (!user && !isLoadingAuth) { + toast({ title: "Authentication Error", description: "Please log in to view dashboard data.", variant: "destructive" }); + } return; } setIsLoading(true); @@ -72,22 +77,31 @@ export default function DashboardPage() { } setLastUpdated(new Date()); - } catch (error) { + } catch (error: any) { console.error("Failed to fetch dashboard data:", error); - toast({ title: "Error", description: "Failed to load dashboard data.", variant: "destructive" }); + toast({ title: "Error", description: "Failed to load dashboard data. " + error.message, variant: "destructive" }); } finally { setIsLoading(false); } - }, [toast]); + }, [toast, user, isLoadingAuth]); useEffect(() => { - fetchData(); + if (user && !isLoadingAuth) { + fetchData(); + } else if (!isLoadingAuth && !user) { + setIsLoading(false); + setAccounts([]); + setAllTransactions([]); + setCategories([]); + setTags([]); + // Optionally clear other state or show login prompt + } const handleStorageChange = (event: StorageEvent) => { - if (event.type === 'storage') { + if (event.type === 'storage' && user && !isLoadingAuth) { const isLikelyOurCustomEvent = event.key === null; - const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'userCategories', 'userTags', 'transactions-']; // transactions- for any account change - const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key.includes(k)); + const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'userCategories', 'userTags', 'transactions-']; + const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key!.includes(k)); if (isLikelyOurCustomEvent || isRelevantExternalChange) { console.log(`Storage change for dashboard (key: ${event.key || 'custom'}), refetching data...`); @@ -105,7 +119,7 @@ export default function DashboardPage() { window.removeEventListener('storage', handleStorageChange); } }; - }, [fetchData]); + }, [fetchData, user, isLoadingAuth]); const handleRefresh = async () => { await fetchData(); @@ -226,7 +240,7 @@ export default function DashboardPage() { }, [selectedDateRange]); - if (isLoading && typeof window !== 'undefined' && accounts.length === 0 && allTransactions.length === 0) { + if (isLoadingAuth || (isLoading && typeof window !== 'undefined' && accounts.length === 0 && allTransactions.length === 0)) { return (
diff --git a/src/app/investments/page.tsx b/src/app/investments/page.tsx index ce99712..3f49f79 100644 --- a/src/app/investments/page.tsx +++ b/src/app/investments/page.tsx @@ -1,13 +1,14 @@ 'use client'; -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import InvestmentPricePanel from "@/components/investments/investment-price-panel"; import { AreaChart, DollarSign, Euro, Bitcoin as BitcoinIcon } from "lucide-react"; import { getUserPreferences } from '@/lib/preferences'; import { convertCurrency, getCurrencySymbol, supportedCurrencies as allAppSupportedCurrencies } from '@/lib/currency'; import { Skeleton } from '@/components/ui/skeleton'; +import { useAuthContext } from '@/contexts/AuthContext'; const BRLIcon = () => ( R$ @@ -31,86 +32,92 @@ const displayAssetCodes = ["BRL", "USD", "EUR", "BTC"]; export default function InvestmentsPage() { + const { user, isLoadingAuth } = useAuthContext(); const [preferredCurrency, setPreferredCurrency] = useState('BRL'); - const [isLoading, setIsLoading] = useState(true); + const [isLoadingPrefs, setIsLoadingPrefs] = useState(true); const [bitcoinPrice, setBitcoinPrice] = useState(null); const [isBitcoinPriceLoading, setIsBitcoinPriceLoading] = useState(true); const [bitcoinPriceError, setBitcoinPriceError] = useState(null); - useEffect(() => { - const fetchPrefs = async () => { - setIsLoading(true); - if (typeof window !== 'undefined') { - try { - const prefs = await getUserPreferences(); - setPreferredCurrency(prefs.preferredCurrency.toUpperCase()); - } catch (error) { - console.error("Failed to fetch user preferences:", error); - } - } - setIsLoading(false); - }; - fetchPrefs(); - }, []); + const fetchPrefs = useCallback(async () => { + if (!user || isLoadingAuth || typeof window === 'undefined') { + setIsLoadingPrefs(false); + if (!user && !isLoadingAuth) console.log("User not logged in, using default preferences for investments page."); + return; + } + setIsLoadingPrefs(true); + try { + const prefs = await getUserPreferences(); + setPreferredCurrency(prefs.preferredCurrency.toUpperCase()); + } catch (error) { + console.error("Failed to fetch user preferences:", error); + } finally { + setIsLoadingPrefs(false); + } + }, [user, isLoadingAuth]); useEffect(() => { - const fetchBitcoinPrice = async () => { - if (!preferredCurrency || typeof window === 'undefined') { - setIsBitcoinPriceLoading(false); - return; - } + fetchPrefs(); + }, [fetchPrefs]); + + const fetchBitcoinPrice = useCallback(async () => { + if (!preferredCurrency || typeof window === 'undefined' || isLoadingPrefs) { + setIsBitcoinPriceLoading(false); + return; + } + + setIsBitcoinPriceLoading(true); + setBitcoinPriceError(null); + setBitcoinPrice(null); - setIsBitcoinPriceLoading(true); - setBitcoinPriceError(null); - setBitcoinPrice(null); + const preferredCurrencyLower = preferredCurrency.toLowerCase(); + const directlySupportedVsCurrencies = ['usd', 'eur', 'brl', 'gbp', 'jpy', 'cad', 'aud', 'chf']; + let targetCoingeckoCurrency = preferredCurrencyLower; - const preferredCurrencyLower = preferredCurrency.toLowerCase(); - const directlySupportedVsCurrencies = ['usd', 'eur', 'brl', 'gbp', 'jpy', 'cad', 'aud', 'chf']; - let targetCoingeckoCurrency = preferredCurrencyLower; + if (!directlySupportedVsCurrencies.includes(preferredCurrencyLower)) { + console.warn(`Preferred currency ${preferredCurrency} might not be directly supported by Coingecko for BTC price. Fetching in USD and will convert.`); + targetCoingeckoCurrency = 'usd'; + } - if (!directlySupportedVsCurrencies.includes(preferredCurrencyLower)) { - console.warn(`Preferred currency ${preferredCurrency} might not be directly supported by Coingecko for BTC price. Fetching in USD and will convert.`); - targetCoingeckoCurrency = 'usd'; + try { + const response = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=${targetCoingeckoCurrency}`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: "Unknown API error structure" })); + throw new Error(`Coingecko API request failed: ${response.status} ${response.statusText} - ${errorData?.error || 'Details unavailable'}`); } + const data = await response.json(); - try { - const response = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=${targetCoingeckoCurrency}`); - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: "Unknown API error structure" })); - throw new Error(`Coingecko API request failed: ${response.status} ${response.statusText} - ${errorData?.error || 'Details unavailable'}`); - } - const data = await response.json(); - - if (data.bitcoin && data.bitcoin[targetCoingeckoCurrency]) { - let priceInTargetCoinGeckoCurrency = data.bitcoin[targetCoingeckoCurrency]; - if (targetCoingeckoCurrency.toUpperCase() !== preferredCurrency.toUpperCase()) { - const convertedPrice = convertCurrency(priceInTargetCoinGeckoCurrency, targetCoingeckoCurrency.toUpperCase(), preferredCurrency); - setBitcoinPrice(convertedPrice); - } else { - setBitcoinPrice(priceInTargetCoinGeckoCurrency); - } + if (data.bitcoin && data.bitcoin[targetCoingeckoCurrency]) { + let priceInTargetCoinGeckoCurrency = data.bitcoin[targetCoingeckoCurrency]; + if (targetCoingeckoCurrency.toUpperCase() !== preferredCurrency.toUpperCase()) { + const convertedPrice = convertCurrency(priceInTargetCoinGeckoCurrency, targetCoingeckoCurrency.toUpperCase(), preferredCurrency); + setBitcoinPrice(convertedPrice); } else { - throw new Error(`Bitcoin price not found in Coingecko response for the target currency '${targetCoingeckoCurrency}'.`); + setBitcoinPrice(priceInTargetCoinGeckoCurrency); } - } catch (err: any) { - console.error("Failed to fetch Bitcoin price:", err); - setBitcoinPriceError(err.message || "Could not load Bitcoin price."); - setBitcoinPrice(convertCurrency(1, "BTC", preferredCurrency)); - } finally { - setIsBitcoinPriceLoading(false); + } else { + throw new Error(`Bitcoin price not found in Coingecko response for the target currency '${targetCoingeckoCurrency}'.`); } - }; + } catch (err: any) { + console.error("Failed to fetch Bitcoin price:", err); + setBitcoinPriceError(err.message || "Could not load Bitcoin price."); + setBitcoinPrice(convertCurrency(1, "BTC", preferredCurrency)); + } finally { + setIsBitcoinPriceLoading(false); + } + }, [preferredCurrency, isLoadingPrefs]); - if (preferredCurrency && !isLoading) { - fetchBitcoinPrice(); + useEffect(() => { + if (user && !isLoadingAuth && !isLoadingPrefs) { + fetchBitcoinPrice(); } - }, [preferredCurrency, isLoading]); + }, [fetchBitcoinPrice, user, isLoadingAuth, isLoadingPrefs]); const dynamicPriceData = useMemo(() => { const filteredAssetCodes = displayAssetCodes.filter(code => code !== preferredCurrency); - if (isLoading) { // General page loading (preferences still loading) + if (isLoadingAuth || isLoadingPrefs) { return filteredAssetCodes.map(code => ({ name: currencyNames[code] || code, code: code, @@ -124,7 +131,7 @@ export default function InvestmentsPage() { return filteredAssetCodes.map(assetCode => { let priceInPreferredCurrency: number | null = null; - let displayChange = "+0.00%"; + let displayChange = "+0.00%"; let isAssetSpecificLoading = false; if (assetCode === "BTC") { @@ -132,16 +139,16 @@ export default function InvestmentsPage() { if (isBitcoinPriceLoading) { displayChange = "Loading..."; } else if (bitcoinPriceError) { - priceInPreferredCurrency = convertCurrency(1, "BTC", preferredCurrency); + priceInPreferredCurrency = convertCurrency(1, "BTC", preferredCurrency); displayChange = "Error"; } else if (bitcoinPrice !== null) { priceInPreferredCurrency = bitcoinPrice; - displayChange = "N/A"; + displayChange = "N/A"; } else { - priceInPreferredCurrency = convertCurrency(1, "BTC", preferredCurrency); + priceInPreferredCurrency = convertCurrency(1, "BTC", preferredCurrency); displayChange = "N/A"; } - } else { // Handles BRL, USD, EUR if they are not the preferredCurrency + } else { priceInPreferredCurrency = convertCurrency(1, assetCode, preferredCurrency); if(assetCode === "USD") displayChange = "-0.05%"; if(assetCode === "EUR") displayChange = "+0.02%"; @@ -152,19 +159,19 @@ export default function InvestmentsPage() { name: currencyNames[assetCode] || assetCode, code: assetCode, price: priceInPreferredCurrency, - change: displayChange, + change: displayChange, icon: currencyIcons[assetCode] || , against: preferredCurrency, isLoading: isAssetSpecificLoading, }; - }).filter(item => - allAppSupportedCurrencies.includes(item.code) && + }).filter(item => + allAppSupportedCurrencies.includes(item.code) && allAppSupportedCurrencies.includes(item.against) ); - }, [preferredCurrency, isLoading, bitcoinPrice, isBitcoinPriceLoading, bitcoinPriceError]); + }, [preferredCurrency, isLoadingAuth, isLoadingPrefs, bitcoinPrice, isBitcoinPriceLoading, bitcoinPriceError]); - if (isLoading && !preferredCurrency) { + if (isLoadingAuth || isLoadingPrefs) { return (
diff --git a/src/app/organization/page.tsx b/src/app/organization/page.tsx index 0f38dc4..5801840 100644 --- a/src/app/organization/page.tsx +++ b/src/app/organization/page.tsx @@ -23,10 +23,11 @@ import { Skeleton } from '@/components/ui/skeleton'; import { useToast } from "@/hooks/use-toast"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { cn } from '@/lib/utils'; +import { useAuthContext } from '@/contexts/AuthContext'; export default function OrganizationPage() { - // Categories State + const { user, isLoadingAuth } = useAuthContext(); const [categories, setCategories] = useState([]); const [isLoadingCategories, setIsLoadingCategories] = useState(true); const [isAddCategoryDialogOpen, setIsAddCategoryDialogOpen] = useState(false); @@ -35,7 +36,6 @@ export default function OrganizationPage() { const [selectedCategory, setSelectedCategory] = useState(null); const [categoryError, setCategoryError] = useState(null); - // Tags State const [tags, setTags] = useState([]); const [isLoadingTags, setIsLoadingTags] = useState(true); const [isAddTagDialogOpen, setIsAddTagDialogOpen] = useState(false); @@ -44,7 +44,6 @@ export default function OrganizationPage() { const [selectedTag, setSelectedTag] = useState(null); const [tagError, setTagError] = useState(null); - // Groups State const [groups, setGroups] = useState([]); const [isLoadingGroups, setIsLoadingGroups] = useState(true); const [isAddGroupDialogOpen, setIsAddGroupDialogOpen] = useState(false); @@ -60,6 +59,15 @@ export default function OrganizationPage() { const { toast } = useToast(); const fetchData = useCallback(async () => { + if (!user || isLoadingAuth || typeof window === 'undefined') { + setIsLoadingCategories(true); setIsLoadingTags(true); setIsLoadingGroups(true); // Keep loading true if no user + if (!user && !isLoadingAuth) { + setCategoryError("Please log in to manage organization."); + setTagError("Please log in to manage organization."); + setGroupError("Please log in to manage organization."); + } + return; + } setIsLoadingCategories(true); setIsLoadingTags(true); setIsLoadingGroups(true); setCategoryError(null); setTagError(null); setGroupError(null); try { @@ -74,21 +82,21 @@ export default function OrganizationPage() { setTags(fetchedTags); fetchedGroups.sort((a, b) => a.name.localeCompare(b.name)); setGroups(fetchedGroups); - } catch (err) { + } catch (err: any) { console.error("Failed to fetch organization data:", err); - const errorMsg = "Could not load organization data."; + const errorMsg = "Could not load organization data. " + err.message; setCategoryError(errorMsg); setTagError(errorMsg); setGroupError(errorMsg); - toast({ title: "Error", description: "Failed to load organization data.", variant: "destructive" }); + toast({ title: "Error", description: "Failed to load organization data. " + err.message, variant: "destructive" }); } finally { setIsLoadingCategories(false); setIsLoadingTags(false); setIsLoadingGroups(false); } - }, [toast]); + }, [toast, user, isLoadingAuth]); useEffect(() => { fetchData(); const handleStorageChange = (event: StorageEvent) => { - if (typeof window !== 'undefined' && event.type === 'storage') { + if (typeof window !== 'undefined' && event.type === 'storage' && user && !isLoadingAuth) { const isLikelyOurCustomEvent = event.key === null; const relevantKeysForThisPage = ['userCategories', 'userTags', 'userGroups']; const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key && event.key.includes(k)); @@ -104,7 +112,7 @@ export default function OrganizationPage() { return () => { if (typeof window !== 'undefined') window.removeEventListener('storage', handleStorageChange); }; - }, [fetchData]); + }, [fetchData, user, isLoadingAuth]); // Category Handlers @@ -312,11 +320,11 @@ export default function OrganizationPage() {
{group.name}
- + +
{ try { @@ -40,6 +42,7 @@ export default function TagDetailPage() { const params = useParams(); const router = useRouter(); const tagId = typeof params.tagId === 'string' ? params.tagId : undefined; + const { user, isLoadingAuth } = useAuthContext(); const [tag, setTag] = useState(null); const [transactions, setTransactions] = useState([]); @@ -60,9 +63,10 @@ export default function TagDetailPage() { const [clonedTransactionData, setClonedTransactionData] = useState | undefined>(undefined); const fetchData = useCallback(async () => { - if (!tagId || typeof window === 'undefined') { + if (!user || isLoadingAuth || typeof window === 'undefined' || !tagId) { setIsLoading(false); - if(!tagId) setError("Tag ID is missing."); + if(!tagId && !isLoadingAuth && user) setError("Tag ID is missing."); + else if (!user && !isLoadingAuth) setError("Please log in to view tag details."); return; } setIsLoading(true); @@ -101,20 +105,27 @@ export default function TagDetailPage() { } catch (err: any) { console.error(`Failed to fetch data for tag ${tagId}:`, err); - setError("Could not load tag data. Please try again later."); + setError("Could not load tag data. Please try again later. " + err.message); toast({ title: "Error", description: err.message || "Failed to load tag data.", variant: "destructive" }); } finally { setIsLoading(false); } - }, [tagId, toast]); + }, [tagId, toast, user, isLoadingAuth]); useEffect(() => { - fetchData(); - }, [fetchData]); + if (user && !isLoadingAuth) { + fetchData(); + } else if (!isLoadingAuth && !user) { + setIsLoading(false); + setTag(null); + setTransactions([]); + setError("Please log in to view tag details."); + } + }, [fetchData, user, isLoadingAuth]); useEffect(() => { const handleStorageChange = (event: StorageEvent) => { - if (event.type === 'storage') { + if (event.type === 'storage' && user && !isLoadingAuth) { const isLikelyOurCustomEvent = event.key === null; const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'userCategories', 'userTags', 'transactions-']; const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key!.includes(k)); @@ -130,7 +141,7 @@ export default function TagDetailPage() { return () => { if (typeof window !== 'undefined') window.removeEventListener('storage', handleStorageChange); }; - }, [tagId, fetchData]); + }, [tagId, fetchData, user, isLoadingAuth]); const filteredTransactions = useMemo(() => { @@ -165,7 +176,6 @@ export default function TagDetailPage() { setIsEditDialogOpen(false); setSelectedTransaction(null); toast({ title: "Success", description: `Transaction "${transactionToUpdate.description}" updated.` }); - // await fetchData(); // Re-fetch data for immediate UI update // Let storage event handle it window.dispatchEvent(new Event('storage')); } catch (err: any) { console.error("Failed to update transaction:", err); @@ -185,7 +195,6 @@ export default function TagDetailPage() { try { await deleteTransaction(selectedTransaction.id, selectedTransaction.accountId); toast({ title: "Transaction Deleted", description: `Transaction "${selectedTransaction.description}" removed.` }); - // await fetchData(); // Re-fetch data for immediate UI update // Let storage event handle it window.dispatchEvent(new Event('storage')); } catch (err: any) { console.error("Failed to delete transaction:", err); @@ -205,7 +214,6 @@ export default function TagDetailPage() { toast({ title: "Success", description: `${data.amount > 0 ? 'Income' : 'Expense'} added successfully.` }); setIsAddTransactionDialogOpen(false); setClonedTransactionData(undefined); - // await fetchData(); // Re-fetch data for immediate UI update // Let storage event handle it window.dispatchEvent(new Event('storage')); } catch (error: any) { console.error("Failed to add transaction:", error); @@ -213,9 +221,8 @@ export default function TagDetailPage() { } }; - const handleTransferAdded = async (data: { fromAccountId: string; toAccountId: string; amount: number; date: Date; description?: string; tags?: string[]; transactionCurrency: string; }) => { + const handleTransferAdded = async (data: { fromAccountId: string; toAccountId: string; amount: number; date: Date; description?: string; tags?: string[]; transactionCurrency: string; toAccountAmount: number; toAccountCurrency: string; }) => { try { - const transferAmount = Math.abs(data.amount); const formattedDate = formatDateFns(data.date, 'yyyy-MM-dd'); const currentAccounts = await getAccounts(); const fromAccountName = currentAccounts.find(a=>a.id === data.fromAccountId)?.name || 'Unknown'; @@ -228,7 +235,7 @@ export default function TagDetailPage() { await addTransaction({ accountId: data.fromAccountId, - amount: -transferAmount, + amount: -Math.abs(data.amount), transactionCurrency: data.transactionCurrency, date: formattedDate, description: desc, @@ -238,8 +245,8 @@ export default function TagDetailPage() { await addTransaction({ accountId: data.toAccountId, - amount: transferAmount, - transactionCurrency: data.transactionCurrency, + amount: Math.abs(data.toAccountAmount), + transactionCurrency: data.toAccountCurrency, date: formattedDate, description: desc, category: 'Transfer', @@ -249,7 +256,6 @@ export default function TagDetailPage() { toast({ title: "Success", description: "Transfer recorded successfully." }); setIsAddTransactionDialogOpen(false); setClonedTransactionData(undefined); - // await fetchData(); // Re-fetch data for immediate UI update // Let storage event handle it window.dispatchEvent(new Event('storage')); } catch (error: any) { console.error("Failed to add transfer:", error); @@ -310,7 +316,7 @@ export default function TagDetailPage() { return 'All Time'; }, [selectedDateRange]); - if (isLoading && !tag) { + if (isLoadingAuth || (isLoading && !tag)) { return (
@@ -475,6 +481,7 @@ export default function TagDetailPage() { categories={allCategories} tags={allTags} onTransactionAdded={handleUpdateTransaction} + onTransferAdded={handleTransferAdded} isLoading={isLoading} initialData={{ ...selectedTransaction, @@ -508,3 +515,4 @@ export default function TagDetailPage() {
); } + diff --git a/src/app/transfers/page.tsx b/src/app/transfers/page.tsx index 21d79fa..bb79986 100644 --- a/src/app/transfers/page.tsx +++ b/src/app/transfers/page.tsx @@ -23,6 +23,7 @@ import type { AddTransactionFormData } from '@/components/transactions/add-trans import MonthlySummarySidebar from '@/components/transactions/monthly-summary-sidebar'; import { useDateRange } from '@/contexts/DateRangeContext'; import Link from 'next/link'; +import { useAuthContext } from '@/contexts/AuthContext'; const INITIAL_TRANSACTION_LIMIT = 50; @@ -38,6 +39,7 @@ const formatDate = (dateString: string): string => { }; export default function TransfersPage() { + const { user, isLoadingAuth } = useAuthContext(); const [accounts, setAccounts] = useState([]); const [allTransactionsUnfiltered, setAllTransactionsUnfiltered] = useState([]); const [allCategories, setAllCategories] = useState([]); @@ -57,9 +59,9 @@ export default function TransfersPage() { const fetchData = useCallback(async () => { - if (typeof window === 'undefined') { + if (!user || isLoadingAuth || typeof window === 'undefined') { setIsLoading(false); - setError("Transfer data can only be loaded on the client."); + if (!user && !isLoadingAuth) setError("Please log in to view transfer data."); return; } @@ -92,21 +94,28 @@ export default function TransfersPage() { } catch (err: any) { console.error("Failed to fetch transfer data:", err); - setError("Could not load transfer data. Please try again later."); + setError("Could not load transfer data. Please try again later. " + err.message); toast({ title: "Error", description: err.message || "Failed to load data.", variant: "destructive" }); } finally { setIsLoading(false); } - }, [toast]); + }, [toast, user, isLoadingAuth]); useEffect(() => { - fetchData(); + if (user && !isLoadingAuth) { + fetchData(); + } else if (!isLoadingAuth && !user) { + setIsLoading(false); + setAccounts([]); + setAllTransactionsUnfiltered([]); + setError("Please log in to view transfers."); + } const handleStorageChange = (event: StorageEvent) => { - if (typeof window !== 'undefined' && event.type === 'storage') { + if (typeof window !== 'undefined' && event.type === 'storage' && user && !isLoadingAuth) { const isLikelyOurCustomEvent = event.key === null; const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'userCategories', 'userTags', 'transactions-']; - const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key && event.key.includes(k)); + const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key!.includes(k)); if (isLikelyOurCustomEvent || isRelevantExternalChange) { @@ -124,7 +133,7 @@ export default function TransfersPage() { window.removeEventListener('storage', handleStorageChange); } }; - }, [fetchData]); + }, [fetchData, user, isLoadingAuth]); const transferTransactionPairs = useMemo(() => { if (isLoading) return []; @@ -142,29 +151,23 @@ export default function TransfersPage() { potentialTransfers.forEach(txOut => { if (txOut.amount < 0 && !processedIds.has(txOut.id)) { - // For cross-currency, amount might not be exact opposite. - // We rely more on description, date and involved accounts. - // For same-currency, amount should be exact opposite. const matchingIncoming = potentialTransfers.filter(txIn => txIn.accountId !== txOut.accountId && !processedIds.has(txIn.id) && txIn.date === txOut.date && (txIn.description === txOut.description || - (txIn.description?.startsWith("Transfer from") && txOut.description?.startsWith("Transfer from"))) && // Common pattern - ( (txIn.transactionCurrency === txOut.transactionCurrency && txIn.amount === -txOut.amount) || // Same currency check - (txIn.transactionCurrency !== txOut.transactionCurrency) ) // Allow different currencies for cross-currency + (txIn.description?.startsWith("Transfer from") && txOut.description?.startsWith("Transfer from"))) && + ( (txIn.transactionCurrency === txOut.transactionCurrency && txIn.amount === -txOut.amount) || + (txIn.transactionCurrency !== txOut.transactionCurrency) ) ); if (matchingIncoming.length > 0) { - // Prioritize exact amount match if currencies are same, otherwise take first plausible match let txIn; const sameCurrencyExactMatch = matchingIncoming.find(match => match.transactionCurrency === txOut.transactionCurrency && match.amount === -txOut.amount); if (sameCurrencyExactMatch) { txIn = sameCurrencyExactMatch; } else { - // If no exact match for same currency, or if currencies are different, take the first one. - // This might need more sophisticated matching for multi-leg CSV imports if descriptions aren't identical. - matchingIncoming.sort((a,b) => a.id.localeCompare(b.id)); // Consistent sort for tie-breaking + matchingIncoming.sort((a,b) => a.id.localeCompare(b.id)); txIn = matchingIncoming[0]; } @@ -320,10 +323,8 @@ export default function TransfersPage() { let toAmt = Math.abs(editingTransferPair.to.amount); if (fromAcc && toAcc && fromAcc.currency !== toAcc.currency) { - // If currencies are different, toAccountAmount should be what was in the `to` leg. toAmt = Math.abs(editingTransferPair.to.amount); } else if (fromAcc && toAcc && fromAcc.currency === toAcc.currency) { - // If currencies are same, toAccountAmount should be same as fromAccountAmount (absolute) toAmt = Math.abs(editingTransferPair.from.amount); } @@ -332,10 +333,10 @@ export default function TransfersPage() { type: 'transfer' as 'transfer', fromAccountId: editingTransferPair.from.accountId, toAccountId: editingTransferPair.to.accountId, - amount: Math.abs(editingTransferPair.from.amount), // Amount from source - transactionCurrency: editingTransferPair.from.transactionCurrency, // Currency of source - toAccountAmount: toAmt, // Amount for destination, pre-filled - toAccountCurrency: editingTransferPair.to.transactionCurrency, // Currency of destination + amount: Math.abs(editingTransferPair.from.amount), + transactionCurrency: editingTransferPair.from.transactionCurrency, + toAccountAmount: toAmt, + toAccountCurrency: editingTransferPair.to.transactionCurrency, date: parseISO(editingTransferPair.from.date.includes('T') ? editingTransferPair.from.date : editingTransferPair.from.date + 'T00:00:00Z'), description: editingTransferPair.from.description, tags: editingTransferPair.from.tags || [], From 9082c1b49f41b56474088ccbc93a4b4182206156 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Mon, 26 May 2025 02:39:01 +0000 Subject: [PATCH 036/156] Voce nao aplicou --- src/app/page.tsx | 128 +++++++++++++++++++++++++++++++++-------------- 1 file changed, 91 insertions(+), 37 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 60a3b48..98f17ef 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,12 +1,11 @@ 'use client'; -import { useState, useEffect, useMemo, useCallback } from 'react'; // Added useCallback +import { useState, useEffect, useMemo, useCallback } from 'react'; import type { FC } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import TotalNetWorthCard from "@/components/dashboard/total-net-worth-card"; -// import SpendingsBreakdown from "@/components/dashboard/spendings-breakdown"; // Removed -import SubscriptionsPieChart from "@/components/dashboard/subscriptions-pie-chart"; // Added +import SubscriptionsPieChart from "@/components/dashboard/subscriptions-pie-chart"; import IncomeSourceChart from "@/components/dashboard/income-source-chart"; import IncomeExpensesChart from "@/components/dashboard/income-expenses-chart"; import AssetsChart from "@/components/dashboard/assets-chart"; @@ -19,6 +18,7 @@ import { startOfMonth, endOfMonth, isWithinInterval, parseISO, format as formatD import { Skeleton } from '@/components/ui/skeleton'; import { useToast } from "@/hooks/use-toast"; import { useDateRange } from '@/contexts/DateRangeContext'; +import { useAuthContext } from '@/contexts/AuthContext'; const assetsData = [ @@ -30,17 +30,22 @@ const assetsData = [ export default function DashboardPage() { + const { user, isLoadingAuth } = useAuthContext(); const [accounts, setAccounts] = useState([]); const [allTransactions, setAllTransactions] = useState([]); const [categories, setCategories] = useState([]); const [isLoading, setIsLoading] = useState(true); - const [preferredCurrency, setPreferredCurrency] = useState('BRL'); // Default + const [preferredCurrency, setPreferredCurrency] = useState('BRL'); const { toast } = useToast(); const { selectedDateRange } = useDateRange(); const fetchData = useCallback(async () => { - if (typeof window === 'undefined') { + if (!user || isLoadingAuth || typeof window === 'undefined') { setIsLoading(false); + if (!user && !isLoadingAuth) { + // Optionally set an error or return if you don't want to proceed without a user + toast({ title: "Authentication Error", description: "Please log in to view dashboard data.", variant: "destructive" }); + } return; } setIsLoading(true); @@ -62,21 +67,29 @@ export default function DashboardPage() { } else { setAllTransactions([]); } - } catch (error) { + } catch (error: any) { console.error("Failed to fetch dashboard data:", error); - toast({ title: "Error", description: "Failed to load dashboard data.", variant: "destructive" }); + toast({ title: "Error", description: "Failed to load dashboard data. " + error.message, variant: "destructive" }); } finally { setIsLoading(false); } - }, [toast]); + }, [toast, user, isLoadingAuth]); useEffect(() => { - fetchData(); + if (user && !isLoadingAuth) { + fetchData(); + } else if (!isLoadingAuth && !user) { + setIsLoading(false); + setAccounts([]); + setAllTransactions([]); + setCategories([]); + } + const handleStorageChange = (event: StorageEvent) => { - if (typeof window !== 'undefined' && event.type === 'storage') { + if (typeof window !== 'undefined' && event.type === 'storage' && user && !isLoadingAuth) { const isLikelyOurCustomEvent = event.key === null; - const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'userCategories', 'userTags', 'transactions-', 'userSubscriptions']; // Added userSubscriptions + const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'userCategories', 'userTags', 'transactions-', 'userSubscriptions']; const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key!.includes(k)); if (isLikelyOurCustomEvent || isRelevantExternalChange) { @@ -95,64 +108,64 @@ export default function DashboardPage() { window.removeEventListener('storage', handleStorageChange); } }; - }, [fetchData]); + }, [fetchData, user, isLoadingAuth]); const totalNetWorth = useMemo(() => { - if (isLoading || typeof window === 'undefined') return 0; + if (isLoading || typeof window === 'undefined' || !user) return 0; return accounts - .filter(acc => acc.includeInNetWorth !== false) + .filter(acc => acc.includeInNetWorth !== false) .reduce((sum, account) => { return sum + convertCurrency(account.balance, account.currency, preferredCurrency); }, 0); - }, [accounts, preferredCurrency, isLoading]); + }, [accounts, preferredCurrency, isLoading, user]); const periodTransactions = useMemo(() => { - if (isLoading) return []; + if (isLoading || !user) return []; return allTransactions.filter(tx => { const txDate = parseISO(tx.date.includes('T') ? tx.date : tx.date + 'T00:00:00Z'); if (!selectedDateRange.from || !selectedDateRange.to) return true; return isWithinInterval(txDate, { start: selectedDateRange.from, end: selectedDateRange.to }); }); - }, [allTransactions, isLoading, selectedDateRange]); + }, [allTransactions, isLoading, selectedDateRange, user]); const monthlyIncome = useMemo(() => { - if (isLoading || typeof window === 'undefined') return 0; + if (isLoading || typeof window === 'undefined' || !user) return 0; return periodTransactions.reduce((sum, tx) => { - if (tx.amount > 0 && tx.category !== 'Transfer') { + if (tx.amount > 0 && tx.category !== 'Transfer') { const account = accounts.find(acc => acc.id === tx.accountId); - if (account && account.includeInNetWorth !== false) { + if (account && account.includeInNetWorth !== false) { return sum + convertCurrency(tx.amount, tx.transactionCurrency, preferredCurrency); } } return sum; }, 0); - }, [periodTransactions, accounts, preferredCurrency, isLoading]); + }, [periodTransactions, accounts, preferredCurrency, isLoading, user]); const monthlyExpenses = useMemo(() => { - if (isLoading || typeof window === 'undefined') return 0; + if (isLoading || typeof window === 'undefined' || !user) return 0; return periodTransactions.reduce((sum, tx) => { - if (tx.amount < 0 && tx.category !== 'Transfer') { + if (tx.amount < 0 && tx.category !== 'Transfer') { const account = accounts.find(acc => acc.id === tx.accountId); - if (account && account.includeInNetWorth !== false) { + if (account && account.includeInNetWorth !== false) { return sum + convertCurrency(Math.abs(tx.amount), tx.transactionCurrency, preferredCurrency); } } return sum; }, 0); - }, [periodTransactions, accounts, preferredCurrency, isLoading]); + }, [periodTransactions, accounts, preferredCurrency, isLoading, user]); const incomeSourceDataActual = useMemo(() => { - if (isLoading || typeof window === 'undefined' || !periodTransactions.length) return []; + if (isLoading || typeof window === 'undefined' || !periodTransactions.length || !user) return []; const incomeCategoryTotals: { [key: string]: number } = {}; const chartColors = ["hsl(var(--chart-1))", "hsl(var(--chart-2))", "hsl(var(--chart-3))", "hsl(var(--chart-4))", "hsl(var(--chart-5))"]; let colorIndex = 0; periodTransactions.forEach(tx => { - if (tx.amount > 0 && tx.category !== 'Transfer') { + if (tx.amount > 0 && tx.category !== 'Transfer') { const account = accounts.find(acc => acc.id === tx.accountId); - if (account && account.includeInNetWorth !== false) { + if (account && account.includeInNetWorth !== false) { const categoryName = tx.category || 'Uncategorized Income'; const convertedAmount = convertCurrency(tx.amount, tx.transactionCurrency, preferredCurrency); incomeCategoryTotals[categoryName] = (incomeCategoryTotals[categoryName] || 0) + convertedAmount; @@ -167,11 +180,37 @@ export default function DashboardPage() { fill: chartColors[colorIndex++ % chartColors.length], })) .sort((a, b) => b.amount - a.amount); - }, [periodTransactions, accounts, preferredCurrency, isLoading]); + }, [periodTransactions, accounts, preferredCurrency, isLoading, user]); + + const monthlyExpensesByCategoryData = useMemo(() => { + if (isLoading || typeof window === 'undefined' || !periodTransactions.length || !user) return []; + const expenseCategoryTotals: { [key: string]: number } = {}; + const chartColors = ["hsl(var(--chart-5))", "hsl(var(--chart-4))", "hsl(var(--chart-3))", "hsl(var(--chart-2))", "hsl(var(--chart-1))"]; // Slightly different color order for distinction + let colorIndex = 0; + + periodTransactions.forEach(tx => { + if (tx.amount < 0 && tx.category !== 'Transfer') { + const account = accounts.find(acc => acc.id === tx.accountId); + if (account && account.includeInNetWorth !== false) { + const categoryName = tx.category || 'Uncategorized Expense'; + const convertedAmount = convertCurrency(Math.abs(tx.amount), tx.transactionCurrency, preferredCurrency); + expenseCategoryTotals[categoryName] = (expenseCategoryTotals[categoryName] || 0) + convertedAmount; + } + } + }); + + return Object.entries(expenseCategoryTotals) + .map(([categoryName, amount]) => ({ + source: categoryName.charAt(0).toUpperCase() + categoryName.slice(1), // 'source' key for IncomeSourceChart compatibility + amount, + fill: chartColors[colorIndex++ % chartColors.length], + })) + .sort((a, b) => b.amount - a.amount); + }, [periodTransactions, accounts, preferredCurrency, isLoading, user]); const monthlyIncomeExpensesDataActual = useMemo(() => { - if (isLoading || typeof window === 'undefined' || !allTransactions.length) return []; + if (isLoading || typeof window === 'undefined' || !allTransactions.length || !user) return []; const monthlyData: { [month: string]: { income: number; expenses: number } } = {}; @@ -190,7 +229,7 @@ export default function DashboardPage() { const monthKey = formatDateFns(txDate, 'MMM'); const account = accounts.find(acc => acc.id === tx.accountId); - if (account && monthlyData[monthKey] && tx.category !== 'Transfer' && account.includeInNetWorth !== false) { + if (account && monthlyData[monthKey] && tx.category !== 'Transfer' && account.includeInNetWorth !== false) { if (tx.amount > 0) { monthlyData[monthKey].income += convertCurrency(tx.amount, tx.transactionCurrency, preferredCurrency); } else if (tx.amount < 0) { @@ -204,7 +243,7 @@ export default function DashboardPage() { income: monthlyData[monthKey].income, expenses: monthlyData[monthKey].expenses, })); - }, [allTransactions, accounts, preferredCurrency, isLoading]); + }, [allTransactions, accounts, preferredCurrency, isLoading, user]); const dateRangeLabel = useMemo(() => { if (selectedDateRange.from && selectedDateRange.to) { @@ -217,15 +256,16 @@ export default function DashboardPage() { }, [selectedDateRange]); - if (isLoading && typeof window !== 'undefined' && accounts.length === 0) { + if (isLoadingAuth || (isLoading && typeof window !== 'undefined' && accounts.length === 0 && !user)) { return (
-
-
+
{/* Changed from 3 to 2 */} + +
@@ -245,8 +285,8 @@ export default function DashboardPage() {
-
-
+
{/* Changed from md:grid-cols-2 xl:grid-cols-3 */} +
{/* Wrapper for Income Source Chart */} {isLoading || incomeSourceDataActual.length === 0 ? ( @@ -260,6 +300,20 @@ export default function DashboardPage() { )}
+
{/* Wrapper for Expense Breakdown Chart */} + {isLoading || monthlyExpensesByCategoryData.length === 0 ? ( + + + Expense Breakdown ({dateRangeLabel}) + + + {isLoading ? :

No expense data for this period.

} +
+
+ ) : ( + // Re-using IncomeSourceChart for expenses + )} +
From bf3c605b5822f032d5e573ea9126fcfbb50f82d9 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Mon, 26 May 2025 02:42:04 +0000 Subject: [PATCH 037/156] Make the following changes: MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit , Find this element: with the text content: Total Net WorthSum of all included accounts converted to €.256,91 €[data-radix-scroll-area-viewport]{scrollbar-width:none;-ms-overflow-style:none;-webkit-overflow-scrolling:touch;}[data-radix-scroll-area-viewport]::-webkit-scrollbar{display:none} NubankR$ 0,00(≈0,00 €)Wise0,00 €Revolut314,62 €Caixa-R$ 328,92(≈-57,71 €) Relevant files: - https://6000-idx-studio-1746229037823.cluster-axf5tvtfjjfekvhwxwkkkzsk2y.cloudworkstations.dev/_next/static/chunks/src_9f3f1b62._.js - https://6000-idx-studio-1746229037823.cluster-axf5tvtfjjfekvhwxwkkkzsk2y.cloudworkstations.dev/_next/static/chunks/node_modules_b80a0d62._.js - https://6000-idx-studio-1746229037823.cluster-axf5tvtfjjfekvhwxwkkkzsk2y.cloudworkstations.dev/_next/static/chunks/node_modules_next_dist_compiled_2ce9398a._.js - https://6000-idx-studio-1746229037823.cluster-axf5tvtfjjfekvhwxwkkkzsk2y.cloudworkstations.dev/_next/static/chunks/node_modules_next_dist_compiled_2ce9398a._.js And change it as follows: Faca os cards interativos, quando clicar nesse card levar para as accounts e tambem quando clicar em cada conta leva para a pagina de analise dela que ja temos --- .../dashboard/total-net-worth-card.tsx | 73 +++++++++++-------- 1 file changed, 41 insertions(+), 32 deletions(-) diff --git a/src/components/dashboard/total-net-worth-card.tsx b/src/components/dashboard/total-net-worth-card.tsx index d5a247b..4b43b6a 100644 --- a/src/components/dashboard/total-net-worth-card.tsx +++ b/src/components/dashboard/total-net-worth-card.tsx @@ -2,6 +2,7 @@ 'use client'; import type { FC } from 'react'; +import Link from 'next/link'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import type { Account } from '@/services/account-sync'; @@ -21,40 +22,48 @@ const TotalNetWorthCard: FC = ({ accounts, preferredCurr const formattedNetWorth = formatCurrency(netWorth, preferredCurrency, preferredCurrency, false); - return ( - - - Total Net Worth - Sum of all included accounts converted to {getCurrencySymbol(preferredCurrency)}. - - -
{formattedNetWorth}
- {includedAccounts.length > 0 ? ( - {/* Adjust height as needed */} -
- {includedAccounts.map(account => ( -
- {account.name} -
- - {formatCurrency(account.balance, account.currency, account.currency, false)} - - {account.currency.toUpperCase() !== preferredCurrency.toUpperCase() && ( - - (≈{formatCurrency(account.balance, account.currency, preferredCurrency, true)}) - - )} -
+ + + + + Total Net Worth + Sum of all included accounts converted to {getCurrencySymbol(preferredCurrency)}. + + +
{formattedNetWorth}
+ {includedAccounts.length > 0 ? ( + {/* Adjust height as needed */} +
- ))} -
- - ) : ( -

No accounts included in net worth calculation.

- )} - - + + ) : ( +

No accounts included in net worth calculation.

+ )} + + + + ); }; From 5c28b1a87a05638d0fe80db94ec70bd83444d922 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Mon, 26 May 2025 11:43:43 +0000 Subject: [PATCH 038/156] I see this error with the app, reported by NextJS, please fix it. The error is reported as HTML but presented visually to the user). A > before the line number in the error source usually indicates the line of interest: > Console Error: cannot contain a nested . > See this log for the ancestor stack trace.. Error source: src/components/dashboard/total-net-worth-card.tsx (27:7) @ TotalNetWorthCard > > 25 | return ( > 26 | > > 27 | > | ^ > 28 | > 29 | > 30 | Total Net Worth > > Call Stack > 20 > > Show 16 ignore-listed frame(s) > a > (0:0) > TotalNetWorthCard > src/components/dashboard/total-net-worth-card.tsx (27:7) > TotalNetWorthCard > src/components/dashboard/total-net-worth-card.tsx (26:5) > DashboardPage > src/app/page.tsx (283:11) --- .../dashboard/total-net-worth-card.tsx | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/src/components/dashboard/total-net-worth-card.tsx b/src/components/dashboard/total-net-worth-card.tsx index 4b43b6a..0026588 100644 --- a/src/components/dashboard/total-net-worth-card.tsx +++ b/src/components/dashboard/total-net-worth-card.tsx @@ -2,11 +2,13 @@ 'use client'; import type { FC } from 'react'; -import Link from 'next/link'; +import Link from 'next/link'; // For the outer card link +import { useRouter } from 'next/navigation'; // For inner item navigation import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { ScrollArea } from "@/components/ui/scroll-area"; import type { Account } from '@/services/account-sync'; import { formatCurrency, convertCurrency, getCurrencySymbol } from '@/lib/currency'; +// import { cn } from '@/lib/utils'; // cn might not be strictly needed if not using complex conditional classes here interface TotalNetWorthCardProps { accounts: Account[]; @@ -14,6 +16,8 @@ interface TotalNetWorthCardProps { } const TotalNetWorthCard: FC = ({ accounts, preferredCurrency }) => { + const router = useRouter(); // Initialize router + const includedAccounts = accounts.filter(acc => acc.includeInNetWorth !== false); const netWorth = includedAccounts.reduce((sum, account) => { @@ -22,6 +26,11 @@ const TotalNetWorthCard: FC = ({ accounts, preferredCurr const formattedNetWorth = formatCurrency(netWorth, preferredCurrency, preferredCurrency, false); + const handleAccountItemClick = (accountId: string, e: React.MouseEvent | React.KeyboardEvent) => { + e.stopPropagation(); // Prevent the outer card link from firing + router.push(`/accounts/${accountId}`); + }; + return ( @@ -36,24 +45,30 @@ const TotalNetWorthCard: FC = ({ accounts, preferredCurr {/* Adjust height as needed */}
{includedAccounts.map(account => ( - - e.stopPropagation()} // Prevent card link from firing - > - {account.name} - ))}
From 78bfc48c0e4f5373539592d1b39490a425642d11 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 27 May 2025 01:35:14 +0000 Subject: [PATCH 039/156] =?UTF-8?q?E=20retire=20a=20explicacao=20"Sum=20of?= =?UTF-8?q?=20all=20included=20accounts=20convertes=20to=20=E2=82=AC"=20po?= =?UTF-8?q?de=20tirar=20a=20explicacao=20de=20todos=20os=20cards=20dessa?= =?UTF-8?q?=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/page.tsx | 48 +++---------------- .../dashboard/income-expenses-chart.tsx | 5 +- .../dashboard/income-source-chart.tsx | 5 +- .../dashboard/subscriptions-pie-chart.tsx | 12 ++--- .../dashboard/total-net-worth-card.tsx | 9 ++-- 5 files changed, 19 insertions(+), 60 deletions(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index 98f17ef..fedd95e 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -43,8 +43,7 @@ export default function DashboardPage() { if (!user || isLoadingAuth || typeof window === 'undefined') { setIsLoading(false); if (!user && !isLoadingAuth) { - // Optionally set an error or return if you don't want to proceed without a user - toast({ title: "Authentication Error", description: "Please log in to view dashboard data.", variant: "destructive" }); + // toast({ title: "Authentication Error", description: "Please log in to view dashboard data.", variant: "destructive" }); } return; } @@ -110,15 +109,6 @@ export default function DashboardPage() { }; }, [fetchData, user, isLoadingAuth]); - const totalNetWorth = useMemo(() => { - if (isLoading || typeof window === 'undefined' || !user) return 0; - return accounts - .filter(acc => acc.includeInNetWorth !== false) - .reduce((sum, account) => { - return sum + convertCurrency(account.balance, account.currency, preferredCurrency); - }, 0); - }, [accounts, preferredCurrency, isLoading, user]); - const periodTransactions = useMemo(() => { if (isLoading || !user) return []; @@ -130,32 +120,6 @@ export default function DashboardPage() { }, [allTransactions, isLoading, selectedDateRange, user]); - const monthlyIncome = useMemo(() => { - if (isLoading || typeof window === 'undefined' || !user) return 0; - return periodTransactions.reduce((sum, tx) => { - if (tx.amount > 0 && tx.category !== 'Transfer') { - const account = accounts.find(acc => acc.id === tx.accountId); - if (account && account.includeInNetWorth !== false) { - return sum + convertCurrency(tx.amount, tx.transactionCurrency, preferredCurrency); - } - } - return sum; - }, 0); - }, [periodTransactions, accounts, preferredCurrency, isLoading, user]); - - const monthlyExpenses = useMemo(() => { - if (isLoading || typeof window === 'undefined' || !user) return 0; - return periodTransactions.reduce((sum, tx) => { - if (tx.amount < 0 && tx.category !== 'Transfer') { - const account = accounts.find(acc => acc.id === tx.accountId); - if (account && account.includeInNetWorth !== false) { - return sum + convertCurrency(Math.abs(tx.amount), tx.transactionCurrency, preferredCurrency); - } - } - return sum; - }, 0); - }, [periodTransactions, accounts, preferredCurrency, isLoading, user]); - const incomeSourceDataActual = useMemo(() => { if (isLoading || typeof window === 'undefined' || !periodTransactions.length || !user) return []; const incomeCategoryTotals: { [key: string]: number } = {}; @@ -263,7 +227,7 @@ export default function DashboardPage() {
-
{/* Changed from 3 to 2 */} +
@@ -285,8 +249,8 @@ export default function DashboardPage() {
-
{/* Changed from md:grid-cols-2 xl:grid-cols-3 */} -
{/* Wrapper for Income Source Chart */} +
+
{isLoading || incomeSourceDataActual.length === 0 ? ( @@ -300,7 +264,7 @@ export default function DashboardPage() { )}
-
{/* Wrapper for Expense Breakdown Chart */} +
{isLoading || monthlyExpensesByCategoryData.length === 0 ? ( @@ -311,7 +275,7 @@ export default function DashboardPage() { ) : ( - // Re-using IncomeSourceChart for expenses + )}
diff --git a/src/components/dashboard/income-expenses-chart.tsx b/src/components/dashboard/income-expenses-chart.tsx index 1c811e1..b1c7428 100644 --- a/src/components/dashboard/income-expenses-chart.tsx +++ b/src/components/dashboard/income-expenses-chart.tsx @@ -25,7 +25,7 @@ const IncomeExpensesChart: FC = ({ data, currency }) =
Income & Expenses (Last 12 Months) - No income/expense data for the selected period. + {/* CardDescription removed */}
@@ -55,7 +55,7 @@ const IncomeExpensesChart: FC = ({ data, currency }) =
Income & Expenses (Last 12 Months) - Overview of income and expenses for the past year. + {/* CardDescription removed */}

Max Income

@@ -133,4 +133,3 @@ const IncomeExpensesChart: FC = ({ data, currency }) = }; export default IncomeExpensesChart; - diff --git a/src/components/dashboard/income-source-chart.tsx b/src/components/dashboard/income-source-chart.tsx index 239d094..109d722 100644 --- a/src/components/dashboard/income-source-chart.tsx +++ b/src/components/dashboard/income-source-chart.tsx @@ -24,7 +24,7 @@ const IncomeSourceChart: FC = ({ data, currency }) => { Income Source - No income data for the selected period. + {/* CardDescription removed */}

No income data to display.

@@ -48,7 +48,7 @@ const IncomeSourceChart: FC = ({ data, currency }) => { Income Source - Breakdown of income by source for the selected period. + {/* CardDescription removed */} {/* Adjust height as needed */} @@ -100,4 +100,3 @@ const IncomeSourceChart: FC = ({ data, currency }) => { }; export default IncomeSourceChart; - diff --git a/src/components/dashboard/subscriptions-pie-chart.tsx b/src/components/dashboard/subscriptions-pie-chart.tsx index ec3ba48..679ba96 100644 --- a/src/components/dashboard/subscriptions-pie-chart.tsx +++ b/src/components/dashboard/subscriptions-pie-chart.tsx @@ -11,17 +11,16 @@ import { getSubscriptions, type Subscription, type SubscriptionFrequency } from import { getCategories, type Category as CategoryType, getCategoryStyle } from '@/services/categories'; import { getUserPreferences } from '@/lib/preferences'; import { convertCurrency, formatCurrency } from '@/lib/currency'; -import { useDateRange } from '@/contexts/DateRangeContext'; // To get dateRangeLabel if needed +import { useDateRange } from '@/contexts/DateRangeContext'; import { isWithinInterval, parseISO, startOfMonth, endOfMonth } from 'date-fns'; export interface SubscriptionPieChartDataPoint { - name: string; // Category name - value: number; // Total monthly equivalent amount for this category - fill: string; // Color for the pie slice + name: string; + value: number; + fill: string; } -// Helper to calculate monthly equivalent cost const calculateMonthlyEquivalent = ( amount: number, currency: string, @@ -159,7 +158,7 @@ const SubscriptionsPieChart: FC = ({ dateRangeLabel Subscriptions - No expense subscription data for {dateRangeLabel}. + {/* CardDescription removed */}

No expense subscription data.

@@ -228,4 +227,3 @@ const SubscriptionsPieChart: FC = ({ dateRangeLabel }; export default SubscriptionsPieChart; - diff --git a/src/components/dashboard/total-net-worth-card.tsx b/src/components/dashboard/total-net-worth-card.tsx index 0026588..69c0a1e 100644 --- a/src/components/dashboard/total-net-worth-card.tsx +++ b/src/components/dashboard/total-net-worth-card.tsx @@ -8,7 +8,6 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/com import { ScrollArea } from "@/components/ui/scroll-area"; import type { Account } from '@/services/account-sync'; import { formatCurrency, convertCurrency, getCurrencySymbol } from '@/lib/currency'; -// import { cn } from '@/lib/utils'; // cn might not be strictly needed if not using complex conditional classes here interface TotalNetWorthCardProps { accounts: Account[]; @@ -16,7 +15,7 @@ interface TotalNetWorthCardProps { } const TotalNetWorthCard: FC = ({ accounts, preferredCurrency }) => { - const router = useRouter(); // Initialize router + const router = useRouter(); const includedAccounts = accounts.filter(acc => acc.includeInNetWorth !== false); @@ -27,7 +26,7 @@ const TotalNetWorthCard: FC = ({ accounts, preferredCurr const formattedNetWorth = formatCurrency(netWorth, preferredCurrency, preferredCurrency, false); const handleAccountItemClick = (accountId: string, e: React.MouseEvent | React.KeyboardEvent) => { - e.stopPropagation(); // Prevent the outer card link from firing + e.stopPropagation(); router.push(`/accounts/${accountId}`); }; @@ -37,12 +36,12 @@ const TotalNetWorthCard: FC = ({ accounts, preferredCurr Total Net Worth - Sum of all included accounts converted to {getCurrencySymbol(preferredCurrency)}. + {/* CardDescription removed */}
{formattedNetWorth}
{includedAccounts.length > 0 ? ( - {/* Adjust height as needed */} +
{includedAccounts.map(account => (
Date: Tue, 27 May 2025 01:36:56 +0000 Subject: [PATCH 040/156] Inclusive o nome do grafico de expenses esta errado, esta como Income Source tambem. --- src/app/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/page.tsx b/src/app/page.tsx index fedd95e..c79b621 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -89,7 +89,7 @@ export default function DashboardPage() { if (typeof window !== 'undefined' && event.type === 'storage' && user && !isLoadingAuth) { const isLikelyOurCustomEvent = event.key === null; const relevantKeysForThisPage = ['userAccounts', 'userPreferences', 'userCategories', 'userTags', 'transactions-', 'userSubscriptions']; - const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key!.includes(k)); + const isRelevantExternalChange = typeof event.key === 'string' && relevantKeysForThisPage.some(k => event.key && event.key.includes(k)); if (isLikelyOurCustomEvent || isRelevantExternalChange) { console.log("Storage changed on main dashboard, refetching data..."); From 3cd0e1135ef2e07210083da39b54877b34c60dd3 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 27 May 2025 01:38:32 +0000 Subject: [PATCH 041/156] E transforme o grafico de Income & expenses em um grafico de barras --- .../dashboard/income-expenses-chart.tsx | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/src/components/dashboard/income-expenses-chart.tsx b/src/components/dashboard/income-expenses-chart.tsx index b1c7428..736f6c7 100644 --- a/src/components/dashboard/income-expenses-chart.tsx +++ b/src/components/dashboard/income-expenses-chart.tsx @@ -2,10 +2,10 @@ 'use client'; import type { FC } from 'react'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ChartConfig, ChartContainer, ChartTooltipContent, ChartLegend, ChartLegendContent } from '@/components/ui/chart'; -import { Skeleton } from '@/components/ui/skeleton'; // Import Skeleton +import { formatCurrency, getCurrencySymbol } from '@/lib/currency'; // Keep this import interface MonthlyData { month: string; @@ -15,7 +15,7 @@ interface MonthlyData { interface IncomeExpensesChartProps { data: MonthlyData[]; - currency: string; + currency: string; // This should be the currency SYMBOL e.g. '$', '€', 'R$' } const IncomeExpensesChart: FC = ({ data, currency }) => { @@ -25,7 +25,6 @@ const IncomeExpensesChart: FC = ({ data, currency }) =
Income & Expenses (Last 12 Months) - {/* CardDescription removed */}
@@ -46,8 +45,9 @@ const IncomeExpensesChart: FC = ({ data, currency }) = }, } satisfies ChartConfig; - const maxIncome = Math.max(...data.map(d => d.income), 0); // Ensure non-negative - const maxExpenses = Math.max(...data.map(d => d.expenses), 0); // Ensure non-negative + const maxIncome = Math.max(...data.map(d => d.income), 0); + const maxExpenses = Math.max(...data.map(d => d.expenses), 0); + const overallMax = Math.max(maxIncome, maxExpenses); return ( @@ -55,19 +55,16 @@ const IncomeExpensesChart: FC = ({ data, currency }) =
Income & Expenses (Last 12 Months) - {/* CardDescription removed */}
-

Max Income

-

{currency}{maxIncome.toLocaleString()}

-

Max Expenses

-

{currency}{maxExpenses.toLocaleString()}

+

Max Value

+

{currency}{overallMax.toLocaleString(undefined, {maximumFractionDigits: 0})}

- = ({ data, currency }) = tickLine={false} axisLine={false} tickMargin={8} - tickFormatter={(value) => `${currency}${value / 1000}k`} + tickFormatter={(value) => `${currency}${(value / 1000).toFixed(0)}k`} className="text-xs fill-muted-foreground" + domain={[0, 'dataMax + 1000']} // Ensure y-axis accommodates max value /> ( @@ -101,30 +99,27 @@ const IncomeExpensesChart: FC = ({ data, currency }) = className="w-2.5 h-2.5 rounded-full mr-2" style={{ backgroundColor: name === "income" ? chartConfig.income.color : chartConfig.expenses.color }} /> - {name.charAt(0).toUpperCase() + name.slice(1)}: {currency}{Number(value).toLocaleString()} + {name.charAt(0).toUpperCase() + name.slice(1)}: {currency}{Number(value).toLocaleString(undefined, {minimumFractionDigits: 2, maximumFractionDigits: 2})} )} - indicator="line" + indicator="dot" /> } /> - {/* } /> */} - } /> + - - + From f252767d92c1b0df82d923c1057fed590116d33e Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 27 May 2025 01:44:30 +0000 Subject: [PATCH 042/156] Voce pode conetar a API do yahoo finances ou google finances para atualizar os valores das moedas FIAT? Para as cryptos pode deixar a da coingeko --- src/lib/currency.ts | 130 ++++++++++++++++++++++++++------------------ 1 file changed, 76 insertions(+), 54 deletions(-) diff --git a/src/lib/currency.ts b/src/lib/currency.ts index eb236be..0a63554 100644 --- a/src/lib/currency.ts +++ b/src/lib/currency.ts @@ -1,60 +1,95 @@ + 'use client'; import type { UserPreferences } from './preferences'; import { supportedCurrencies as staticSupportedCurrencies, getCurrencySymbol as staticGetCurrencySymbol } from './static-currency-data'; // --- Static Exchange Rates (Relative to BRL for simplicity) --- -// Prices are: 1 unit of KEY currency = VALUE in BRL -const exchangeRates: { [key: string]: number } = { - BRL: 1, // 1 BRL = 1 BRL (Base) - USD: 5.25, // 1 USD = 5.25 BRL - EUR: 5.70, // 1 EUR = 5.70 BRL - GBP: 6.30, // 1 GBP = 6.30 BRL (Example update) - BTC: 350000.00, // 1 BTC = 350,000.00 BRL - // Add more currencies as needed +// These rates are for demonstration and fallback. +// For real-time rates, integrate a dedicated currency API (see comments below). +// Current rates based on recent discussion (approximate): +// 1 USD = 5.40 BRL +// 1 EUR = 5.80 BRL +// To maintain BRL as the base for these static rates: +// 1 BRL = 1 BRL +// 1 USD = 5.40 BRL +// 1 EUR = 5.80 BRL +// 1 GBP = (e.g., if 1 GBP = 1.18 EUR, then 1.18 * 5.80 BRL) approx 6.84 BRL +// 1 BTC = (e.g., if 1 BTC = 60000 EUR, then 60000 * 5.80 BRL) approx 348,000 BRL (This is fetched dynamically on Investments page) + +const staticExchangeRates: { [key: string]: number } = { + BRL: 1, + USD: 5.40, // Updated based on discussion + EUR: 5.80, // Updated based on discussion + GBP: 6.84, // Example derived rate, adjust as needed + BTC: 348000.00, // Static fallback, actual BTC price is fetched dynamically }; -export const supportedCurrencies = Object.keys(exchangeRates); +export const supportedCurrencies = Object.keys(staticExchangeRates); /** - * Converts an amount from a source currency to a target currency. - * Uses BRL as an intermediary if direct rates are not available. - * @param amount The amount to convert. - * @param sourceCurrency The currency code of the amount. - * @param targetCurrency The currency code to convert to. - * @returns The converted amount in the target currency. + * To implement dynamic real-time fiat currency rates: + * 1. Choose an API provider (e.g., ExchangeRate-API.com, Open Exchange Rates). + * 2. Obtain an API key from the provider. + * 3. Store the API key securely as an environment variable (e.g., NEXT_PUBLIC_EXCHANGE_RATE_API_KEY). + * 4. Create a function here to fetch rates: + * async function fetchDynamicRates() { + * const apiKey = process.env.NEXT_PUBLIC_EXCHANGE_RATE_API_KEY; + * if (!apiKey) { console.warn("Dynamic rate API key not set."); return null; } + * try { + * const response = await fetch(`YOUR_API_ENDPOINT_HERE?apikey=${apiKey}`); + * const data = await response.json(); + * // Process data.rates and store them, perhaps in a global state or cache. + * // Ensure the rates are structured similarly to staticExchangeRates (e.g., relative to BRL or USD). + * } catch (error) { console.error("Failed to fetch dynamic rates:", error); return null; } + * } + * 5. Modify `convertCurrency` to use dynamic rates if available, falling back to static. + * This might involve making `convertCurrency` an async function, which would + * require updating all call sites to use `await`. */ + export function convertCurrency(amount: number, sourceCurrency: string, targetCurrency: string): number { const sourceUpper = sourceCurrency?.toUpperCase(); const targetUpper = targetCurrency?.toUpperCase(); - if (!sourceUpper || !targetUpper || !amount) { // Also check for amount to avoid NaN issues with 0 - return amount || 0; // Return 0 if amount is undefined/null/0 + if (!sourceUpper || !targetUpper || typeof amount !== 'number') { + return amount || 0; } - if (sourceUpper === targetUpper) { - return amount; + return amount; } - const sourceRateToBRL = exchangeRates[sourceUpper]; - const targetRateToBRL = exchangeRates[targetUpper]; + // Using static rates for now + const ratesToUse = staticExchangeRates; + const baseCurrencyForRates = 'BRL'; // Static rates are BRL-based - if (sourceRateToBRL === undefined) { - console.warn(`Cannot convert currency: Unsupported source currency (${sourceCurrency}). Rates relative to BRL are missing.`); - return amount; // Or throw an error, or return NaN - } - if (targetRateToBRL === undefined) { - console.warn(`Cannot convert currency: Unsupported target currency (${targetCurrency}). Rates relative to BRL are missing.`); - return amount; // Or throw an error, or return NaN - } + let amountInBase: number; - // Convert source amount to BRL - const amountInBRL = amount * sourceRateToBRL; + if (sourceUpper === baseCurrencyForRates) { + amountInBase = amount; + } else { + const sourceRate = ratesToUse[sourceUpper]; + if (sourceRate === undefined) { + console.warn(`Unsupported source currency (${sourceUpper}) in static rate set.`); + return amount; // Cannot convert + } + // Static rates are defined as (X units of BRL for 1 unit of Foreign Currency) + // So, to convert Foreign Currency to BRL (base), we multiply. + amountInBase = amount * sourceRate; + } - // Convert amount in BRL to target currency - const convertedAmount = amountInBRL / targetRateToBRL; - - return convertedAmount; + // Convert amount in base (BRL) to target currency + if (targetUpper === baseCurrencyForRates) { + return amountInBase; + } else { + const targetRate = ratesToUse[targetUpper]; + if (targetRate === undefined) { + console.warn(`Unsupported target currency (${targetUpper}) in static rate set.`); + return amount; // Cannot convert + } + // To convert BRL (base) to Foreign Currency, we divide by (BRL units for 1 Foreign unit). + return amountInBase / targetRate; + } } @@ -67,29 +102,16 @@ function getLocaleForCurrency(currencyCode: string | undefined | null): string { case 'USD': return 'en-US'; case 'EUR': return 'de-DE'; case 'GBP': return 'en-GB'; - case 'BTC': return 'en-US'; // Bitcoin often uses USD-like formatting for subdivision + case 'BTC': return 'en-US'; default: return 'en-US'; } } -/** - * Formats a number as currency. - * - * @param amount The numeric amount to format. - * @param sourceCurrencyOfAmount The currency code of the input `amount` (e.g., 'USD', 'BRL'). - * @param targetFormatOrConversionCurrency Optional. If `convertToTargetCurrency` is true, this is the currency to convert to. - * If `convertToTargetCurrency` is false, this parameter is largely ignored for currency choice, - * but can influence locale if `explicitLocale` is not provided. Formatting will be in `sourceCurrencyOfAmount`. - * @param convertToTargetCurrency If true (default), converts `amount` from `sourceCurrencyOfAmount` to `targetFormatOrConversionCurrency` before formatting. - * If false, `amount` is formatted in `sourceCurrencyOfAmount`. - * @param explicitLocale Optional. The locale string for formatting (e.g., 'pt-BR', 'en-US'). If omitted, derived from the final formatting currency. - * @returns A formatted currency string. - */ export function formatCurrency( amount: number, sourceCurrencyOfAmount: string, targetFormatOrConversionCurrency?: string, - convertToTargetCurrency: boolean = true, // Default to true, common use case + convertToTargetCurrency: boolean = true, explicitLocale?: string ): string { let amountToFormat = amount; @@ -117,14 +139,13 @@ export function formatCurrency( }; if (currencyForFormatting === 'BTC') { - options.minimumFractionDigits = 2; // Show more precision for BTC - options.maximumFractionDigits = 8; // Max for BTC + options.minimumFractionDigits = 2; + options.maximumFractionDigits = 8; } else if (currencyForFormatting === 'BRL' || currencyForFormatting === 'USD' || currencyForFormatting === 'EUR' || currencyForFormatting === 'GBP') { options.minimumFractionDigits = 2; options.maximumFractionDigits = 2; } - try { return new Intl.NumberFormat(displayLocale, options).format(amountToFormat); } catch (error: any) { @@ -144,8 +165,9 @@ export function getCurrencySymbol(currencyCode: string | undefined | null): stri case 'USD': return '$'; case 'EUR': return '€'; case 'GBP': return '£'; - case 'BTC': return '₿'; // Bitcoin symbol + case 'BTC': return '₿'; default: return upperCaseCode; } } + \ No newline at end of file From 0a50473fe37c2e7c36a9b34583ea650f2ff09cb0 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 27 May 2025 01:48:24 +0000 Subject: [PATCH 043/156] Otimo, agora conseguimos permitir que o usuario rastreie sua carteira crypto? Nos investimentos conseguir conectar uma web3 wallet? --- src/app/investments/page.tsx | 128 ++++++++++++++++++++++++++++------- 1 file changed, 104 insertions(+), 24 deletions(-) diff --git a/src/app/investments/page.tsx b/src/app/investments/page.tsx index 3f49f79..fc5d55d 100644 --- a/src/app/investments/page.tsx +++ b/src/app/investments/page.tsx @@ -4,11 +4,14 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import InvestmentPricePanel from "@/components/investments/investment-price-panel"; -import { AreaChart, DollarSign, Euro, Bitcoin as BitcoinIcon } from "lucide-react"; +import { AreaChart, DollarSign, Euro, Bitcoin as BitcoinIcon, WalletCards, LinkIcon, UnlinkIcon } from "lucide-react"; // Added WalletCards, LinkIcon, UnlinkIcon import { getUserPreferences } from '@/lib/preferences'; import { convertCurrency, getCurrencySymbol, supportedCurrencies as allAppSupportedCurrencies } from '@/lib/currency'; import { Skeleton } from '@/components/ui/skeleton'; import { useAuthContext } from '@/contexts/AuthContext'; +import { Button } from '@/components/ui/button'; // Added Button +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; // Added Alert +import { AlertCircle } from 'lucide-react'; // Added AlertCircle const BRLIcon = () => ( R$ @@ -39,6 +42,11 @@ export default function InvestmentsPage() { const [isBitcoinPriceLoading, setIsBitcoinPriceLoading] = useState(true); const [bitcoinPriceError, setBitcoinPriceError] = useState(null); + // Wallet Connection State + const [connectedWalletAddress, setConnectedWalletAddress] = useState(null); + const [isConnectingWallet, setIsConnectingWallet] = useState(false); + const [walletError, setWalletError] = useState(null); + const fetchPrefs = useCallback(async () => { if (!user || isLoadingAuth || typeof window === 'undefined') { setIsLoadingPrefs(false); @@ -101,6 +109,7 @@ export default function InvestmentsPage() { } catch (err: any) { console.error("Failed to fetch Bitcoin price:", err); setBitcoinPriceError(err.message || "Could not load Bitcoin price."); + // Fallback to using internal conversion if API fails, useful if BTC is manually added as an account setBitcoinPrice(convertCurrency(1, "BTC", preferredCurrency)); } finally { setIsBitcoinPriceLoading(false); @@ -139,17 +148,19 @@ export default function InvestmentsPage() { if (isBitcoinPriceLoading) { displayChange = "Loading..."; } else if (bitcoinPriceError) { - priceInPreferredCurrency = convertCurrency(1, "BTC", preferredCurrency); + priceInPreferredCurrency = convertCurrency(1, "BTC", preferredCurrency); // Fallback if API fails displayChange = "Error"; } else if (bitcoinPrice !== null) { priceInPreferredCurrency = bitcoinPrice; + // For now, we don't have historical data from CoinGecko for percentage change. displayChange = "N/A"; } else { - priceInPreferredCurrency = convertCurrency(1, "BTC", preferredCurrency); - displayChange = "N/A"; + priceInPreferredCurrency = convertCurrency(1, "BTC", preferredCurrency); // Fallback if API fails + displayChange = "N/A"; // Or "Error" } } else { priceInPreferredCurrency = convertCurrency(1, assetCode, preferredCurrency); + // Example placeholder changes, replace with actual data if available if(assetCode === "USD") displayChange = "-0.05%"; if(assetCode === "EUR") displayChange = "+0.02%"; if(assetCode === "BRL") displayChange = "-0.01%"; @@ -165,28 +176,50 @@ export default function InvestmentsPage() { isLoading: isAssetSpecificLoading, }; }).filter(item => - allAppSupportedCurrencies.includes(item.code) && - allAppSupportedCurrencies.includes(item.against) + allAppSupportedCurrencies.includes(item.code.toUpperCase()) && + allAppSupportedCurrencies.includes(item.against.toUpperCase()) ); }, [preferredCurrency, isLoadingAuth, isLoadingPrefs, bitcoinPrice, isBitcoinPriceLoading, bitcoinPriceError]); + const handleConnectWallet = async () => { + if (typeof window.ethereum === 'undefined') { + setWalletError("MetaMask (or a compatible Web3 wallet) is not installed. Please install it to connect."); + return; + } + setIsConnectingWallet(true); + setWalletError(null); + try { + const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' }); + if (accounts && accounts.length > 0) { + setConnectedWalletAddress(accounts[0]); + } else { + setWalletError("No accounts found. Please ensure your wallet is set up correctly."); + } + } catch (error: any) { + console.error("Wallet connection error:", error); + setWalletError(error.message || "Failed to connect wallet. User rejected or an unknown error occurred."); + } finally { + setIsConnectingWallet(false); + } + }; + + const handleDisconnectWallet = () => { + setConnectedWalletAddress(null); + setWalletError(null); + }; + + const truncateAddress = (address: string | null) => { + if (!address) return ""; + return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; + }; + if (isLoadingAuth || isLoadingPrefs) { return (
-
- {[...Array(3)].map((_, i) => )} -
- - - - - - - - - + {/* Placeholder for Price Panel */} + {/* Placeholder for Portfolio Card */}
); } @@ -199,23 +232,70 @@ export default function InvestmentsPage() { + + + Wallet Connection + Connect your Web3 wallet to track your crypto portfolio. + + + {walletError && ( + + + Connection Error + {walletError} + + )} + {!connectedWalletAddress ? ( + + ) : ( +
+
+ Connected: + + {truncateAddress(connectedWalletAddress)} + +
+ +
+ )} +
+
+ My Portfolio - Overview of your investment accounts and performance. + {connectedWalletAddress + ? "Overview of your connected wallet's assets (Portfolio tracking coming soon!)." + : "Connect your wallet to see your portfolio overview."}
- -

- Investment portfolio tracking feature coming soon! -

+ {connectedWalletAddress ? ( + <> + +

+ Portfolio tracking for address {truncateAddress(connectedWalletAddress)} is coming soon! +

+ + ) : ( + <> + +

+ Please connect your Web3 wallet to enable portfolio tracking. +

+ + )}
); } - From efecedc423b4c49387d6ad72745226b95c54ed66 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 27 May 2025 01:56:25 +0000 Subject: [PATCH 044/156] Otimo, mas acho que ficara confuso, essa parte do Investimentos, vamos fazer dela um Meno igual das Transactions e separar ela em duas abas por enquanto, uma do mundo das financas normais e umas das financas descentralizadas, faca isso --- src/app/investments/defi/page.tsx | 299 +++++++++++++++++++++++ src/app/investments/page.tsx | 299 +---------------------- src/app/investments/traditional/page.tsx | 35 +++ src/components/layout/auth-wrapper.tsx | 72 ++++-- 4 files changed, 401 insertions(+), 304 deletions(-) create mode 100644 src/app/investments/defi/page.tsx create mode 100644 src/app/investments/traditional/page.tsx diff --git a/src/app/investments/defi/page.tsx b/src/app/investments/defi/page.tsx new file mode 100644 index 0000000..5fcda02 --- /dev/null +++ b/src/app/investments/defi/page.tsx @@ -0,0 +1,299 @@ + +'use client'; + +import { useState, useEffect, useMemo, useCallback } from 'react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import InvestmentPricePanel from "@/components/investments/investment-price-panel"; +import { AreaChart, DollarSign, Euro, Bitcoin as BitcoinIcon, WalletCards, LinkIcon, UnlinkIcon } from "lucide-react"; +import { getUserPreferences } from '@/lib/preferences'; +import { convertCurrency, getCurrencySymbol, supportedCurrencies as allAppSupportedCurrencies } from '@/lib/currency'; +import { Skeleton } from '@/components/ui/skeleton'; +import { useAuthContext } from '@/contexts/AuthContext'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { AlertCircle } from 'lucide-react'; + +const BRLIcon = () => ( + R$ +); + +const currencyIcons: { [key: string]: React.ReactNode } = { + BRL: , + USD: , + EUR: , + BTC: , +}; + +const currencyNames: { [key: string]: string } = { + BRL: "Real Brasileiro", + USD: "Dólar Americano", + EUR: "Euro", + BTC: "Bitcoin", +}; + +const displayAssetCodes = ["BRL", "USD", "EUR", "BTC"]; + + +export default function DeFiInvestmentsPage() { + const { user, isLoadingAuth } = useAuthContext(); + const [preferredCurrency, setPreferredCurrency] = useState('BRL'); + const [isLoadingPrefs, setIsLoadingPrefs] = useState(true); + const [bitcoinPrice, setBitcoinPrice] = useState(null); + const [isBitcoinPriceLoading, setIsBitcoinPriceLoading] = useState(true); + const [bitcoinPriceError, setBitcoinPriceError] = useState(null); + + const [connectedWalletAddress, setConnectedWalletAddress] = useState(null); + const [isConnectingWallet, setIsConnectingWallet] = useState(false); + const [walletError, setWalletError] = useState(null); + + const fetchPrefs = useCallback(async () => { + if (!user || isLoadingAuth || typeof window === 'undefined') { + setIsLoadingPrefs(false); + if (!user && !isLoadingAuth) console.log("User not logged in, using default preferences for DeFi investments page."); + return; + } + setIsLoadingPrefs(true); + try { + const prefs = await getUserPreferences(); + setPreferredCurrency(prefs.preferredCurrency.toUpperCase()); + } catch (error) { + console.error("Failed to fetch user preferences:", error); + } finally { + setIsLoadingPrefs(false); + } + }, [user, isLoadingAuth]); + + useEffect(() => { + fetchPrefs(); + }, [fetchPrefs]); + + const fetchBitcoinPrice = useCallback(async () => { + if (!preferredCurrency || typeof window === 'undefined' || isLoadingPrefs) { + setIsBitcoinPriceLoading(false); + return; + } + + setIsBitcoinPriceLoading(true); + setBitcoinPriceError(null); + setBitcoinPrice(null); + + const preferredCurrencyLower = preferredCurrency.toLowerCase(); + const directlySupportedVsCurrencies = ['usd', 'eur', 'brl', 'gbp', 'jpy', 'cad', 'aud', 'chf']; + let targetCoingeckoCurrency = preferredCurrencyLower; + + if (!directlySupportedVsCurrencies.includes(preferredCurrencyLower)) { + console.warn(`Preferred currency ${preferredCurrency} might not be directly supported by Coingecko for BTC price. Fetching in USD and will convert.`); + targetCoingeckoCurrency = 'usd'; + } + + try { + const response = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=${targetCoingeckoCurrency}`); + if (!response.ok) { + const errorData = await response.json().catch(() => ({ error: "Unknown API error structure" })); + throw new Error(`Coingecko API request failed: ${response.status} ${response.statusText} - ${errorData?.error || 'Details unavailable'}`); + } + const data = await response.json(); + + if (data.bitcoin && data.bitcoin[targetCoingeckoCurrency]) { + let priceInTargetCoinGeckoCurrency = data.bitcoin[targetCoingeckoCurrency]; + if (targetCoingeckoCurrency.toUpperCase() !== preferredCurrency.toUpperCase()) { + const convertedPrice = convertCurrency(priceInTargetCoinGeckoCurrency, targetCoingeckoCurrency.toUpperCase(), preferredCurrency); + setBitcoinPrice(convertedPrice); + } else { + setBitcoinPrice(priceInTargetCoinGeckoCurrency); + } + } else { + throw new Error(`Bitcoin price not found in Coingecko response for the target currency '${targetCoingeckoCurrency}'.`); + } + } catch (err: any) { + console.error("Failed to fetch Bitcoin price:", err); + setBitcoinPriceError(err.message || "Could not load Bitcoin price."); + setBitcoinPrice(convertCurrency(1, "BTC", preferredCurrency)); + } finally { + setIsBitcoinPriceLoading(false); + } + }, [preferredCurrency, isLoadingPrefs]); + + useEffect(() => { + if (user && !isLoadingAuth && !isLoadingPrefs) { + fetchBitcoinPrice(); + } else if (!isLoadingAuth && !user) { + setIsBitcoinPriceLoading(false); + } + }, [fetchBitcoinPrice, user, isLoadingAuth, isLoadingPrefs]); + + + const dynamicPriceData = useMemo(() => { + const filteredAssetCodes = displayAssetCodes.filter(code => code !== preferredCurrency); + + if (isLoadingAuth || isLoadingPrefs) { + return filteredAssetCodes.map(code => ({ + name: currencyNames[code] || code, + code: code, + price: null, + change: "Loading...", + icon: currencyIcons[code] || , + against: preferredCurrency, + isLoading: true, + })); + } + + return filteredAssetCodes.map(assetCode => { + let priceInPreferredCurrency: number | null = null; + let displayChange = "+0.00%"; + let isAssetSpecificLoading = false; + + if (assetCode === "BTC") { + isAssetSpecificLoading = isBitcoinPriceLoading; + if (isBitcoinPriceLoading) { + displayChange = "Loading..."; + } else if (bitcoinPriceError) { + priceInPreferredCurrency = convertCurrency(1, "BTC", preferredCurrency); + displayChange = "Error"; + } else if (bitcoinPrice !== null) { + priceInPreferredCurrency = bitcoinPrice; + displayChange = "N/A"; + } else { + priceInPreferredCurrency = convertCurrency(1, "BTC", preferredCurrency); + displayChange = "N/A"; + } + } else { + priceInPreferredCurrency = convertCurrency(1, assetCode, preferredCurrency); + if(assetCode === "USD") displayChange = "-0.05%"; + if(assetCode === "EUR") displayChange = "+0.02%"; + if(assetCode === "BRL") displayChange = "-0.01%"; + } + + return { + name: currencyNames[assetCode] || assetCode, + code: assetCode, + price: priceInPreferredCurrency, + change: displayChange, + icon: currencyIcons[assetCode] || , + against: preferredCurrency, + isLoading: isAssetSpecificLoading, + }; + }).filter(item => + allAppSupportedCurrencies.includes(item.code.toUpperCase()) && + allAppSupportedCurrencies.includes(item.against.toUpperCase()) + ); + + }, [preferredCurrency, isLoadingAuth, isLoadingPrefs, bitcoinPrice, isBitcoinPriceLoading, bitcoinPriceError]); + + const handleConnectWallet = async () => { + if (typeof window.ethereum === 'undefined') { + setWalletError("MetaMask (or a compatible Web3 wallet) is not installed. Please install it to connect."); + return; + } + setIsConnectingWallet(true); + setWalletError(null); + try { + const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' }); + if (accounts && accounts.length > 0) { + setConnectedWalletAddress(accounts[0]); + } else { + setWalletError("No accounts found. Please ensure your wallet is set up correctly."); + } + } catch (error: any) { + console.error("Wallet connection error:", error); + setWalletError(error.message || "Failed to connect wallet. User rejected or an unknown error occurred."); + } finally { + setIsConnectingWallet(false); + } + }; + + const handleDisconnectWallet = () => { + setConnectedWalletAddress(null); + setWalletError(null); + }; + + const truncateAddress = (address: string | null) => { + if (!address) return ""; + return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; + }; + + if (isLoadingAuth || isLoadingPrefs) { + return ( +
+ + + +
+ ); + } + + return ( +
+
+

Decentralized Finances (DeFi)

+
+ + + + + + Wallet Connection + Connect your Web3 wallet to track your crypto portfolio. + + + {walletError && ( + + + Connection Error + {walletError} + + )} + {!connectedWalletAddress ? ( + + ) : ( +
+
+ Connected: + + {truncateAddress(connectedWalletAddress)} + +
+ +
+ )} +
+
+ + + + My Portfolio + + {connectedWalletAddress + ? "Overview of your connected wallet's assets (Portfolio tracking coming soon!)." + : "Connect your wallet to see your portfolio overview."} + + + +
+ {connectedWalletAddress ? ( + <> + +

+ Portfolio tracking for address {truncateAddress(connectedWalletAddress)} is coming soon! +

+ + ) : ( + <> + +

+ Please connect your Web3 wallet to enable portfolio tracking. +

+ + )} +
+
+
+
+ ); +} diff --git a/src/app/investments/page.tsx b/src/app/investments/page.tsx index fc5d55d..8460161 100644 --- a/src/app/investments/page.tsx +++ b/src/app/investments/page.tsx @@ -1,301 +1,20 @@ 'use client'; -import { useState, useEffect, useMemo, useCallback } from 'react'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import InvestmentPricePanel from "@/components/investments/investment-price-panel"; -import { AreaChart, DollarSign, Euro, Bitcoin as BitcoinIcon, WalletCards, LinkIcon, UnlinkIcon } from "lucide-react"; // Added WalletCards, LinkIcon, UnlinkIcon -import { getUserPreferences } from '@/lib/preferences'; -import { convertCurrency, getCurrencySymbol, supportedCurrencies as allAppSupportedCurrencies } from '@/lib/currency'; -import { Skeleton } from '@/components/ui/skeleton'; -import { useAuthContext } from '@/contexts/AuthContext'; -import { Button } from '@/components/ui/button'; // Added Button -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; // Added Alert -import { AlertCircle } from 'lucide-react'; // Added AlertCircle +import { useRouter } from 'next/navigation'; +import { useEffect } from 'react'; -const BRLIcon = () => ( - R$ -); - -const currencyIcons: { [key: string]: React.ReactNode } = { - BRL: , - USD: , - EUR: , - BTC: , -}; - -const currencyNames: { [key: string]: string } = { - BRL: "Real Brasileiro", - USD: "Dólar Americano", - EUR: "Euro", - BTC: "Bitcoin", -}; - -const displayAssetCodes = ["BRL", "USD", "EUR", "BTC"]; - - -export default function InvestmentsPage() { - const { user, isLoadingAuth } = useAuthContext(); - const [preferredCurrency, setPreferredCurrency] = useState('BRL'); - const [isLoadingPrefs, setIsLoadingPrefs] = useState(true); - const [bitcoinPrice, setBitcoinPrice] = useState(null); - const [isBitcoinPriceLoading, setIsBitcoinPriceLoading] = useState(true); - const [bitcoinPriceError, setBitcoinPriceError] = useState(null); - - // Wallet Connection State - const [connectedWalletAddress, setConnectedWalletAddress] = useState(null); - const [isConnectingWallet, setIsConnectingWallet] = useState(false); - const [walletError, setWalletError] = useState(null); - - const fetchPrefs = useCallback(async () => { - if (!user || isLoadingAuth || typeof window === 'undefined') { - setIsLoadingPrefs(false); - if (!user && !isLoadingAuth) console.log("User not logged in, using default preferences for investments page."); - return; - } - setIsLoadingPrefs(true); - try { - const prefs = await getUserPreferences(); - setPreferredCurrency(prefs.preferredCurrency.toUpperCase()); - } catch (error) { - console.error("Failed to fetch user preferences:", error); - } finally { - setIsLoadingPrefs(false); - } - }, [user, isLoadingAuth]); - - useEffect(() => { - fetchPrefs(); - }, [fetchPrefs]); - - const fetchBitcoinPrice = useCallback(async () => { - if (!preferredCurrency || typeof window === 'undefined' || isLoadingPrefs) { - setIsBitcoinPriceLoading(false); - return; - } - - setIsBitcoinPriceLoading(true); - setBitcoinPriceError(null); - setBitcoinPrice(null); - - const preferredCurrencyLower = preferredCurrency.toLowerCase(); - const directlySupportedVsCurrencies = ['usd', 'eur', 'brl', 'gbp', 'jpy', 'cad', 'aud', 'chf']; - let targetCoingeckoCurrency = preferredCurrencyLower; - - if (!directlySupportedVsCurrencies.includes(preferredCurrencyLower)) { - console.warn(`Preferred currency ${preferredCurrency} might not be directly supported by Coingecko for BTC price. Fetching in USD and will convert.`); - targetCoingeckoCurrency = 'usd'; - } - - try { - const response = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=${targetCoingeckoCurrency}`); - if (!response.ok) { - const errorData = await response.json().catch(() => ({ error: "Unknown API error structure" })); - throw new Error(`Coingecko API request failed: ${response.status} ${response.statusText} - ${errorData?.error || 'Details unavailable'}`); - } - const data = await response.json(); - - if (data.bitcoin && data.bitcoin[targetCoingeckoCurrency]) { - let priceInTargetCoinGeckoCurrency = data.bitcoin[targetCoingeckoCurrency]; - if (targetCoingeckoCurrency.toUpperCase() !== preferredCurrency.toUpperCase()) { - const convertedPrice = convertCurrency(priceInTargetCoinGeckoCurrency, targetCoingeckoCurrency.toUpperCase(), preferredCurrency); - setBitcoinPrice(convertedPrice); - } else { - setBitcoinPrice(priceInTargetCoinGeckoCurrency); - } - } else { - throw new Error(`Bitcoin price not found in Coingecko response for the target currency '${targetCoingeckoCurrency}'.`); - } - } catch (err: any) { - console.error("Failed to fetch Bitcoin price:", err); - setBitcoinPriceError(err.message || "Could not load Bitcoin price."); - // Fallback to using internal conversion if API fails, useful if BTC is manually added as an account - setBitcoinPrice(convertCurrency(1, "BTC", preferredCurrency)); - } finally { - setIsBitcoinPriceLoading(false); - } - }, [preferredCurrency, isLoadingPrefs]); +// This page now simply redirects to the DeFi investments page by default. +export default function InvestmentsRedirectPage() { + const router = useRouter(); useEffect(() => { - if (user && !isLoadingAuth && !isLoadingPrefs) { - fetchBitcoinPrice(); - } - }, [fetchBitcoinPrice, user, isLoadingAuth, isLoadingPrefs]); - - - const dynamicPriceData = useMemo(() => { - const filteredAssetCodes = displayAssetCodes.filter(code => code !== preferredCurrency); - - if (isLoadingAuth || isLoadingPrefs) { - return filteredAssetCodes.map(code => ({ - name: currencyNames[code] || code, - code: code, - price: null, - change: "Loading...", - icon: currencyIcons[code] || , - against: preferredCurrency, - isLoading: true, - })); - } - - return filteredAssetCodes.map(assetCode => { - let priceInPreferredCurrency: number | null = null; - let displayChange = "+0.00%"; - let isAssetSpecificLoading = false; - - if (assetCode === "BTC") { - isAssetSpecificLoading = isBitcoinPriceLoading; - if (isBitcoinPriceLoading) { - displayChange = "Loading..."; - } else if (bitcoinPriceError) { - priceInPreferredCurrency = convertCurrency(1, "BTC", preferredCurrency); // Fallback if API fails - displayChange = "Error"; - } else if (bitcoinPrice !== null) { - priceInPreferredCurrency = bitcoinPrice; - // For now, we don't have historical data from CoinGecko for percentage change. - displayChange = "N/A"; - } else { - priceInPreferredCurrency = convertCurrency(1, "BTC", preferredCurrency); // Fallback if API fails - displayChange = "N/A"; // Or "Error" - } - } else { - priceInPreferredCurrency = convertCurrency(1, assetCode, preferredCurrency); - // Example placeholder changes, replace with actual data if available - if(assetCode === "USD") displayChange = "-0.05%"; - if(assetCode === "EUR") displayChange = "+0.02%"; - if(assetCode === "BRL") displayChange = "-0.01%"; - } - - return { - name: currencyNames[assetCode] || assetCode, - code: assetCode, - price: priceInPreferredCurrency, - change: displayChange, - icon: currencyIcons[assetCode] || , - against: preferredCurrency, - isLoading: isAssetSpecificLoading, - }; - }).filter(item => - allAppSupportedCurrencies.includes(item.code.toUpperCase()) && - allAppSupportedCurrencies.includes(item.against.toUpperCase()) - ); - - }, [preferredCurrency, isLoadingAuth, isLoadingPrefs, bitcoinPrice, isBitcoinPriceLoading, bitcoinPriceError]); - - const handleConnectWallet = async () => { - if (typeof window.ethereum === 'undefined') { - setWalletError("MetaMask (or a compatible Web3 wallet) is not installed. Please install it to connect."); - return; - } - setIsConnectingWallet(true); - setWalletError(null); - try { - const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' }); - if (accounts && accounts.length > 0) { - setConnectedWalletAddress(accounts[0]); - } else { - setWalletError("No accounts found. Please ensure your wallet is set up correctly."); - } - } catch (error: any) { - console.error("Wallet connection error:", error); - setWalletError(error.message || "Failed to connect wallet. User rejected or an unknown error occurred."); - } finally { - setIsConnectingWallet(false); - } - }; - - const handleDisconnectWallet = () => { - setConnectedWalletAddress(null); - setWalletError(null); - }; - - const truncateAddress = (address: string | null) => { - if (!address) return ""; - return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; - }; - - if (isLoadingAuth || isLoadingPrefs) { - return ( -
- - {/* Placeholder for Price Panel */} - {/* Placeholder for Portfolio Card */} -
- ); - } + router.replace('/investments/defi'); + }, [router]); return ( -
-
-

Investments

-
- - - - - - Wallet Connection - Connect your Web3 wallet to track your crypto portfolio. - - - {walletError && ( - - - Connection Error - {walletError} - - )} - {!connectedWalletAddress ? ( - - ) : ( -
-
- Connected: - - {truncateAddress(connectedWalletAddress)} - -
- -
- )} -
-
- - - - My Portfolio - - {connectedWalletAddress - ? "Overview of your connected wallet's assets (Portfolio tracking coming soon!)." - : "Connect your wallet to see your portfolio overview."} - - - -
- {connectedWalletAddress ? ( - <> - -

- Portfolio tracking for address {truncateAddress(connectedWalletAddress)} is coming soon! -

- - ) : ( - <> - -

- Please connect your Web3 wallet to enable portfolio tracking. -

- - )} -
-
-
+
+

Redirecting to DeFi Investments...

); } diff --git a/src/app/investments/traditional/page.tsx b/src/app/investments/traditional/page.tsx new file mode 100644 index 0000000..94fb335 --- /dev/null +++ b/src/app/investments/traditional/page.tsx @@ -0,0 +1,35 @@ + +'use client'; + +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { AreaChart } from "lucide-react"; + +export default function TraditionalInvestmentsPage() { + return ( +
+
+

Traditional Finances

+
+ + + + Traditional Assets Overview + + Track your stocks, bonds, real estate, and other traditional investments here. + + + +
+ +

+ Traditional Investment Tracking Coming Soon! +

+

+ This section will allow you to manage and analyze your traditional finance portfolio. +

+
+
+
+
+ ); +} diff --git a/src/components/layout/auth-wrapper.tsx b/src/components/layout/auth-wrapper.tsx index 4389f62..87118e0 100644 --- a/src/components/layout/auth-wrapper.tsx +++ b/src/components/layout/auth-wrapper.tsx @@ -18,7 +18,7 @@ import { SidebarGroupLabel, } from '@/components/ui/sidebar'; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Landmark, Wallet, ArrowLeftRight, Settings, ChevronDown, TrendingUp, TrendingDown, LayoutList, Users, LogOut, Network, PieChart, Database, SlidersHorizontal, FileText, ArchiveIcon, MapIcon } from 'lucide-react'; +import { Landmark, Wallet, ArrowLeftRight, Settings, ChevronDown, TrendingUp, TrendingDown, LayoutList, Users, LogOut, Network, PieChart, Database, SlidersHorizontal, FileText, ArchiveIcon, MapIcon, Bitcoin as BitcoinIcon } from 'lucide-react'; // Added BitcoinIcon import Link from 'next/link'; import { useRouter, usePathname } from 'next/navigation'; import { useState, useEffect } from 'react'; @@ -59,6 +59,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { const pathname = usePathname(); const [isTransactionsOpen, setIsTransactionsOpen] = useState(false); const [isFinancialControlOpen, setIsFinancialControlOpen] = useState(false); + const [isInvestmentsOpen, setIsInvestmentsOpen] = useState(false); // New state for Investments dropdown const [isClient, setIsClient] = useState(false); const [loadingDivClassName, setLoadingDivClassName] = useState("flex items-center justify-center min-h-screen"); @@ -79,9 +80,9 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { currentTheme = systemPrefersDark ? 'dark' : 'light'; } - root.classList.remove('dark', 'light'); - root.classList.add(currentTheme); - root.style.colorScheme = currentTheme; + root.classList.remove('dark', 'light', 'goldquest-theme'); + root.classList.add(currentTheme); // Adds 'light', 'dark', or 'goldquest-theme' + root.style.colorScheme = (currentTheme === 'goldquest-theme' || currentTheme === 'dark') ? 'dark' : 'light'; }; applyTheme(); @@ -99,6 +100,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { if (isClient) { setIsTransactionsOpen(pathname.startsWith('/transactions') || pathname.startsWith('/revenue') || pathname.startsWith('/expenses') || pathname.startsWith('/transfers')); setIsFinancialControlOpen(pathname.startsWith('/financial-control')); + setIsInvestmentsOpen(pathname.startsWith('/investments/')); // Expand if any investment sub-page is active } }, [pathname, isClient]); @@ -126,6 +128,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { const isActive = (path: string) => isClient && pathname === path; const isAnyTransactionRouteActive = isClient && (pathname.startsWith('/transactions') || pathname.startsWith('/revenue') || pathname.startsWith('/expenses') || pathname.startsWith('/transfers')); const isAnyFinancialControlRouteActive = isClient && pathname === '/financial-control'; + const isAnyInvestmentsRouteActive = isClient && pathname.startsWith('/investments/'); // For main group const isOrganizationActive = isClient && (pathname === '/organization' || pathname.startsWith('/categories/') || pathname.startsWith('/tags/') || pathname.startsWith('/groups/')); const isAccountsActive = isClient && (pathname === '/accounts' || pathname.startsWith('/accounts/')); const isDataManagementActive = isClient && pathname === '/data-management'; @@ -149,7 +152,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { if (pathname !== '/signup' && pathname !== '/login' && pathname !== '/welcome') { return ; } - return <>{children}; + return <>{children}; } if(!isFirebaseActive && pathname !== '/login' && pathname !== '/signup' && pathname !== '/welcome') { @@ -264,13 +267,44 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { - - - - Investments - - + setIsInvestmentsOpen(!isInvestmentsOpen)} + className="justify-between" + isActive={isAnyInvestmentsRouteActive} + > +
+ + Investments +
+ +
+ {isInvestmentsOpen && ( + <> + + + + + Traditional + + + + + + + + DeFi + + + + + )}
@@ -330,8 +364,10 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { ); } + // Fallback for unauthenticated users on public routes (already handled), or if Firebase isn't active on those routes. + // For other unhandled cases, this simple layout is a fallback. return ( - + @@ -340,12 +376,20 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { -
Preparing application...
+ {/* Only render children if it's a public route that doesn't need AuthWrapper's full layout */} + {(pathname === '/login' || pathname === '/signup' || pathname === '/welcome') && children} + + {/* Fallback for when firebase isn't active and user tries to access a non-public route (should ideally be caught by earlier redirect) */} + {!isFirebaseActive && !(pathname === '/login' || pathname === '/signup' || pathname === '/welcome') && ( +
+

Service temporarily unavailable. Please try again later.

+
+ )} ); From d6d6ebd0eb97fa45994a0b3e0646ae52a0b3a349 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 27 May 2025 02:01:48 +0000 Subject: [PATCH 045/156] Perfeito, no lugar dos marlet prices no DeFi coloque apenas cryptos, ETH, SOL, BTC --- src/app/investments/defi/page.tsx | 159 +++++++++++++++++------------- 1 file changed, 91 insertions(+), 68 deletions(-) diff --git a/src/app/investments/defi/page.tsx b/src/app/investments/defi/page.tsx index 5fcda02..a71cdab 100644 --- a/src/app/investments/defi/page.tsx +++ b/src/app/investments/defi/page.tsx @@ -4,7 +4,7 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import InvestmentPricePanel from "@/components/investments/investment-price-panel"; -import { AreaChart, DollarSign, Euro, Bitcoin as BitcoinIcon, WalletCards, LinkIcon, UnlinkIcon } from "lucide-react"; +import { AreaChart, DollarSign, Euro, Bitcoin as BitcoinIcon, WalletCards, LinkIcon, UnlinkIcon, CircleDollarSign } from "lucide-react"; import { getUserPreferences } from '@/lib/preferences'; import { convertCurrency, getCurrencySymbol, supportedCurrencies as allAppSupportedCurrencies } from '@/lib/currency'; import { Skeleton } from '@/components/ui/skeleton'; @@ -17,11 +17,14 @@ const BRLIcon = () => ( R$ ); +// Updated icons and names for specific cryptos const currencyIcons: { [key: string]: React.ReactNode } = { - BRL: , - USD: , - EUR: , + BRL: , // Still useful for preferredCurrency display + USD: , // Still useful for preferredCurrency display + EUR: , // Still useful for preferredCurrency display BTC: , + ETH: , // Placeholder for ETH + SOL: , // Placeholder for SOL }; const currencyNames: { [key: string]: string } = { @@ -29,18 +32,28 @@ const currencyNames: { [key: string]: string } = { USD: "Dólar Americano", EUR: "Euro", BTC: "Bitcoin", + ETH: "Ethereum", + SOL: "Solana", }; -const displayAssetCodes = ["BRL", "USD", "EUR", "BTC"]; +// Updated display asset codes +const displayAssetCodes = ["ETH", "SOL", "BTC"]; +const coingeckoAssetIds = { + ETH: "ethereum", + SOL: "solana", + BTC: "bitcoin", +}; export default function DeFiInvestmentsPage() { const { user, isLoadingAuth } = useAuthContext(); const [preferredCurrency, setPreferredCurrency] = useState('BRL'); const [isLoadingPrefs, setIsLoadingPrefs] = useState(true); - const [bitcoinPrice, setBitcoinPrice] = useState(null); - const [isBitcoinPriceLoading, setIsBitcoinPriceLoading] = useState(true); - const [bitcoinPriceError, setBitcoinPriceError] = useState(null); + + // Updated state for crypto prices + const [cryptoPrices, setCryptoPrices] = useState<{ [key: string]: number | null }>({ ETH: null, SOL: null, BTC: null }); + const [isCryptoPricesLoading, setIsCryptoPricesLoading] = useState(true); + const [cryptoPricesError, setCryptoPricesError] = useState(null); const [connectedWalletAddress, setConnectedWalletAddress] = useState(null); const [isConnectingWallet, setIsConnectingWallet] = useState(false); @@ -67,67 +80,88 @@ export default function DeFiInvestmentsPage() { fetchPrefs(); }, [fetchPrefs]); - const fetchBitcoinPrice = useCallback(async () => { + // Renamed and generalized function to fetch prices for ETH, SOL, BTC + const fetchCryptoPrices = useCallback(async () => { if (!preferredCurrency || typeof window === 'undefined' || isLoadingPrefs) { - setIsBitcoinPriceLoading(false); + setIsCryptoPricesLoading(false); return; } - setIsBitcoinPriceLoading(true); - setBitcoinPriceError(null); - setBitcoinPrice(null); + setIsCryptoPricesLoading(true); + setCryptoPricesError(null); + setCryptoPrices({ ETH: null, SOL: null, BTC: null }); // Reset prices const preferredCurrencyLower = preferredCurrency.toLowerCase(); - const directlySupportedVsCurrencies = ['usd', 'eur', 'brl', 'gbp', 'jpy', 'cad', 'aud', 'chf']; + const directlySupportedVsCurrencies = ['usd', 'eur', 'brl', 'gbp', 'jpy', 'cad', 'aud', 'chf']; // Common CoinGecko vs_currencies let targetCoingeckoCurrency = preferredCurrencyLower; if (!directlySupportedVsCurrencies.includes(preferredCurrencyLower)) { - console.warn(`Preferred currency ${preferredCurrency} might not be directly supported by Coingecko for BTC price. Fetching in USD and will convert.`); + console.warn(`Preferred currency ${preferredCurrency} might not be directly supported by CoinGecko for all assets. Fetching in USD and will convert.`); targetCoingeckoCurrency = 'usd'; } + + const assetIdsString = Object.values(coingeckoAssetIds).join(','); try { - const response = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=bitcoin&vs_currencies=${targetCoingeckoCurrency}`); + const response = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${assetIdsString}&vs_currencies=${targetCoingeckoCurrency}`); if (!response.ok) { const errorData = await response.json().catch(() => ({ error: "Unknown API error structure" })); - throw new Error(`Coingecko API request failed: ${response.status} ${response.statusText} - ${errorData?.error || 'Details unavailable'}`); + throw new Error(`CoinGecko API request failed: ${response.status} ${response.statusText} - ${errorData?.error || 'Details unavailable'}`); } const data = await response.json(); - - if (data.bitcoin && data.bitcoin[targetCoingeckoCurrency]) { - let priceInTargetCoinGeckoCurrency = data.bitcoin[targetCoingeckoCurrency]; - if (targetCoingeckoCurrency.toUpperCase() !== preferredCurrency.toUpperCase()) { - const convertedPrice = convertCurrency(priceInTargetCoinGeckoCurrency, targetCoingeckoCurrency.toUpperCase(), preferredCurrency); - setBitcoinPrice(convertedPrice); + const newPrices: { [key: string]: number | null } = {}; + + for (const code of displayAssetCodes) { + const coingeckoId = (coingeckoAssetIds as any)[code]; + if (data[coingeckoId] && data[coingeckoId][targetCoingeckoCurrency]) { + let priceInTargetCoinGeckoCurrency = data[coingeckoId][targetCoingeckoCurrency]; + if (targetCoingeckoCurrency.toUpperCase() !== preferredCurrency.toUpperCase()) { + // Convert if fetched currency is not the preferred one (e.g., fetched in USD, need BRL) + newPrices[code] = convertCurrency(priceInTargetCoinGeckoCurrency, targetCoingeckoCurrency.toUpperCase(), preferredCurrency); + } else { + newPrices[code] = priceInTargetCoinGeckoCurrency; + } } else { - setBitcoinPrice(priceInTargetCoinGeckoCurrency); + console.warn(`Price for ${code} not found in Coingecko response for target currency '${targetCoingeckoCurrency}'. Will try to convert from static BTC rate if ${code} is BTC.`); + if(code === 'BTC') { // Fallback for BTC if primary fetch fails for it + newPrices[code] = convertCurrency(1, "BTC", preferredCurrency); + } else { + newPrices[code] = null; + } } - } else { - throw new Error(`Bitcoin price not found in Coingecko response for the target currency '${targetCoingeckoCurrency}'.`); } + setCryptoPrices(newPrices); + } catch (err: any) { - console.error("Failed to fetch Bitcoin price:", err); - setBitcoinPriceError(err.message || "Could not load Bitcoin price."); - setBitcoinPrice(convertCurrency(1, "BTC", preferredCurrency)); + console.error("Failed to fetch crypto prices:", err); + setCryptoPricesError(err.message || "Could not load crypto prices."); + // Fallback for all display assets on error + const fallbackPrices: { [key: string]: number | null } = {}; + displayAssetCodes.forEach(code => { + fallbackPrices[code] = convertCurrency(1, code, preferredCurrency); // Assumes convertCurrency can handle BTC, ETH, SOL if static rates for them exist or as a concept + }); + setCryptoPrices(fallbackPrices); } finally { - setIsBitcoinPriceLoading(false); + setIsCryptoPricesLoading(false); } }, [preferredCurrency, isLoadingPrefs]); useEffect(() => { if (user && !isLoadingAuth && !isLoadingPrefs) { - fetchBitcoinPrice(); - } else if (!isLoadingAuth && !user) { - setIsBitcoinPriceLoading(false); + fetchCryptoPrices(); + } else if (!isLoadingAuth && !user && !isLoadingPrefs) { // Also check isLoadingPrefs + setIsCryptoPricesLoading(false); } - }, [fetchBitcoinPrice, user, isLoadingAuth, isLoadingPrefs]); + }, [fetchCryptoPrices, user, isLoadingAuth, isLoadingPrefs]); const dynamicPriceData = useMemo(() => { - const filteredAssetCodes = displayAssetCodes.filter(code => code !== preferredCurrency); + // Filter for the new displayAssetCodes (ETH, SOL, BTC) + // No need to filter out preferredCurrency as these are cryptos + const codesToDisplay = displayAssetCodes; if (isLoadingAuth || isLoadingPrefs) { - return filteredAssetCodes.map(code => ({ + return codesToDisplay.map(code => ({ name: currencyNames[code] || code, code: code, price: null, @@ -138,47 +172,33 @@ export default function DeFiInvestmentsPage() { })); } - return filteredAssetCodes.map(assetCode => { - let priceInPreferredCurrency: number | null = null; - let displayChange = "+0.00%"; - let isAssetSpecificLoading = false; - - if (assetCode === "BTC") { - isAssetSpecificLoading = isBitcoinPriceLoading; - if (isBitcoinPriceLoading) { - displayChange = "Loading..."; - } else if (bitcoinPriceError) { - priceInPreferredCurrency = convertCurrency(1, "BTC", preferredCurrency); - displayChange = "Error"; - } else if (bitcoinPrice !== null) { - priceInPreferredCurrency = bitcoinPrice; - displayChange = "N/A"; - } else { - priceInPreferredCurrency = convertCurrency(1, "BTC", preferredCurrency); - displayChange = "N/A"; - } - } else { - priceInPreferredCurrency = convertCurrency(1, assetCode, preferredCurrency); - if(assetCode === "USD") displayChange = "-0.05%"; - if(assetCode === "EUR") displayChange = "+0.02%"; - if(assetCode === "BRL") displayChange = "-0.01%"; + return codesToDisplay.map(assetCode => { + let priceInPreferredCurrency: number | null = cryptoPrices[assetCode]; + let displayChange = "N/A"; // Real-time change % is complex, setting to N/A for now + let isAssetSpecificLoading = isCryptoPricesLoading; + + if (isCryptoPricesLoading) { + displayChange = "Loading..."; + } else if (cryptoPricesError && cryptoPrices[assetCode] === null) { // Error specific to this asset or general error + displayChange = "Error"; + // Attempt to use static conversion if available, mainly for BTC + if (assetCode === "BTC") priceInPreferredCurrency = convertCurrency(1, "BTC", preferredCurrency); + else priceInPreferredCurrency = null; // For ETH/SOL, if API fails, show null + } else if (priceInPreferredCurrency === null && !isCryptoPricesLoading) { + displayChange = "N/A"; // Price not found or API issue } return { name: currencyNames[assetCode] || assetCode, code: assetCode, price: priceInPreferredCurrency, - change: displayChange, - icon: currencyIcons[assetCode] || , + change: displayChange, // Real-time change requires more complex API or websockets + icon: currencyIcons[assetCode] || , against: preferredCurrency, isLoading: isAssetSpecificLoading, }; - }).filter(item => - allAppSupportedCurrencies.includes(item.code.toUpperCase()) && - allAppSupportedCurrencies.includes(item.against.toUpperCase()) - ); - - }, [preferredCurrency, isLoadingAuth, isLoadingPrefs, bitcoinPrice, isBitcoinPriceLoading, bitcoinPriceError]); + }); + }, [preferredCurrency, isLoadingAuth, isLoadingPrefs, cryptoPrices, isCryptoPricesLoading, cryptoPricesError]); const handleConnectWallet = async () => { if (typeof window.ethereum === 'undefined') { @@ -297,3 +317,6 @@ export default function DeFiInvestmentsPage() {
); } + + + \ No newline at end of file From 6442cd615bf3c0b0949b2db8b5dfffbaffef7061 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 27 May 2025 02:07:12 +0000 Subject: [PATCH 046/156] Agora na aba preferences vamos duplicar a funcao de Preferred currency e dar a opcao de mudar a moeda principal apenas na aba Investment --- src/app/investments/defi/page.tsx | 127 +++++++++++------------------- src/app/preferences/page.tsx | 45 +++++++++-- src/contexts/AuthContext.tsx | 79 ++++++++----------- src/lib/preferences.ts | 16 +++- 4 files changed, 129 insertions(+), 138 deletions(-) diff --git a/src/app/investments/defi/page.tsx b/src/app/investments/defi/page.tsx index a71cdab..91b6a71 100644 --- a/src/app/investments/defi/page.tsx +++ b/src/app/investments/defi/page.tsx @@ -5,8 +5,8 @@ import { useState, useEffect, useMemo, useCallback } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import InvestmentPricePanel from "@/components/investments/investment-price-panel"; import { AreaChart, DollarSign, Euro, Bitcoin as BitcoinIcon, WalletCards, LinkIcon, UnlinkIcon, CircleDollarSign } from "lucide-react"; -import { getUserPreferences } from '@/lib/preferences'; -import { convertCurrency, getCurrencySymbol, supportedCurrencies as allAppSupportedCurrencies } from '@/lib/currency'; +// getUserPreferences removed as we get it from AuthContext +import { convertCurrency, getCurrencySymbol } from '@/lib/currency'; import { Skeleton } from '@/components/ui/skeleton'; import { useAuthContext } from '@/contexts/AuthContext'; import { Button } from '@/components/ui/button'; @@ -17,14 +17,13 @@ const BRLIcon = () => ( R$ ); -// Updated icons and names for specific cryptos const currencyIcons: { [key: string]: React.ReactNode } = { - BRL: , // Still useful for preferredCurrency display - USD: , // Still useful for preferredCurrency display - EUR: , // Still useful for preferredCurrency display + BRL: , + USD: , + EUR: , BTC: , - ETH: , // Placeholder for ETH - SOL: , // Placeholder for SOL + ETH: , + SOL: , }; const currencyNames: { [key: string]: string } = { @@ -36,7 +35,6 @@ const currencyNames: { [key: string]: string } = { SOL: "Solana", }; -// Updated display asset codes const displayAssetCodes = ["ETH", "SOL", "BTC"]; const coingeckoAssetIds = { ETH: "ethereum", @@ -44,13 +42,13 @@ const coingeckoAssetIds = { BTC: "bitcoin", }; - export default function DeFiInvestmentsPage() { - const { user, isLoadingAuth } = useAuthContext(); - const [preferredCurrency, setPreferredCurrency] = useState('BRL'); - const [isLoadingPrefs, setIsLoadingPrefs] = useState(true); - - // Updated state for crypto prices + const { user, isLoadingAuth, userPreferences } = useAuthContext(); + // Use investment-specific currency from preferences, fallback to global or 'USD' + const investmentsDisplayCurrency = useMemo(() => { + return userPreferences?.investmentsPreferredCurrency || userPreferences?.preferredCurrency || 'USD'; + }, [userPreferences]); + const [cryptoPrices, setCryptoPrices] = useState<{ [key: string]: number | null }>({ ETH: null, SOL: null, BTC: null }); const [isCryptoPricesLoading, setIsCryptoPricesLoading] = useState(true); const [cryptoPricesError, setCryptoPricesError] = useState(null); @@ -59,51 +57,30 @@ export default function DeFiInvestmentsPage() { const [isConnectingWallet, setIsConnectingWallet] = useState(false); const [walletError, setWalletError] = useState(null); - const fetchPrefs = useCallback(async () => { - if (!user || isLoadingAuth || typeof window === 'undefined') { - setIsLoadingPrefs(false); - if (!user && !isLoadingAuth) console.log("User not logged in, using default preferences for DeFi investments page."); - return; - } - setIsLoadingPrefs(true); - try { - const prefs = await getUserPreferences(); - setPreferredCurrency(prefs.preferredCurrency.toUpperCase()); - } catch (error) { - console.error("Failed to fetch user preferences:", error); - } finally { - setIsLoadingPrefs(false); - } - }, [user, isLoadingAuth]); - useEffect(() => { - fetchPrefs(); - }, [fetchPrefs]); - - // Renamed and generalized function to fetch prices for ETH, SOL, BTC const fetchCryptoPrices = useCallback(async () => { - if (!preferredCurrency || typeof window === 'undefined' || isLoadingPrefs) { + if (typeof window === 'undefined' || !investmentsDisplayCurrency) { setIsCryptoPricesLoading(false); return; } setIsCryptoPricesLoading(true); setCryptoPricesError(null); - setCryptoPrices({ ETH: null, SOL: null, BTC: null }); // Reset prices + setCryptoPrices({ ETH: null, SOL: null, BTC: null }); - const preferredCurrencyLower = preferredCurrency.toLowerCase(); - const directlySupportedVsCurrencies = ['usd', 'eur', 'brl', 'gbp', 'jpy', 'cad', 'aud', 'chf']; // Common CoinGecko vs_currencies - let targetCoingeckoCurrency = preferredCurrencyLower; + const targetVsCurrencyLower = investmentsDisplayCurrency.toLowerCase(); + const directlySupportedVsCurrencies = ['usd', 'eur', 'brl', 'gbp', 'jpy', 'cad', 'aud', 'chf']; + let fetchAgainstCurrency = targetVsCurrencyLower; - if (!directlySupportedVsCurrencies.includes(preferredCurrencyLower)) { - console.warn(`Preferred currency ${preferredCurrency} might not be directly supported by CoinGecko for all assets. Fetching in USD and will convert.`); - targetCoingeckoCurrency = 'usd'; + if (!directlySupportedVsCurrencies.includes(targetVsCurrencyLower)) { + console.warn(`Investments currency ${investmentsDisplayCurrency} might not be directly supported by CoinGecko for all assets. Fetching in USD and will convert.`); + fetchAgainstCurrency = 'usd'; } const assetIdsString = Object.values(coingeckoAssetIds).join(','); try { - const response = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${assetIdsString}&vs_currencies=${targetCoingeckoCurrency}`); + const response = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${assetIdsString}&vs_currencies=${fetchAgainstCurrency}`); if (!response.ok) { const errorData = await response.json().catch(() => ({ error: "Unknown API error structure" })); throw new Error(`CoinGecko API request failed: ${response.status} ${response.statusText} - ${errorData?.error || 'Details unavailable'}`); @@ -113,21 +90,16 @@ export default function DeFiInvestmentsPage() { for (const code of displayAssetCodes) { const coingeckoId = (coingeckoAssetIds as any)[code]; - if (data[coingeckoId] && data[coingeckoId][targetCoingeckoCurrency]) { - let priceInTargetCoinGeckoCurrency = data[coingeckoId][targetCoingeckoCurrency]; - if (targetCoingeckoCurrency.toUpperCase() !== preferredCurrency.toUpperCase()) { - // Convert if fetched currency is not the preferred one (e.g., fetched in USD, need BRL) - newPrices[code] = convertCurrency(priceInTargetCoinGeckoCurrency, targetCoingeckoCurrency.toUpperCase(), preferredCurrency); + if (data[coingeckoId] && data[coingeckoId][fetchAgainstCurrency]) { + let priceInFetchedCurrency = data[coingeckoId][fetchAgainstCurrency]; + if (fetchAgainstCurrency.toUpperCase() !== investmentsDisplayCurrency.toUpperCase()) { + newPrices[code] = convertCurrency(priceInFetchedCurrency, fetchAgainstCurrency.toUpperCase(), investmentsDisplayCurrency); } else { - newPrices[code] = priceInTargetCoinGeckoCurrency; + newPrices[code] = priceInFetchedCurrency; } } else { - console.warn(`Price for ${code} not found in Coingecko response for target currency '${targetCoingeckoCurrency}'. Will try to convert from static BTC rate if ${code} is BTC.`); - if(code === 'BTC') { // Fallback for BTC if primary fetch fails for it - newPrices[code] = convertCurrency(1, "BTC", preferredCurrency); - } else { - newPrices[code] = null; - } + console.warn(`Price for ${code} not found in Coingecko response for target currency '${fetchAgainstCurrency}'.`); + newPrices[code] = null; // Explicitly set to null if not found } } setCryptoPrices(newPrices); @@ -135,70 +107,66 @@ export default function DeFiInvestmentsPage() { } catch (err: any) { console.error("Failed to fetch crypto prices:", err); setCryptoPricesError(err.message || "Could not load crypto prices."); - // Fallback for all display assets on error const fallbackPrices: { [key: string]: number | null } = {}; displayAssetCodes.forEach(code => { - fallbackPrices[code] = convertCurrency(1, code, preferredCurrency); // Assumes convertCurrency can handle BTC, ETH, SOL if static rates for them exist or as a concept + fallbackPrices[code] = null; // Ensure fallback is null on error }); setCryptoPrices(fallbackPrices); } finally { setIsCryptoPricesLoading(false); } - }, [preferredCurrency, isLoadingPrefs]); + }, [investmentsDisplayCurrency]); useEffect(() => { - if (user && !isLoadingAuth && !isLoadingPrefs) { + // Fetch prices if user is loaded, not loading auth, and we have the investment display currency + if (user && !isLoadingAuth && investmentsDisplayCurrency) { fetchCryptoPrices(); - } else if (!isLoadingAuth && !user && !isLoadingPrefs) { // Also check isLoadingPrefs - setIsCryptoPricesLoading(false); + } else if (!isLoadingAuth && !user) { + setIsCryptoPricesLoading(false); // Stop loading if no user } - }, [fetchCryptoPrices, user, isLoadingAuth, isLoadingPrefs]); + }, [fetchCryptoPrices, user, isLoadingAuth, investmentsDisplayCurrency]); const dynamicPriceData = useMemo(() => { - // Filter for the new displayAssetCodes (ETH, SOL, BTC) - // No need to filter out preferredCurrency as these are cryptos const codesToDisplay = displayAssetCodes; - if (isLoadingAuth || isLoadingPrefs) { + if (isLoadingAuth || !userPreferences) { // Check for userPreferences existence as well return codesToDisplay.map(code => ({ name: currencyNames[code] || code, code: code, price: null, change: "Loading...", icon: currencyIcons[code] || , - against: preferredCurrency, + against: investmentsDisplayCurrency, isLoading: true, })); } return codesToDisplay.map(assetCode => { let priceInPreferredCurrency: number | null = cryptoPrices[assetCode]; - let displayChange = "N/A"; // Real-time change % is complex, setting to N/A for now + let displayChange = "N/A"; let isAssetSpecificLoading = isCryptoPricesLoading; if (isCryptoPricesLoading) { displayChange = "Loading..."; - } else if (cryptoPricesError && cryptoPrices[assetCode] === null) { // Error specific to this asset or general error + } else if (cryptoPricesError && cryptoPrices[assetCode] === null) { displayChange = "Error"; - // Attempt to use static conversion if available, mainly for BTC - if (assetCode === "BTC") priceInPreferredCurrency = convertCurrency(1, "BTC", preferredCurrency); - else priceInPreferredCurrency = null; // For ETH/SOL, if API fails, show null + priceInPreferredCurrency = null; } else if (priceInPreferredCurrency === null && !isCryptoPricesLoading) { - displayChange = "N/A"; // Price not found or API issue + displayChange = "N/A"; } return { name: currencyNames[assetCode] || assetCode, code: assetCode, price: priceInPreferredCurrency, - change: displayChange, // Real-time change requires more complex API or websockets + change: displayChange, icon: currencyIcons[assetCode] || , - against: preferredCurrency, + against: investmentsDisplayCurrency, isLoading: isAssetSpecificLoading, }; }); - }, [preferredCurrency, isLoadingAuth, isLoadingPrefs, cryptoPrices, isCryptoPricesLoading, cryptoPricesError]); + }, [investmentsDisplayCurrency, isLoadingAuth, userPreferences, cryptoPrices, isCryptoPricesLoading, cryptoPricesError]); const handleConnectWallet = async () => { if (typeof window.ethereum === 'undefined') { @@ -232,7 +200,7 @@ export default function DeFiInvestmentsPage() { return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`; }; - if (isLoadingAuth || isLoadingPrefs) { + if (isLoadingAuth || !userPreferences) { return (
@@ -317,6 +285,3 @@ export default function DeFiInvestmentsPage() {
); } - - - \ No newline at end of file diff --git a/src/app/preferences/page.tsx b/src/app/preferences/page.tsx index 258dfd1..768012d 100644 --- a/src/app/preferences/page.tsx +++ b/src/app/preferences/page.tsx @@ -16,6 +16,7 @@ import { useAuthContext } from '@/contexts/AuthContext'; export default function PreferencesPage() { const { user, isLoadingAuth, userPreferences, refreshUserPreferences } = useAuthContext(); const [preferredCurrency, setPreferredCurrency] = useState(userPreferences?.preferredCurrency || 'BRL'); + const [selectedInvestmentsCurrency, setSelectedInvestmentsCurrency] = useState(userPreferences?.investmentsPreferredCurrency || 'USD'); const [selectedTheme, setSelectedTheme] = useState(userPreferences?.theme || 'system'); const [isSaving, setIsSaving] = useState(false); const { toast } = useToast(); @@ -23,7 +24,8 @@ export default function PreferencesPage() { useEffect(() => { if (userPreferences) { setPreferredCurrency(userPreferences.preferredCurrency); - setSelectedTheme(userPreferences.theme); + setSelectedInvestmentsCurrency(userPreferences.investmentsPreferredCurrency || 'USD'); + setSelectedTheme(userPreferences.theme || 'system'); } }, [userPreferences]); @@ -31,6 +33,10 @@ export default function PreferencesPage() { setPreferredCurrency(newCurrency); }; + const handleInvestmentsCurrencyChange = (newCurrency: string) => { + setSelectedInvestmentsCurrency(newCurrency); + }; + const handleThemeChange = (newTheme: UserPreferences['theme']) => { setSelectedTheme(newTheme); }; @@ -44,10 +50,11 @@ export default function PreferencesPage() { try { const newPreferencesToSave: UserPreferences = { preferredCurrency: preferredCurrency, + investmentsPreferredCurrency: selectedInvestmentsCurrency, theme: selectedTheme, }; await saveUserPreferences(newPreferencesToSave); - await refreshUserPreferences(); + await refreshUserPreferences(); // This will update the context and re-apply theme if necessary toast({ title: "Preferences Saved", description: "Your preferences have been updated successfully.", @@ -83,6 +90,10 @@ export default function PreferencesPage() {
+
+ + +
@@ -107,14 +118,37 @@ export default function PreferencesPage() { ) : ( <>
- + +

+ General balances and totals will be converted and displayed in this currency. +

+
+ +
+ +

- Balances and totals will be converted and displayed in this currency. + Investment values (like crypto prices) will be displayed in this currency.

@@ -142,6 +176,7 @@ export default function PreferencesPage() { Light Dark + GoldQuest System Default diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 251571f..000b038 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -30,8 +30,8 @@ interface AuthContextType { signIn: (email: string, pass: string) => Promise; signInWithGoogle: () => Promise; signOut: () => Promise; - userPreferences: UserPreferences | null; // Add userPreferences to context - refreshUserPreferences: () => Promise; // Add function to refresh preferences + userPreferences: UserPreferences | null; + refreshUserPreferences: () => Promise; } const AuthContext = createContext(undefined); @@ -40,90 +40,74 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { const [user, setUser] = useState(null); const [isLoadingAuth, setIsLoadingAuth] = useState(true); const [userPreferences, setUserPreferences] = useState(null); - const [theme, setThemeState] = useState('system'); + const [currentTheme, setCurrentTheme] = useState('system'); // Renamed from setThemeState to avoid confusion const router = useRouter(); - const fetchUserPreferences = useCallback(async () => { - if (firebaseInitialized && firebaseAuthInstance?.currentUser) { + const fetchUserPreferences = useCallback(async (firebaseUser: User | null) => { + if (firebaseInitialized && firebaseUser) { try { const prefs = await getUserPreferences(); setUserPreferences(prefs); - setThemeState(prefs.theme || 'system'); + setCurrentTheme(prefs.theme || 'system'); } catch (error) { console.error("AuthProvider: Failed to fetch user preferences:", error); - // Fallback to default if fetching fails - const defaultPrefs = { preferredCurrency: 'BRL', theme: 'system' } as UserPreferences; + const defaultPrefs = { preferredCurrency: 'BRL', investmentsPreferredCurrency: 'USD', theme: 'system' } as UserPreferences; setUserPreferences(defaultPrefs); - setThemeState(defaultPrefs.theme); + setCurrentTheme(defaultPrefs.theme); } } else { - // No user or Firebase not ready, use default - const defaultPrefs = { preferredCurrency: 'BRL', theme: 'system' } as UserPreferences; + const defaultPrefs = { preferredCurrency: 'BRL', investmentsPreferredCurrency: 'USD', theme: 'system' } as UserPreferences; setUserPreferences(defaultPrefs); - setThemeState(defaultPrefs.theme); + setCurrentTheme(defaultPrefs.theme); } - }, []); + }, []); // Removed firebaseAuthInstance from dependencies as it's stable or handled by firebaseInitialized useEffect(() => { if (!firebaseInitialized || !firebaseAuthInstance) { setIsLoadingAuth(false); setUser(null); - fetchUserPreferences(); // Fetch default/localStorage preferences + fetchUserPreferences(null); // Fetch default/localStorage preferences console.warn(`AuthContext: Firebase not properly initialized. ${firebaseInitializationError || "Authentication features may be disabled."}`); return () => {}; } const unsubscribe = onAuthStateChanged(firebaseAuthInstance, async (firebaseUser) => { setUser(firebaseUser); - if (firebaseUser) { - await fetchUserPreferences(); - } else { - // User signed out, reset to default preferences - const defaultPrefs = { preferredCurrency: 'BRL', theme: 'system' } as UserPreferences; - setUserPreferences(defaultPrefs); - setThemeState(defaultPrefs.theme); - } + await fetchUserPreferences(firebaseUser); // Pass firebaseUser to fetchUserPreferences setIsLoadingAuth(false); }); return () => unsubscribe(); - }, [router, firebaseInitialized, firebaseInitializationError, fetchUserPreferences]); + }, [firebaseInitialized, firebaseInitializationError, fetchUserPreferences]); // fetchUserPreferences is stable due to useCallback const ensureFirebaseAuth = useCallback((): Auth => { if (!firebaseInitialized || !firebaseAuthInstance) { throw new Error(firebaseInitializationError || "Firebase authentication service is not available. Please check configuration."); } return firebaseAuthInstance; - }, [firebaseInitializationError]); + }, [firebaseInitializationError]); // Added firebaseInitializationError const ensureGoogleAuthProvider = useCallback((): GoogleAuthProvider => { if (!firebaseInitialized || !firebaseGoogleAuthProviderInstance) { throw new Error(firebaseInitializationError || "Firebase Google Auth Provider is not available. Please check configuration."); } return firebaseGoogleAuthProviderInstance; - },[firebaseInitializationError]); + },[firebaseInitializationError]); // Added firebaseInitializationError const setAppTheme = async (newTheme: UserPreferences['theme']) => { - if (userPreferences) { - const updatedPrefs = { ...userPreferences, theme: newTheme }; - try { - await saveUserPreferences(updatedPrefs); - setUserPreferences(updatedPrefs); // Update local state of preferences - setThemeState(newTheme); // Update local theme state - // Dispatch a storage event to notify other components (like AuthWrapper for theme) - window.dispatchEvent(new Event('storage')); - } catch (error) { - console.error("AuthProvider: Failed to save theme preference:", error); - } - } else { // If userPreferences is null (e.g. user not logged in, or initial load) - // We can still attempt to save a non-user-specific preference or a default - // For now, let's assume we only save if userPreferences (and thus user) exists. - // Or, save to localStorage directly if this is a non-user specific theme setting - console.warn("AuthProvider: User preferences not loaded, cannot save theme."); + const currentPrefs = userPreferences || { preferredCurrency: 'BRL', investmentsPreferredCurrency: 'USD', theme: 'system' }; + const updatedPrefs: UserPreferences = { ...currentPrefs, theme: newTheme }; + try { + await saveUserPreferences(updatedPrefs); + setUserPreferences(updatedPrefs); + setCurrentTheme(newTheme); + window.dispatchEvent(new Event('storage')); + } catch (error) { + console.error("AuthProvider: Failed to save theme preference:", error); } }; const refreshUserPreferences = async () => { - await fetchUserPreferences(); + await fetchUserPreferences(user); // Pass current user to refresh }; @@ -131,7 +115,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { const currentAuth = ensureFirebaseAuth(); try { const userCredential = await createUserWithEmailAndPassword(currentAuth, email, pass); - await fetchUserPreferences(); // Fetch prefs for new user + await fetchUserPreferences(userCredential.user); // Fetch prefs for new user return userCredential.user; } catch (error) { console.error('Error signing up:', error); @@ -143,7 +127,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { const currentAuth = ensureFirebaseAuth(); try { const userCredential = await signInWithEmailAndPassword(currentAuth, email, pass); - await fetchUserPreferences(); // Fetch prefs on sign in + await fetchUserPreferences(userCredential.user); // Fetch prefs on sign in return userCredential.user; } catch (error) { console.error('Error signing in:', error); @@ -156,7 +140,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { const provider = ensureGoogleAuthProvider(); try { const result = await signInWithPopup(currentAuth, provider); - await fetchUserPreferences(); // Fetch prefs on Google sign in + await fetchUserPreferences(result.user); // Fetch prefs on Google sign in return result.user; } catch (error) { console.error('Error signing in with Google:', error); @@ -168,7 +152,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { const currentAuth = ensureFirebaseAuth(); try { await firebaseSignOut(currentAuth); - // Preferences are reset in onAuthStateChanged + // Preferences are reset by onAuthStateChanged -> fetchUserPreferences(null) router.push('/login'); } catch (error) { console.error('Error signing out:', error); @@ -184,7 +168,7 @@ export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => { isLoadingAuth, isFirebaseActive: firebaseInitialized, firebaseError: firebaseInitializationError, - theme: theme || 'system', + theme: currentTheme || 'system', // Use currentTheme state here setAppTheme, signUp, signIn, @@ -206,4 +190,3 @@ export const useAuthContext = (): AuthContextType => { } return context; }; - diff --git a/src/lib/preferences.ts b/src/lib/preferences.ts index f4a49c6..dc37797 100644 --- a/src/lib/preferences.ts +++ b/src/lib/preferences.ts @@ -8,12 +8,14 @@ import { supportedCurrencies } from './currency'; export interface UserPreferences { preferredCurrency: string; - theme?: 'light' | 'dark' | 'system'; // Added theme preference + investmentsPreferredCurrency?: string; // New field for investment-specific currency + theme?: 'light' | 'dark' | 'system' | 'goldquest'; } const defaultPreferences: UserPreferences = { preferredCurrency: 'BRL', - theme: 'system', // Default theme + investmentsPreferredCurrency: 'USD', // Default investment currency + theme: 'system', }; function getPreferencesRefPath(currentUser: User | null) { @@ -38,7 +40,10 @@ export async function getUserPreferences(): Promise { preferredCurrency: supportedCurrencies.includes(prefs.preferredCurrency?.toUpperCase() ?? '') ? prefs.preferredCurrency!.toUpperCase() : defaultPreferences.preferredCurrency, - theme: prefs.theme && ['light', 'dark', 'system'].includes(prefs.theme) + investmentsPreferredCurrency: prefs.investmentsPreferredCurrency && supportedCurrencies.includes(prefs.investmentsPreferredCurrency.toUpperCase()) + ? prefs.investmentsPreferredCurrency.toUpperCase() + : defaultPreferences.investmentsPreferredCurrency, + theme: prefs.theme && ['light', 'dark', 'system', 'goldquest'].includes(prefs.theme) ? prefs.theme : defaultPreferences.theme, }; @@ -61,7 +66,10 @@ export async function saveUserPreferences(preferences: UserPreferences): Promise preferredCurrency: supportedCurrencies.includes(preferences.preferredCurrency.toUpperCase()) ? preferences.preferredCurrency.toUpperCase() : defaultPreferences.preferredCurrency, - theme: preferences.theme && ['light', 'dark', 'system'].includes(preferences.theme) + investmentsPreferredCurrency: preferences.investmentsPreferredCurrency && supportedCurrencies.includes(preferences.investmentsPreferredCurrency.toUpperCase()) + ? preferences.investmentsPreferredCurrency.toUpperCase() + : defaultPreferences.investmentsPreferredCurrency, + theme: preferences.theme && ['light', 'dark', 'system', 'goldquest'].includes(preferences.theme) ? preferences.theme : defaultPreferences.theme, }; From 0441a0c47bdc5e72724b564d7e995236bdd4df65 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 27 May 2025 02:17:51 +0000 Subject: [PATCH 047/156] Ta bem, ate agora estamos muito bem mas para comecar a fazer a organizacao total de todas as movimentacoes vamos ter que comecar a organizar e analisar os SWAPS. Digo totas as transacoes, tanto de moedas quanto de cryptos. Na no menu Transactions vamos adcionar a sessao Swap e dar um jeito de visualizar tanto isso, quanto as taxas pagas e etc, tendo em vista que no mercado crypto toda transacao tem taxas significativas.E que as taxas de IOF de moedas tambem sao. --- src/app/transactions/swaps/page.tsx | 61 ++++++++++++++++++++++++++ src/components/layout/auth-wrapper.tsx | 31 +++++++++---- 2 files changed, 84 insertions(+), 8 deletions(-) create mode 100644 src/app/transactions/swaps/page.tsx diff --git a/src/app/transactions/swaps/page.tsx b/src/app/transactions/swaps/page.tsx new file mode 100644 index 0000000..4edc2f5 --- /dev/null +++ b/src/app/transactions/swaps/page.tsx @@ -0,0 +1,61 @@ + +'use client'; + +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { useAuthContext } from '@/contexts/AuthContext'; +import { useDateRange } from '@/contexts/DateRangeContext'; +import { Repeat } from 'lucide-react'; + +export default function SwapsPage() { + const { user, isLoadingAuth } = useAuthContext(); + const { selectedDateRange } = useDateRange(); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isLoadingAuth) { + setIsLoading(false); + if (!user) { + setError("Please log in to view swap transactions."); + } + } + }, [user, isLoadingAuth]); + + if (isLoading) { + return

Loading swap data...

; + } + + if (error) { + return
{error}
; + } + + return ( +
+

Swap Transactions & Analysis

+ + + + + + Swap Details + + + Detailed analysis of your currency and cryptocurrency swaps, including fees. + + + +
+ +

+ Swap Analysis & Fee Tracking Coming Soon! +

+

+ This section will provide insights into your swap activities and associated costs. +

+
+
+
+
+ ); +} diff --git a/src/components/layout/auth-wrapper.tsx b/src/components/layout/auth-wrapper.tsx index 87118e0..7f5723e 100644 --- a/src/components/layout/auth-wrapper.tsx +++ b/src/components/layout/auth-wrapper.tsx @@ -18,7 +18,7 @@ import { SidebarGroupLabel, } from '@/components/ui/sidebar'; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Landmark, Wallet, ArrowLeftRight, Settings, ChevronDown, TrendingUp, TrendingDown, LayoutList, Users, LogOut, Network, PieChart, Database, SlidersHorizontal, FileText, ArchiveIcon, MapIcon, Bitcoin as BitcoinIcon } from 'lucide-react'; // Added BitcoinIcon +import { Landmark, Wallet, ArrowLeftRight, Settings, ChevronDown, TrendingUp, TrendingDown, LayoutList, Users, LogOut, Network, PieChart, Database, SlidersHorizontal, FileText, ArchiveIcon, MapIcon, Bitcoin as BitcoinIcon, Repeat } from 'lucide-react'; import Link from 'next/link'; import { useRouter, usePathname } from 'next/navigation'; import { useState, useEffect } from 'react'; @@ -59,7 +59,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { const pathname = usePathname(); const [isTransactionsOpen, setIsTransactionsOpen] = useState(false); const [isFinancialControlOpen, setIsFinancialControlOpen] = useState(false); - const [isInvestmentsOpen, setIsInvestmentsOpen] = useState(false); // New state for Investments dropdown + const [isInvestmentsOpen, setIsInvestmentsOpen] = useState(false); const [isClient, setIsClient] = useState(false); const [loadingDivClassName, setLoadingDivClassName] = useState("flex items-center justify-center min-h-screen"); @@ -81,7 +81,9 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { } root.classList.remove('dark', 'light', 'goldquest-theme'); - root.classList.add(currentTheme); // Adds 'light', 'dark', or 'goldquest-theme' + if (currentTheme) { + root.classList.add(currentTheme); + } root.style.colorScheme = (currentTheme === 'goldquest-theme' || currentTheme === 'dark') ? 'dark' : 'light'; }; @@ -98,9 +100,9 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { useEffect(() => { if (isClient) { - setIsTransactionsOpen(pathname.startsWith('/transactions') || pathname.startsWith('/revenue') || pathname.startsWith('/expenses') || pathname.startsWith('/transfers')); + setIsTransactionsOpen(pathname.startsWith('/transactions') || pathname.startsWith('/revenue') || pathname.startsWith('/expenses') || pathname.startsWith('/transfers') || pathname.startsWith('/transactions/swaps')); setIsFinancialControlOpen(pathname.startsWith('/financial-control')); - setIsInvestmentsOpen(pathname.startsWith('/investments/')); // Expand if any investment sub-page is active + setIsInvestmentsOpen(pathname.startsWith('/investments/')); } }, [pathname, isClient]); @@ -126,9 +128,9 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { const isActive = (path: string) => isClient && pathname === path; - const isAnyTransactionRouteActive = isClient && (pathname.startsWith('/transactions') || pathname.startsWith('/revenue') || pathname.startsWith('/expenses') || pathname.startsWith('/transfers')); + const isAnyTransactionRouteActive = isClient && (pathname.startsWith('/transactions') || pathname.startsWith('/revenue') || pathname.startsWith('/expenses') || pathname.startsWith('/transfers') || pathname.startsWith('/transactions/swaps')); const isAnyFinancialControlRouteActive = isClient && pathname === '/financial-control'; - const isAnyInvestmentsRouteActive = isClient && pathname.startsWith('/investments/'); // For main group + const isAnyInvestmentsRouteActive = isClient && pathname.startsWith('/investments/'); const isOrganizationActive = isClient && (pathname === '/organization' || pathname.startsWith('/categories/') || pathname.startsWith('/tags/') || pathname.startsWith('/groups/')); const isAccountsActive = isClient && (pathname === '/accounts' || pathname.startsWith('/accounts/')); const isDataManagementActive = isClient && pathname === '/data-management'; @@ -190,6 +192,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { + @@ -200,6 +203,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { + @@ -262,9 +266,18 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { + + + + + Swaps + + + )} + )} + @@ -316,6 +330,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { + Settings @@ -383,7 +398,7 @@ export default function AuthWrapper({ children }: AuthWrapperProps) { > {/* Only render children if it's a public route that doesn't need AuthWrapper's full layout */} {(pathname === '/login' || pathname === '/signup' || pathname === '/welcome') && children} - + {/* Fallback for when firebase isn't active and user tries to access a non-public route (should ideally be caught by earlier redirect) */} {!isFirebaseActive && !(pathname === '/login' || pathname === '/signup' || pathname === '/welcome') && (
From 3c4f7beb75c9cbca8bf4e9d31946ac9e8f7a82c7 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 27 May 2025 02:23:18 +0000 Subject: [PATCH 048/156] Isso, agora me ajude a pensar na melhor forma de fazer isso, pensando que por exemplo. Cadastrei a Binance como uma conta mas ela nao e realmente uma conta e uma corretora que uso para comprar as cryptos e enviar para o wallet, a mesma coisa com a wise, uso ela para o swap de EUR para Brl e assim vai, como seria a melhor forma de organizar isso? --- src/app/transactions/swaps/page.tsx | 48 ++++++++++++++++++++++++++--- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/src/app/transactions/swaps/page.tsx b/src/app/transactions/swaps/page.tsx index 4edc2f5..9e377cf 100644 --- a/src/app/transactions/swaps/page.tsx +++ b/src/app/transactions/swaps/page.tsx @@ -5,7 +5,9 @@ import { useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { useAuthContext } from '@/contexts/AuthContext'; import { useDateRange } from '@/contexts/DateRangeContext'; -import { Repeat } from 'lucide-react'; +import { Repeat, Info } from 'lucide-react'; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { AlertCircle } from 'lucide-react'; export default function SwapsPage() { const { user, isLoadingAuth } = useAuthContext(); @@ -34,24 +36,60 @@ export default function SwapsPage() {

Swap Transactions & Analysis

+ + + How to Record Swaps & Conversions Currently + +

+ To accurately track currency swaps (e.g., EUR to BRL within Wise) or crypto trades (e.g., BTC to ETH on Binance), please follow these steps for now: +

+
    +
  1. + Represent Asset Holdings as Separate Accounts: For platforms like Wise or Binance, create separate "accounts" in GoldQuest for each currency/asset you hold. For example: +
      +
    • "Wise EUR" (Currency: EUR)
    • +
    • "Wise BRL" (Currency: BRL)
    • +
    • "Binance BTC" (Currency: BTC)
    • +
    • "Binance ETH" (Currency: ETH)
    • +
    +
  2. +
  3. + Record the Swap/Conversion as a Transfer: Use the "Add Transfer" functionality. +
      +
    • From: The source "account" and currency (e.g., "Wise EUR").
    • +
    • Amount: The amount of the source currency converted (e.g., 100 EUR).
    • +
    • To: The destination "account" and currency (e.g., "Wise BRL").
    • +
    • Amount in Destination Account: The amount of the destination currency received (e.g., 530 BRL). The app will show the effective rate.
    • +
    +
  4. +
  5. + Record Fees Separately: If there was a fee for the swap/conversion, record it as a separate "Expense" transaction from the relevant account/currency (e.g., an expense of 0.50 EUR from "Wise EUR", categorized as "FX Fees" or "Trading Fees"). +
  6. +
+

+ Future Enhancements: This "Swaps" page will be enhanced to automatically identify these patterns, provide a consolidated view of your swap activities, analyze effective exchange rates, and track associated fees more directly. A dedicated "Record Swap" form is also planned. +

+
+
+ - Swap Details + Swap Activity Overview - Detailed analysis of your currency and cryptocurrency swaps, including fees. + Consolidated view of your currency and cryptocurrency swaps, including effective rates and fees (Coming Soon).

- Swap Analysis & Fee Tracking Coming Soon! + Detailed Swap Analysis Coming Soon!

- This section will provide insights into your swap activities and associated costs. + This section will provide insights into your swap activities, helping you track conversions and understand associated costs.

From d3c88a1402dbcc9d9205cdcaa45f0501de3f3f23 Mon Sep 17 00:00:00 2001 From: Otavio Augusto Date: Tue, 27 May 2025 02:28:31 +0000 Subject: [PATCH 049/156] Nao, nao era pra voce colocar isso kkkkk. era pra voce so me ajudar a pensar como vamos fazer isso usual no systema, assim e muito amador e muito dificil, vamos criar um tipo de tabela para guardar os Swaps bem parecido o de transferencia mas vamos alterar algumas colunas para ver informacoes importantes --- src/app/transactions/swaps/page.tsx | 311 +++++++++++++++++++------ src/components/swaps/add-swap-form.tsx | 301 ++++++++++++++++++++++++ src/services/swaps.ts | 148 ++++++++++++ 3 files changed, 691 insertions(+), 69 deletions(-) create mode 100644 src/components/swaps/add-swap-form.tsx create mode 100644 src/services/swaps.ts diff --git a/src/app/transactions/swaps/page.tsx b/src/app/transactions/swaps/page.tsx index 9e377cf..6e4b850 100644 --- a/src/app/transactions/swaps/page.tsx +++ b/src/app/transactions/swaps/page.tsx @@ -1,97 +1,270 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo, useCallback } from 'react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogTrigger } from "@/components/ui/dialog"; +import { MoreHorizontal, Edit, Trash2, PlusCircle, Repeat } from 'lucide-react'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"; + import { useAuthContext } from '@/contexts/AuthContext'; import { useDateRange } from '@/contexts/DateRangeContext'; -import { Repeat, Info } from 'lucide-react'; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { AlertCircle } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; +import { formatCurrency, getCurrencySymbol } from '@/lib/currency'; +import { format as formatDateFns, parseISO, isWithinInterval } from 'date-fns'; + +import { getSwaps, addSwap, updateSwap, deleteSwap, type Swap, type NewSwapData } from '@/services/swaps'; +import { getAccounts, type Account } from '@/services/account-sync'; +import AddSwapForm, { type AddSwapFormData } from '@/components/swaps/add-swap-form'; +import { Skeleton } from '@/components/ui/skeleton'; + + +const formatDate = (dateString: string): string => { + try { + const date = parseISO(dateString.includes('T') ? dateString : dateString + 'T00:00:00Z'); + return formatDateFns(date, 'MMM do, yyyy'); + } catch (error) { + return 'Invalid Date'; + } +}; export default function SwapsPage() { - const { user, isLoadingAuth } = useAuthContext(); + const { user, isLoadingAuth, userPreferences } = useAuthContext(); const { selectedDateRange } = useDateRange(); + const { toast } = useToast(); + + const [swaps, setSwaps] = useState([]); + const [accounts, setAccounts] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - useEffect(() => { - if (!isLoadingAuth) { + const [isAddSwapDialogOpen, setIsAddSwapDialogOpen] = useState(false); + const [editingSwap, setEditingSwap] = useState(null); + const [swapToDelete, setSwapToDelete] = useState(null); + const [isDeletingSwap, setIsDeletingSwap] = useState(false); + + const preferredCurrency = useMemo(() => userPreferences?.preferredCurrency || 'BRL', [userPreferences]); + + const fetchData = useCallback(async () => { + if (!user || isLoadingAuth) { setIsLoading(false); - if (!user) { - setError("Please log in to view swap transactions."); + if (!user && !isLoadingAuth) setError("Please log in to view swaps."); + return; + } + setIsLoading(true); + setError(null); + try { + const [fetchedSwaps, fetchedAccounts] = await Promise.all([ + getSwaps(), + getAccounts() + ]); + setSwaps(fetchedSwaps); + setAccounts(fetchedAccounts); + } catch (err: any) { + console.error("Failed to fetch swap data:", err); + setError("Could not load swap data. " + err.message); + toast({ title: "Error", description: err.message || "Failed to load data.", variant: "destructive" }); + } finally { + setIsLoading(false); + } + }, [user, isLoadingAuth, toast]); + + useEffect(() => { + fetchData(); + const handleStorage = () => fetchData(); // Re-fetch data on any 'storage' event + window.addEventListener('storage', handleStorage); + return () => window.removeEventListener('storage', handleStorage); + }, [fetchData]); + + const filteredSwaps = useMemo(() => { + return swaps.filter(swap => { + const swapDate = parseISO(swap.date.includes('T') ? swap.date : swap.date + 'T00:00:00Z'); + if (!selectedDateRange.from || !selectedDateRange.to) return true; + return isWithinInterval(swapDate, { start: selectedDateRange.from, end: selectedDateRange.to }); + }).sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + }, [swaps, selectedDateRange]); + + const handleSwapSubmit = async (data: NewSwapData) => { + try { + if (editingSwap) { + await updateSwap({ ...editingSwap, ...data }); + toast({ title: "Success", description: "Swap updated successfully." }); + } else { + await addSwap(data); + toast({ title: "Success", description: "Swap recorded successfully." }); } + setIsAddSwapDialogOpen(false); + setEditingSwap(null); + fetchData(); // Refresh data + } catch (error: any) { + toast({ title: "Error", description: `Could not save swap: ${error.message}`, variant: "destructive" }); } - }, [user, isLoadingAuth]); + }; + + const openEditSwapDialog = (swap: Swap) => { + setEditingSwap(swap); + setIsAddSwapDialogOpen(true); + }; - if (isLoading) { - return

Loading swap data...

; - } + const handleDeleteSwap = (swapId: string) => { + const swap = swaps.find(s => s.id === swapId); + if (swap) setSwapToDelete(swap); + }; + + const confirmDeleteSwap = async () => { + if (!swapToDelete) return; + setIsDeletingSwap(true); + try { + await deleteSwap(swapToDelete.id); + toast({ title: "Success", description: "Swap record deleted." }); + setSwapToDelete(null); + fetchData(); // Refresh data + } catch (error: any) { + toast({ title: "Error", description: `Could not delete swap: ${error.message}`, variant: "destructive" }); + } finally { + setIsDeletingSwap(false); + } + }; + + const getPlatformAccountName = (accountId: string) => accounts.find(a => a.id === accountId)?.name || 'Unknown Platform'; + + const calculateEffectiveRate = (swap: Swap): string => { + if (swap.fromAmount > 0 && swap.toAmount > 0 && swap.fromAsset && swap.toAsset) { + const rate = swap.toAmount / swap.fromAmount; + return `1 ${swap.fromAsset.toUpperCase()} = ${rate.toFixed(Math.max(2, Math.min(8, (rate < 0.0001 ? 10 : 6))))} ${swap.toAsset.toUpperCase()}`; + } + return "N/A"; + }; - if (error) { - return
{error}
; - } return (
-

Swap Transactions & Analysis

- - - - How to Record Swaps & Conversions Currently - -

- To accurately track currency swaps (e.g., EUR to BRL within Wise) or crypto trades (e.g., BTC to ETH on Binance), please follow these steps for now: -

-
    -
  1. - Represent Asset Holdings as Separate Accounts: For platforms like Wise or Binance, create separate "accounts" in GoldQuest for each currency/asset you hold. For example: -
      -
    • "Wise EUR" (Currency: EUR)
    • -
    • "Wise BRL" (Currency: BRL)
    • -
    • "Binance BTC" (Currency: BTC)
    • -
    • "Binance ETH" (Currency: ETH)
    • -
    -
  2. -
  3. - Record the Swap/Conversion as a Transfer: Use the "Add Transfer" functionality. -
      -
    • From: The source "account" and currency (e.g., "Wise EUR").
    • -
    • Amount: The amount of the source currency converted (e.g., 100 EUR).
    • -
    • To: The destination "account" and currency (e.g., "Wise BRL").
    • -
    • Amount in Destination Account: The amount of the destination currency received (e.g., 530 BRL). The app will show the effective rate.
    • -
    -
  4. -
  5. - Record Fees Separately: If there was a fee for the swap/conversion, record it as a separate "Expense" transaction from the relevant account/currency (e.g., an expense of 0.50 EUR from "Wise EUR", categorized as "FX Fees" or "Trading Fees"). -
  6. -
-

- Future Enhancements: This "Swaps" page will be enhanced to automatically identify these patterns, provide a consolidated view of your swap activities, analyze effective exchange rates, and track associated fees more directly. A dedicated "Record Swap" form is also planned. -

-
-
+
+

Swap Transactions

+ { + setIsAddSwapDialogOpen(isOpen); + if (!isOpen) setEditingSwap(null); + }}> + + + + + + {editingSwap ? 'Edit Swap Record' : 'Record New Swap'} + + Log a currency or cryptocurrency swap event. This records the details of the swap itself. + + + {isLoadingAuth || (isLoading && !accounts.length) ? () : ( + + )} + + +
+ + {error && ( +
{error}
+ )} - - - Swap Activity Overview - - - Consolidated view of your currency and cryptocurrency swaps, including effective rates and fees (Coming Soon). - + Swap History + Overview of your recorded swap activities for the selected period. -
- -

- Detailed Swap Analysis Coming Soon! -

-

- This section will provide insights into your swap activities, helping you track conversions and understand associated costs. -

-
+ {isLoading && filteredSwaps.length === 0 ? ( +
+ {[...Array(3)].map((_, i) => )} +
+ ) : filteredSwaps.length > 0 ? ( + + + + Date + Platform + From + To + Rate + Fee + Notes + Actions + + + + {filteredSwaps.map((swap) => ( + + {formatDate(swap.date)} + {getPlatformAccountName(swap.platformAccountId)} + {formatCurrency(swap.fromAmount, swap.fromAsset, swap.fromAsset, false)} + {formatCurrency(swap.toAmount, swap.toAsset, swap.toAsset, false)} + {calculateEffectiveRate(swap)} + + {swap.feeAmount && swap.feeAmount > 0 && swap.feeCurrency + ? formatCurrency(swap.feeAmount, swap.feeCurrency, swap.feeCurrency, false) + : '-'} + + {swap.notes || '-'} + + + + + + + openEditSwapDialog(swap)}> + Edit + + + +
{ e.stopPropagation(); handleDeleteSwap(swap.id); }} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') {e.stopPropagation(); handleDeleteSwap(swap.id);}}} + > + Delete +
+
+ {swapToDelete?.id === swap.id && ( + + + Are you sure? + + This action will permanently delete this swap record for '{swap.fromAmount} {swap.fromAsset} to {swap.toAmount} {swap.toAsset}'. + + + + setSwapToDelete(null)} disabled={isDeletingSwap}>Cancel + + {isDeletingSwap ? "Deleting..." : "Delete Swap Record"} + + + + )} +
+
+
+
+
+ ))} +
+
+ ) : ( +
+

No swap transactions recorded for this period.

+
+ )}
diff --git a/src/components/swaps/add-swap-form.tsx b/src/components/swaps/add-swap-form.tsx new file mode 100644 index 0000000..f69498a --- /dev/null +++ b/src/components/swaps/add-swap-form.tsx @@ -0,0 +1,301 @@ + +'use client'; + +import type { FC } from 'react'; +import { useForm, useMemo as reactUseMemo } from 'react-hook-form'; // Renamed useMemo to avoid conflict +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Calendar } from "@/components/ui/calendar"; +import { CalendarIcon, Repeat } from 'lucide-react'; +import { cn } from "@/lib/utils"; +import { format as formatDateFns, parseISO } from 'date-fns'; +import type { Account } from '@/services/account-sync'; +import { supportedCurrencies, getCurrencySymbol } from '@/lib/currency'; +import type { NewSwapData, Swap } from '@/services/swaps'; +import { Textarea } from '@/components/ui/textarea'; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Info } from 'lucide-react'; + +// Combine fiat and common crypto for asset selection +const commonCryptoAssets = ["BTC", "ETH", "SOL", "USDT", "USDC", "ADA", "XRP", "DOT", "DOGE"]; +const allAssets = [...new Set([...supportedCurrencies, ...commonCryptoAssets])].sort(); + + +const formSchema = z.object({ + date: z.date({ required_error: "Swap date is required" }), + platformAccountId: z.string().min(1, "Platform/Account is required"), + fromAsset: z.string().min(1, "Source asset is required").refine(val => allAssets.includes(val.toUpperCase()), "Unsupported source asset"), + fromAmount: z.coerce.number({ invalid_type_error: "Amount must be a number"}).positive("Source amount must be positive"), + toAsset: z.string().min(1, "Destination asset is required").refine(val => allAssets.includes(val.toUpperCase()), "Unsupported destination asset"), + toAmount: z.coerce.number({ invalid_type_error: "Amount must be a number"}).positive("Destination amount must be positive"), + feeAmount: z.coerce.number({ invalid_type_error: "Fee must be a number"}).min(0, "Fee cannot be negative").optional().nullable(), + feeCurrency: z.string().min(3, "Fee currency is required if fee amount is entered").optional().nullable().refine(val => val ? allAssets.includes(val.toUpperCase()) : true, "Unsupported fee currency"), + notes: z.string().max(500, "Notes too long").optional().nullable(), +}).refine(data => data.fromAsset.toUpperCase() !== data.toAsset.toUpperCase(), { + message: "Source and destination assets must be different.", + path: ["toAsset"], +}).refine(data => (data.feeAmount && data.feeAmount > 0) ? !!data.feeCurrency : true, { + message: "Fee currency is required if a fee amount is entered and is greater than zero.", + path: ["feeCurrency"], +}); + +export type AddSwapFormData = z.infer; + +interface AddSwapFormProps { + onSubmit: (data: NewSwapData) => Promise | void; + isLoading: boolean; + accounts: Account[]; // Accounts in the app, to select the "platform" + initialData?: Swap; +} + +const AddSwapForm: FC = ({ onSubmit: passedOnSubmit, isLoading, accounts, initialData }) => { + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: initialData ? { + ...initialData, + date: initialData.date ? parseISO(initialData.date) : new Date(), + feeAmount: initialData.feeAmount === null ? undefined : initialData.feeAmount, + feeCurrency: initialData.feeCurrency === null ? undefined : initialData.feeCurrency, + notes: initialData.notes || "", + } : { + date: new Date(), + platformAccountId: undefined, + fromAsset: undefined, + fromAmount: undefined, + toAsset: undefined, + toAmount: undefined, + feeAmount: undefined, + feeCurrency: undefined, + notes: "", + }, + }); + + const fromAmount = form.watch('fromAmount'); + const toAmount = form.watch('toAmount'); + const fromAssetWatch = form.watch('fromAsset'); + const toAssetWatch = form.watch('toAsset'); + + const effectiveRate = reactUseMemo(() => { // Using reactUseMemo + if (fromAmount && toAmount && fromAssetWatch && toAssetWatch && fromAmount > 0 && toAmount > 0) { + const rate = toAmount / fromAmount; + return `1 ${fromAssetWatch.toUpperCase()} = ${rate.toFixed(Math.max(2, Math.min(8, (rate < 0.0001 ? 10 : 6))))} ${toAssetWatch.toUpperCase()}`; + } + return null; + }, [fromAmount, toAmount, fromAssetWatch, toAssetWatch]); + + const handleFormSubmit = async (data: AddSwapFormData) => { + const swapDataToSave: NewSwapData = { + ...data, + date: formatDateFns(data.date, 'yyyy-MM-dd'), + fromAsset: data.fromAsset.toUpperCase(), + toAsset: data.toAsset.toUpperCase(), + feeCurrency: data.feeCurrency ? data.feeCurrency.toUpperCase() : null, + feeAmount: data.feeAmount === undefined || data.feeAmount === null ? null : data.feeAmount, + notes: data.notes || null, + }; + await passedOnSubmit(swapDataToSave); + if (!initialData?.id) { + form.reset({ + date: new Date(), platformAccountId: undefined, fromAsset: undefined, fromAmount: undefined, + toAsset: undefined, toAmount: undefined, feeAmount: undefined, feeCurrency: undefined, notes: "" + }); + } + }; + + return ( + + + + + Important Note on Balances + + Recording a swap here logs the event. To reflect changes in your GoldQuest account balances, + please also record corresponding transfers between your currency-specific sub-accounts (e.g., "Wise EUR" to "Wise BRL") + or adjust balances via expense/income entries if you manage platforms as single-currency accounts. + + + +
+ ( + + Platform/Service Account + + The account in GoldQuest representing the service where the swap occurred. + + + )} + /> + ( + + Swap Date + + + + + + + + date > new Date()} /> + + + + + )} + /> +
+ +
+ +

Swap Details

+
+ +
+ ( + + From Asset + + + + )} + /> + ( + + From Amount + + + + )} + /> +
+ +
+ ( + + To Asset + + + + )} + /> + ( + + To Amount (Received) + + + + )} + /> +
+ {effectiveRate && ( +
+ Effective Rate: {effectiveRate} +
+ )} + +
+

Fees (Optional)

+
+ +
+ ( + + Fee Amount + field.onChange(e.target.value === '' ? null : parseFloat(e.target.value))} /> + + + )} + /> + ( + + Fee Currency + + + + )} + /> +
+ + ( + + Notes (Optional) +