From 534a14790e777f3bd3d993fe6083fef40a9727ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Tue, 26 May 2026 15:57:23 +0200 Subject: [PATCH] feat(accounts): add transaction action (#438) ## Summary - add Add transaction action to disconnected transactional account detail pages - keep balance actions grouped as Update balance | Import balances | menu - center empty data-table messages vertically ## Tests - php artisan test --compact tests/Feature/LocalizationTest.php tests/Feature/AccountControllerTest.php - npm test -- resources/js/pages/Accounts/Show.test.tsx - npx eslint resources/js/pages/Accounts/Show.tsx resources/js/pages/Accounts/Show.test.tsx resources/js/components/ui/data-table.tsx Note: npm run types still fails due existing unrelated TypeScript errors. --- lang/es.json | 1 + .../transactions/edit-transaction-dialog.tsx | 12 +- resources/js/components/ui/data-table.tsx | 9 +- resources/js/pages/Accounts/Show.test.tsx | 132 ++++++++++++++++++ resources/js/pages/Accounts/Show.tsx | 56 +++++++- 5 files changed, 202 insertions(+), 8 deletions(-) create mode 100644 resources/js/pages/Accounts/Show.test.tsx diff --git a/lang/es.json b/lang/es.json index 6eeb243c..ead5139a 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1021,6 +1021,7 @@ "Please fill in all required fields.": "Por favor, completa todos los campos obligatorios.", "Please proceed with caution, this cannot be undone.": "Por favor procede con precaución, esto no se puede deshacer.", "Please select a bank.": "Por favor, selecciona un banco.", + "Please unlock your encryption key to add transactions": "Por favor desbloquea tu clave de encriptación para agregar transacciones", "Please unlock your encryption key to import transactions": "Por favor desbloquea tu clave de encriptación para importar transacciones", "Please unlock your encryption key to save transactions": "Por favor desbloquea tu clave de encriptación para guardar transacciones", "Please verify your email address by clicking on the link we just emailed to you.": "Por favor verifica tu dirección de correo electrónico haciendo clic en el enlace que te acabamos de enviar.", diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index 40256a50..32fe8da4 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -64,6 +64,7 @@ interface EditTransactionDialogProps { ) => void; onLabelCreated?: (label: Label) => void; mode: 'create' | 'edit'; + initialAccountId?: string | null; } export function EditTransactionDialog({ @@ -79,6 +80,7 @@ export function EditTransactionDialog({ onCategorized, onLabelCreated, mode, + initialAccountId = null, }: EditTransactionDialogProps) { const locale = useLocale(); const STORAGE_KEY_UPDATE_BALANCE = @@ -123,14 +125,20 @@ export function EditTransactionDialog({ setDescription(''); setAmount(0); const availableAccounts = filterTransactionalAccounts(accounts); + const initialAccount = availableAccounts.find( + (account) => account.id === initialAccountId, + ); setAccountId( - availableAccounts.length > 0 ? availableAccounts[0].id : '', + initialAccount?.id ?? + (availableAccounts.length > 0 + ? availableAccounts[0].id + : ''), ); setCategoryId('null'); setSelectedLabelIds([]); setNotes(''); } - }, [mode, transaction, open, accounts]); + }, [mode, transaction, open, accounts, initialAccountId]); useEffect(() => { if (!open || mode !== 'create') return; diff --git a/resources/js/components/ui/data-table.tsx b/resources/js/components/ui/data-table.tsx index 4daf4266..6d7b24db 100644 --- a/resources/js/components/ui/data-table.tsx +++ b/resources/js/components/ui/data-table.tsx @@ -232,7 +232,14 @@ export function DataTable({ {emptyMessage} diff --git a/resources/js/pages/Accounts/Show.test.tsx b/resources/js/pages/Accounts/Show.test.tsx new file mode 100644 index 00000000..98d4ba33 --- /dev/null +++ b/resources/js/pages/Accounts/Show.test.tsx @@ -0,0 +1,132 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { type ReactNode } from 'react'; +import { describe, expect, it, vi } from 'vitest'; + +import AccountShow from './Show'; + +vi.mock('@inertiajs/react', () => ({ + Head: () => null, + router: { reload: vi.fn() }, +})); + +vi.mock('@/actions/App/Http/Controllers/AccountController', () => ({ + index: () => ({ url: '/accounts' }), + show: { url: (id: string) => `/accounts/${id}` }, +})); + +vi.mock('@/actions/App/Http/Controllers/LoanDetailController', () => ({ + update: { form: () => ({ action: '/loan-detail', method: 'patch' }) }, +})); + +vi.mock('@/actions/App/Http/Controllers/RealEstateDetailController', () => ({ + update: { + form: () => ({ action: '/real-estate-detail', method: 'patch' }), + }, +})); + +vi.mock('@/contexts/encryption-key-context', () => ({ + useEncryptionKey: () => ({ isKeySet: true }), +})); + +vi.mock('@/layouts/app/app-sidebar-layout', () => ({ + default: ({ children }: { children: ReactNode }) => <>{children}, +})); + +vi.mock('@/components/accounts/account-balance-chart', () => ({ + AccountBalanceChart: () => null, +})); + +vi.mock('@/components/accounts/balances-modal', () => ({ + BalancesModal: () => null, +})); + +vi.mock('@/components/accounts/edit-account-dialog', () => ({ + EditAccountDialog: () => null, +})); + +vi.mock('@/components/accounts/edit-loan-detail-dialog', () => ({ + EditLoanDetailDialog: () => null, +})); + +vi.mock('@/components/accounts/import-balances-drawer', () => ({ + ImportBalancesDrawer: () => null, +})); + +vi.mock('@/components/accounts/update-balance-dialog', () => ({ + UpdateBalanceDialog: () => null, +})); + +const editTransactionDialog = vi.fn(); + +vi.mock('@/components/transactions/edit-transaction-dialog', () => ({ + EditTransactionDialog: (props: Record) => { + editTransactionDialog(props); + return null; + }, +})); + +vi.mock('@/components/transactions/transaction-list', () => ({ + TransactionList: () => null, +})); + +vi.mock('@/components/bank-logo', () => ({ + BankLogo: () => null, +})); + +vi.mock('@/components/mobile-back-button', () => ({ + MobileBackButton: () => null, +})); + +const baseAccount = { + id: 'account-1', + name: 'Checking', + name_iv: null, + encrypted: false, + bank: null, + type: 'checking' as const, + currency_code: 'EUR', + banking_connection_id: null, + external_account_id: null, + linked_at: null, +}; + +const renderPage = (account = baseAccount) => + render( + , + ); + +describe('AccountShow', () => { + it('opens create transaction dialog for disconnected transactional accounts', () => { + renderPage(); + + fireEvent.click( + screen.getByRole('button', { name: 'Add transaction' }), + ); + + expect(editTransactionDialog).toHaveBeenLastCalledWith( + expect.objectContaining({ + open: true, + initialAccountId: 'account-1', + mode: 'create', + }), + ); + }); + + it('hides transaction action for connected accounts', () => { + renderPage({ + ...baseAccount, + banking_connection_id: 'connection-1', + }); + + expect( + screen.queryByRole('button', { name: 'Transaction' }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/resources/js/pages/Accounts/Show.tsx b/resources/js/pages/Accounts/Show.tsx index e68e3f15..bbdbab04 100644 --- a/resources/js/pages/Accounts/Show.tsx +++ b/resources/js/pages/Accounts/Show.tsx @@ -16,6 +16,7 @@ import { AmountTrendIndicator } from '@/components/dashboard/amount-trend-indica import HeadingSmall from '@/components/heading-small'; import InputError from '@/components/input-error'; import { MobileBackButton } from '@/components/mobile-back-button'; +import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog'; import { TransactionList } from '@/components/transactions/transaction-list'; import { AmountDisplay } from '@/components/ui/amount-display'; import { AmountInput } from '@/components/ui/amount-input'; @@ -39,6 +40,7 @@ import { SelectValue, } from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; +import { useEncryptionKey } from '@/contexts/encryption-key-context'; import { useChartColors } from '@/hooks/use-chart-color-scheme'; import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'; import { BreadcrumbItem } from '@/types'; @@ -62,9 +64,10 @@ import { Label as LabelType } from '@/types/label'; import { formatDateMedium } from '@/utils/date'; import { __ } from '@/utils/i18n'; import { Head, router } from '@inertiajs/react'; -import { ChevronDown, Pencil } from 'lucide-react'; +import { ChevronDown, Pencil, Plus } from 'lucide-react'; import { useCallback, useMemo, useState } from 'react'; import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts'; +import { toast } from 'sonner'; interface AccountWithDetails extends Account { real_estate_detail?: RealEstateDetail; @@ -99,8 +102,11 @@ export default function AccountShow({ const [editingDetails, setEditingDetails] = useState(false); const [editingLoanDetails, setEditingLoanDetails] = useState(false); const [editLoanDialogOpen, setEditLoanDialogOpen] = useState(false); + const [createTransactionOpen, setCreateTransactionOpen] = useState(false); + const [transactionRefreshKey, setTransactionRefreshKey] = useState(0); const [chartComputedData, setChartComputedData] = useState(null); + const { isKeySet } = useEncryptionKey(); const handleChartDataLoaded = useCallback((data: ChartComputedData) => { setChartComputedData(data); @@ -110,6 +116,22 @@ export default function AccountShow({ setChartRefreshKey((prev) => prev + 1); } + function handleAddTransaction() { + if (!isKeySet) { + toast.error( + __('Please unlock your encryption key to add transactions'), + ); + return; + } + + setCreateTransactionOpen(true); + } + + function handleTransactionCreated() { + setTransactionRefreshKey((prev) => prev + 1); + handleBalanceUpdated(); + } + const isConnected = !!account.banking_connection_id; const isLoan = account.type === 'loan'; const isRealEstate = account.type === 'real_estate'; @@ -117,6 +139,8 @@ export default function AccountShow({ const loanDetail = account.loan_detail; const linkedLoanAccount = account.linked_loan_account; const hasLinkedLoan = isRealEstate && !!linkedLoanAccount; + const canCreateTransaction = + !isConnected && isTransactionalAccount(account); const breadcrumbs: BreadcrumbItem[] = [ { @@ -256,7 +280,16 @@ export default function AccountShow({ ) : ( - +
+ {canCreateTransaction && ( + + )} - -
)} @@ -360,6 +391,7 @@ export default function AccountShow({ {isTransactionalAccount(account) && ( + +