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.
This commit is contained in:
Víctor Falcón 2026-05-26 15:57:23 +02:00 committed by GitHub
parent 235911b960
commit 534a14790e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 202 additions and 8 deletions

View File

@ -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.",

View File

@ -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;

View File

@ -232,7 +232,14 @@ export function DataTable<TData, TValue>({
<TableRow>
<TableCell
colSpan={visibleColumnCount}
className="h-24 text-center"
className="h-24 text-center align-middle"
style={
maxHeight
? {
height: 80,
}
: undefined
}
>
{emptyMessage}
</TableCell>

View File

@ -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<string, unknown>) => {
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(
<AccountShow
account={account}
categories={[]}
accounts={[account]}
banks={[]}
labels={[]}
automationRules={[]}
/>,
);
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();
});
});

View File

@ -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<ChartComputedData | null>(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({
</ButtonGroup>
</ButtonGroup>
) : (
<ButtonGroup>
<div className="flex flex-wrap gap-2">
{canCreateTransaction && (
<Button
variant="outline"
onClick={handleAddTransaction}
>
<Plus className="h-4 w-4" />
{__('Add transaction')}
</Button>
)}
<ButtonGroup>
<Button
variant="outline"
@ -264,8 +297,6 @@ export default function AccountShow({
>
{updateBalanceLabel}
</Button>
</ButtonGroup>
<ButtonGroup>
<Button
variant="outline"
onClick={() => setImportBalancesOpen(true)}
@ -298,7 +329,7 @@ export default function AccountShow({
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</ButtonGroup>
</div>
)}
</div>
@ -360,6 +391,7 @@ export default function AccountShow({
{isTransactionalAccount(account) && (
<TransactionList
key={transactionRefreshKey}
categories={categories}
accounts={accounts}
banks={banks}
@ -390,6 +422,20 @@ export default function AccountShow({
onSuccess={handleBalanceUpdated}
/>
<EditTransactionDialog
transaction={null}
categories={categories}
accounts={accounts}
banks={banks}
labels={labels}
automationRules={automationRules}
open={createTransactionOpen}
onOpenChange={setCreateTransactionOpen}
onSuccess={handleTransactionCreated}
mode="create"
initialAccountId={account.id}
/>
<BalancesModal
account={account}
open={balancesOpen}