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:
parent
235911b960
commit
534a14790e
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue