diff --git a/app/Http/Controllers/AccountBalanceController.php b/app/Http/Controllers/AccountBalanceController.php index 319192c0..8d61e58b 100644 --- a/app/Http/Controllers/AccountBalanceController.php +++ b/app/Http/Controllers/AccountBalanceController.php @@ -12,6 +12,20 @@ class AccountBalanceController extends Controller { use AuthorizesRequests; + /** + * List paginated balances for an account. + */ + public function index(Account $account): JsonResponse + { + $this->authorize('view', $account); + + $balances = $account->balances() + ->orderBy('balance_date', 'desc') + ->paginate(50); + + return response()->json($balances); + } + /** * Update or create the current balance for an account. */ @@ -35,4 +49,20 @@ class AccountBalanceController extends Controller 'data' => $balance, ]); } + + /** + * Delete a balance record. + */ + public function destroy(Account $account, AccountBalance $accountBalance): JsonResponse + { + $this->authorize('update', $account); + + if ($accountBalance->account_id !== $account->id) { + abort(404); + } + + $accountBalance->delete(); + + return response()->json(null, 204); + } } diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php new file mode 100644 index 00000000..cac129de --- /dev/null +++ b/app/Http/Controllers/AccountController.php @@ -0,0 +1,67 @@ +user(); + + $accounts = Account::query() + ->where('user_id', $user->id) + ->with('bank:id,name,logo') + ->orderByRaw("FIELD(type, 'checking', 'savings', 'investment', 'retirement', 'loan', 'credit_card', 'others')") + ->orderBy('name') + ->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']); + + return Inertia::render('Accounts/Index', [ + 'accounts' => $accounts, + ]); + } + + public function show(Request $request, Account $account): Response + { + $this->authorize('view', $account); + + $user = $request->user(); + + $categories = Category::query() + ->where('user_id', $user->id) + ->orderBy('name') + ->get(['id', 'name', 'icon', 'color']); + + $accounts = Account::query() + ->where('user_id', $user->id) + ->with('bank:id,name,logo') + ->orderBy('name') + ->get(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code']); + + $banks = Bank::query() + ->where(function ($q) use ($user) { + $q->whereNull('user_id') + ->orWhere('user_id', $user->id); + }) + ->orderBy('name') + ->get(['id', 'name', 'logo']); + + $account->load('bank:id,name,logo'); + + return Inertia::render('Accounts/Show', [ + 'account' => $account->only(['id', 'name', 'name_iv', 'bank_id', 'type', 'currency_code', 'bank']), + 'categories' => $categories, + 'accounts' => $accounts, + 'banks' => $banks, + ]); + } +} diff --git a/app/Http/Controllers/Api/DashboardAnalyticsController.php b/app/Http/Controllers/Api/DashboardAnalyticsController.php index 8153c267..1e87a615 100644 --- a/app/Http/Controllers/Api/DashboardAnalyticsController.php +++ b/app/Http/Controllers/Api/DashboardAnalyticsController.php @@ -116,6 +116,46 @@ class DashboardAnalyticsController extends Controller ]); } + public function accountBalanceEvolution(Request $request, Account $account) + { + if ($account->user_id !== $request->user()->id) { + abort(403); + } + + $validated = $request->validate([ + 'from' => 'required|date', + 'to' => 'required|date', + ]); + + $start = Carbon::parse($validated['from']); + $end = Carbon::parse($validated['to']); + + $points = []; + $current = $start->copy()->startOfMonth(); + $endMonth = $end->copy()->startOfMonth(); + + while ($current->lte($endMonth)) { + $date = $current->copy()->endOfMonth(); + $points[] = [ + 'month' => $date->format('Y-m'), + 'timestamp' => $date->timestamp, + 'value' => $this->getBalanceAt($account->id, $date), + ]; + $current->addMonth(); + } + + return response()->json([ + 'data' => $points, + 'account' => [ + 'id' => $account->id, + 'name' => $account->name, + 'name_iv' => $account->name_iv, + 'type' => $account->type, + 'currency_code' => $account->currency_code, + ], + ]); + } + public function topCategories(Request $request) { $validated = $request->validate([ diff --git a/app/Policies/AccountPolicy.php b/app/Policies/AccountPolicy.php index dc80eade..5c409f0d 100644 --- a/app/Policies/AccountPolicy.php +++ b/app/Policies/AccountPolicy.php @@ -20,7 +20,7 @@ class AccountPolicy */ public function view(User $user, Account $account): bool { - return false; + return $user->id === $account->user_id; } /** diff --git a/resources/js/components/accounts/account-balance-chart.tsx b/resources/js/components/accounts/account-balance-chart.tsx new file mode 100644 index 00000000..81e926af --- /dev/null +++ b/resources/js/components/accounts/account-balance-chart.tsx @@ -0,0 +1,278 @@ +import { EncryptedText } from '@/components/encrypted-text'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from '@/components/ui/chart'; +import { Account } from '@/types/account'; +import { format, subMonths } from 'date-fns'; +import { TrendingDown, TrendingUp } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { Bar, BarChart, XAxis } from 'recharts'; + +interface BalanceDataPoint { + month: string; + timestamp: number; + value: number; +} + +interface AccountBalanceData { + data: BalanceDataPoint[]; + account: { + id: string; + name: string; + name_iv: string; + type: string; + currency_code: string; + }; +} + +interface AccountBalanceChartProps { + account: Account; + loading?: boolean; + refreshKey?: number; +} + +function formatXAxisLabel(value: string): string { + const [year, month] = value.split('-'); + const date = new Date(parseInt(year), parseInt(month) - 1); + const monthName = date.toLocaleString('en-US', { month: 'short' }); + const currentYear = new Date().getFullYear(); + + if (parseInt(year) === currentYear) { + return monthName; + } + + return `${monthName} ${year.slice(-2)}`; +} + +function formatCurrency(value: number, currencyCode: string): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: currencyCode, + minimumFractionDigits: 0, + maximumFractionDigits: 0, + }).format(value / 100); +} + +function calculateTrend( + data: BalanceDataPoint[], + monthsBack: number, +): number | null { + if (data.length < 2) return null; + + const currentIndex = data.length - 1; + const previousIndex = Math.max(0, data.length - 1 - monthsBack); + + if (currentIndex === previousIndex) return null; + + const currentValue = data[currentIndex].value; + const previousValue = data[previousIndex].value; + + if (previousValue === 0) return null; + + return ((currentValue - previousValue) / Math.abs(previousValue)) * 100; +} + +function TrendIndicator({ + trend, + label, +}: { + trend: number | null; + label: string; +}) { + if (trend === null) return null; + + const isPositive = trend >= 0; + const Icon = isPositive ? TrendingUp : TrendingDown; + const iconColorClass = isPositive + ? 'text-green-600/70 dark:text-green-400/70' + : 'text-red-600/70 dark:text-red-400/70'; + + return ( +
+ + {isPositive ? '+' : ''} + {trend.toFixed(1)}% + + {label} + +
+ ); +} + +export function AccountBalanceChart({ + account, + loading: initialLoading, + refreshKey, +}: AccountBalanceChartProps) { + const [balanceData, setBalanceData] = useState( + null, + ); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + async function fetchBalanceData() { + setIsLoading(true); + try { + const now = new Date(); + const to = format(now, 'yyyy-MM-dd'); + const from = format(subMonths(now, 12), 'yyyy-MM-dd'); + + const params = new URLSearchParams({ from, to }); + const response = await fetch( + `/api/dashboard/account/${account.id}/balance-evolution?${params.toString()}`, + ); + const data = await response.json(); + setBalanceData(data); + } catch (error) { + console.error('Failed to fetch balance data:', error); + } finally { + setIsLoading(false); + } + } + + fetchBalanceData(); + }, [account.id, refreshKey]); + + const { chartData, currentBalance, monthlyTrend, yearlyTrend } = + useMemo(() => { + if (!balanceData?.data?.length) { + return { + chartData: [], + currentBalance: 0, + monthlyTrend: null, + yearlyTrend: null, + }; + } + + const data = balanceData.data; + const current = data[data.length - 1]?.value ?? 0; + + return { + chartData: data, + currentBalance: current, + monthlyTrend: calculateTrend(data, 1), + yearlyTrend: calculateTrend(data, data.length - 1), + }; + }, [balanceData]); + + const chartConfig: ChartConfig = { + value: { + label: ( + + ), + color: 'var(--color-chart-2)', + }, + }; + + const valueFormatter = (value: number): string => { + return formatCurrency(value, account.currency_code); + }; + + if (initialLoading || isLoading) { + return ( + + + Balance evolution + +
+ + + +
+ + + ); + } + + if (chartData.length === 0) { + return ( + + + Balance evolution + + +
+ No balance data available +
+
+
+ ); + } + + return ( + + +
+
+ Balance evolution + + + + +
+
+ + {formatCurrency( + currentBalance, + account.currency_code, + )} + +
+
+
+ + + + + + } + /> + + + + +
+ ); +} diff --git a/resources/js/components/accounts/account-list-card.tsx b/resources/js/components/accounts/account-list-card.tsx new file mode 100644 index 00000000..8b974910 --- /dev/null +++ b/resources/js/components/accounts/account-list-card.tsx @@ -0,0 +1,153 @@ +import { show } from '@/actions/App/Http/Controllers/AccountController'; +import { EncryptedText } from '@/components/encrypted-text'; +import { Card, CardContent } from '@/components/ui/card'; +import { AccountWithMetrics } from '@/hooks/use-dashboard-data'; +import { Link } from '@inertiajs/react'; +import { TrendingDown, TrendingUp } from 'lucide-react'; +import { Line, LineChart, ResponsiveContainer, Tooltip } from 'recharts'; +import { Button } from '../ui/button'; + +interface AccountListCardProps { + account: AccountWithMetrics; + loading?: boolean; +} + +export function AccountListCard({ account, loading }: AccountListCardProps) { + if (loading) { + return ( + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + ); + } + + const formatter = new Intl.NumberFormat('en-US', { + style: 'currency', + currency: account.currency_code, + }); + + const isPositive = account.diff >= 0; + const TrendIcon = isPositive ? TrendingUp : TrendingDown; + const trendColorClass = isPositive + ? 'text-green-600 dark:text-green-400' + : 'text-red-600 dark:text-red-400'; + + return ( + + +
+
+
+
+

+ {account.bank?.logo ? ( + {account.bank.name} + ) : ( +
+ + {account.bank?.name?.charAt( + 0, + ) || '?'} + +
+ )} + +

+
+ + {account.bank?.name || 'Unknown Bank'} + +
+
+
+
+ + {formatter.format(account.currentBalance / 100)} + +
+ + + {formatter.format( + Math.abs(account.diff) / 100, + )} + + + vs last month + +
+
+
+
+ + + { + if (!active || !payload?.length) + return null; + const data = payload[0].payload as { + date: string; + value: number; + }; + return ( +
+

+ {data.date} +

+

+ {formatter.format( + data.value / 100, + )} +

+
+ ); + }} + /> + +
+
+
+
+ + + +
+
+
+
+ ); +} diff --git a/resources/js/components/accounts/balances-modal.tsx b/resources/js/components/accounts/balances-modal.tsx new file mode 100644 index 00000000..760d77d7 --- /dev/null +++ b/resources/js/components/accounts/balances-modal.tsx @@ -0,0 +1,438 @@ +import { + destroy, + index, +} from '@/actions/App/Http/Controllers/AccountBalanceController'; +import { store } from '@/actions/App/Http/Controllers/Sync/AccountBalanceSyncController'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { AmountInput } from '@/components/ui/amount-input'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { accountBalanceSyncService } from '@/services/account-balance-sync'; +import type { Account, AccountBalance } from '@/types/account'; +import { Pencil, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useState } from 'react'; + +interface BalancesModalProps { + account: Account; + open: boolean; + onOpenChange: (open: boolean) => void; + onBalanceChange?: () => void; +} + +interface PaginatedResponse { + data: AccountBalance[]; + current_page: number; + last_page: number; + per_page: number; + total: number; +} + +export function BalancesModal({ + account, + open, + onOpenChange, + onBalanceChange, +}: BalancesModalProps) { + const [balances, setBalances] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [lastPage, setLastPage] = useState(1); + const [total, setTotal] = useState(0); + + const [editingBalance, setEditingBalance] = useState( + null, + ); + const [editDate, setEditDate] = useState(''); + const [editAmount, setEditAmount] = useState(0); + const [isEditSubmitting, setIsEditSubmitting] = useState(false); + + const [deletingBalance, setDeletingBalance] = + useState(null); + const [isDeleting, setIsDeleting] = useState(false); + + const formatter = new Intl.NumberFormat(undefined, { + style: 'currency', + currency: account.currency_code, + }); + + const fetchBalances = useCallback( + async (page: number) => { + setIsLoading(true); + try { + const response = await fetch( + index.url(account.id, { query: { page: String(page) } }), + { + headers: { + Accept: 'application/json', + }, + }, + ); + + if (!response.ok) { + throw new Error('Failed to fetch balances'); + } + + const data: PaginatedResponse = await response.json(); + setBalances(data.data); + setCurrentPage(data.current_page); + setLastPage(data.last_page); + setTotal(data.total); + } catch (err) { + console.error('Failed to fetch balances:', err); + } finally { + setIsLoading(false); + } + }, + [account.id], + ); + + useEffect(() => { + if (open) { + fetchBalances(1); + } else { + setBalances([]); + setCurrentPage(1); + setLastPage(1); + setTotal(0); + } + }, [open, fetchBalances]); + + function handleEditClick(balance: AccountBalance) { + setEditingBalance(balance); + setEditDate(balance.balance_date.split('T')[0]); + setEditAmount(balance.balance); + } + + async function handleEditSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!editingBalance) return; + + setIsEditSubmitting(true); + try { + const response = await fetch(store.url(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-XSRF-TOKEN': decodeURIComponent( + document.cookie + .split('; ') + .find((row) => row.startsWith('XSRF-TOKEN=')) + ?.split('=')[1] || '', + ), + Accept: 'application/json', + }, + body: JSON.stringify({ + account_id: account.id, + balance_date: editDate, + balance: editAmount, + }), + }); + + if (!response.ok) { + throw new Error('Failed to update balance'); + } + + await accountBalanceSyncService.sync(); + + setEditingBalance(null); + fetchBalances(currentPage); + onBalanceChange?.(); + } catch (err) { + console.error('Failed to update balance:', err); + } finally { + setIsEditSubmitting(false); + } + } + + async function handleDelete() { + if (!deletingBalance) return; + + setIsDeleting(true); + try { + const response = await fetch( + destroy.url({ + account: account.id, + accountBalance: deletingBalance.id, + }), + { + method: 'DELETE', + headers: { + 'X-XSRF-TOKEN': decodeURIComponent( + document.cookie + .split('; ') + .find((row) => row.startsWith('XSRF-TOKEN=')) + ?.split('=')[1] || '', + ), + Accept: 'application/json', + }, + }, + ); + + if (!response.ok) { + throw new Error('Failed to delete balance'); + } + + await accountBalanceSyncService.sync(); + + setDeletingBalance(null); + fetchBalances(currentPage); + onBalanceChange?.(); + } catch (err) { + console.error('Failed to delete balance:', err); + } finally { + setIsDeleting(false); + } + } + + function formatDate(dateString: string): string { + const date = new Date(dateString); + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); + } + + return ( + <> + + + + Balance History + + View and manage balance records for this account. + + + +
+
+ + + + Date + + Balance + + + Actions + + + + + {isLoading ? ( + + + Loading... + + + ) : balances.length === 0 ? ( + + + No balance records found. + + + ) : ( + balances.map((balance) => ( + + + {formatDate( + balance.balance_date, + )} + + + {formatter.format( + balance.balance / 100, + )} + + +
+ + +
+
+
+ )) + )} +
+
+
+
+ + {lastPage > 1 && ( +
+ + {total} balance record{total !== 1 ? 's' : ''} + +
+ + + Page {currentPage} of {lastPage} + + +
+
+ )} +
+
+ + !open && setEditingBalance(null)} + > + + + Edit Balance + + Update the balance record. + + + +
+
+ + +
+ +
+ + setEditDate(e.target.value)} + required + /> +
+ + + + + +
+
+
+ + !open && setDeletingBalance(null)} + > + + + Delete balance + + Are you sure you want to delete this balance record? + This action cannot be undone. + + + + + Cancel + + + {isDeleting ? 'Deleting...' : 'Delete'} + + + + + + ); +} diff --git a/resources/js/components/accounts/custom-bank-form.tsx b/resources/js/components/accounts/custom-bank-form.tsx index 37a66a2f..fc6a80ec 100644 --- a/resources/js/components/accounts/custom-bank-form.tsx +++ b/resources/js/components/accounts/custom-bank-form.tsx @@ -143,7 +143,7 @@ export function CustomBankForm({ Bank logo preview ) : ( diff --git a/resources/js/components/accounts/delete-account-dialog.tsx b/resources/js/components/accounts/delete-account-dialog.tsx index 1909fcad..3e66afa2 100644 --- a/resources/js/components/accounts/delete-account-dialog.tsx +++ b/resources/js/components/accounts/delete-account-dialog.tsx @@ -12,7 +12,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { db } from '@/lib/dexie-db'; import { type Account } from '@/types/account'; -import { Form } from '@inertiajs/react'; +import { Form, router } from '@inertiajs/react'; import { useState } from 'react'; interface DeleteAccountDialogProps { @@ -20,6 +20,7 @@ interface DeleteAccountDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onSuccess?: () => void; + redirectTo?: string; } export function DeleteAccountDialog({ @@ -27,6 +28,7 @@ export function DeleteAccountDialog({ open, onOpenChange, onSuccess, + redirectTo, }: DeleteAccountDialogProps) { const [confirmText, setConfirmText] = useState(''); @@ -61,6 +63,7 @@ export function DeleteAccountDialog({ setConfirmText(e.target.value)} placeholder="Type DELETE" @@ -73,7 +76,11 @@ export function DeleteAccountDialog({ onSuccess={async () => { await db.accounts.delete(account.id); handleOpenChange(false); - onSuccess?.(); + if (redirectTo) { + router.visit(redirectTo); + } else { + onSuccess?.(); + } }} > {({ processing }) => ( diff --git a/resources/js/components/accounts/edit-account-dialog.tsx b/resources/js/components/accounts/edit-account-dialog.tsx index 7dca2d3a..44c88730 100644 --- a/resources/js/components/accounts/edit-account-dialog.tsx +++ b/resources/js/components/accounts/edit-account-dialog.tsx @@ -20,6 +20,7 @@ interface EditAccountDialogProps { open: boolean; onOpenChange: (open: boolean) => void; onSuccess?: () => void; + redirectTo?: string; } export function EditAccountDialog({ @@ -27,6 +28,7 @@ export function EditAccountDialog({ open, onOpenChange, onSuccess, + redirectTo, }: EditAccountDialogProps) { const [decryptedName, setDecryptedName] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); @@ -171,9 +173,14 @@ export function EditAccountDialog({ currency_code: currencyCode, }, { + preserveScroll: true, onSuccess: () => { onOpenChange(false); - onSuccess?.(); + if (redirectTo) { + router.visit(redirectTo); + } else { + onSuccess?.(); + } }, onFinish: () => { setIsSubmitting(false); diff --git a/resources/js/components/accounts/update-balance-dialog.tsx b/resources/js/components/accounts/update-balance-dialog.tsx new file mode 100644 index 00000000..99f7434f --- /dev/null +++ b/resources/js/components/accounts/update-balance-dialog.tsx @@ -0,0 +1,149 @@ +import { store } from '@/actions/App/Http/Controllers/Sync/AccountBalanceSyncController'; +import { AmountInput } from '@/components/ui/amount-input'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { accountBalanceSyncService } from '@/services/account-balance-sync'; +import type { Account } from '@/types/account'; +import { useState } from 'react'; + +interface UpdateBalanceDialogProps { + account: Account; + open: boolean; + onOpenChange: (open: boolean) => void; + onSuccess?: () => void; +} + +function getTodayDate(): string { + const today = new Date(); + return today.toISOString().split('T')[0]; +} + +export function UpdateBalanceDialog({ + account, + open, + onOpenChange, + onSuccess, +}: UpdateBalanceDialogProps) { + const [date, setDate] = useState(getTodayDate()); + const [balance, setBalance] = useState(0); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + function handleOpenChange(newOpen: boolean) { + if (!newOpen) { + setDate(getTodayDate()); + setBalance(0); + setError(null); + } + onOpenChange(newOpen); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setIsSubmitting(true); + setError(null); + + try { + const response = await fetch(store.url(), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-XSRF-TOKEN': decodeURIComponent( + document.cookie + .split('; ') + .find((row) => row.startsWith('XSRF-TOKEN=')) + ?.split('=')[1] || '', + ), + Accept: 'application/json', + }, + body: JSON.stringify({ + account_id: account.id, + balance_date: date, + balance: balance, + }), + }); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.message || 'Failed to update balance'); + } + + await accountBalanceSyncService.sync(); + + handleOpenChange(false); + onSuccess?.(); + } catch (err) { + setError( + err instanceof Error ? err.message : 'Failed to update balance', + ); + } finally { + setIsSubmitting(false); + } + } + + return ( + + + + Update balance + + Set the balance for this account on a specific date. + + + +
+
+ + +
+ +
+ + setDate(e.target.value)} + required + /> +
+ + {error && ( +

{error}

+ )} + + + + + +
+
+
+ ); +} diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index b9a1f336..aaa4ef79 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,3 +1,4 @@ +import { index as accountsIndex } from '@/actions/App/Http/Controllers/AccountController'; import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController'; import { NavFooter } from '@/components/nav-footer'; import { NavMain } from '@/components/nav-main'; @@ -14,7 +15,13 @@ import { import { dashboard } from '@/routes'; import { type NavItem } from '@/types'; import { Link } from '@inertiajs/react'; -import { BookOpen, Folder, LayoutGrid, Receipt } from 'lucide-react'; +import { + BookOpen, + CreditCard, + Folder, + LayoutGrid, + Receipt, +} from 'lucide-react'; import AppLogo from './app-logo'; const mainNavItems: NavItem[] = [ @@ -23,6 +30,11 @@ const mainNavItems: NavItem[] = [ href: dashboard(), icon: LayoutGrid, }, + { + title: 'Accounts', + href: accountsIndex(), + icon: CreditCard, + }, { title: 'Transactions', href: transactionsIndex(), diff --git a/resources/js/components/transactions/transaction-filters.tsx b/resources/js/components/transactions/transaction-filters.tsx index 27c38e99..81700171 100644 --- a/resources/js/components/transactions/transaction-filters.tsx +++ b/resources/js/components/transactions/transaction-filters.tsx @@ -33,6 +33,7 @@ interface TransactionFiltersProps { accounts: Account[]; isKeySet: boolean; actions?: ReactNode; + hideAccountFilter?: boolean; } export function TransactionFilters({ @@ -42,6 +43,7 @@ export function TransactionFilters({ accounts, isKeySet, actions, + hideAccountFilter = false, }: TransactionFiltersProps) { const [isOpen, setIsOpen] = useState(false); const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false); @@ -83,7 +85,7 @@ export function TransactionFilters({ (filters.amountMin !== null ? 1 : 0) + (filters.amountMax !== null ? 1 : 0) + filters.categoryIds.length + - filters.accountIds.length; + (hideAccountFilter ? 0 : filters.accountIds.length); return (
@@ -336,39 +338,46 @@ export function TransactionFilters({
-
- -
- {accounts.map((account) => { - const isSelected = - filters.accountIds.includes( - account.id, + {!hideAccountFilter && ( +
+ +
+ {accounts.map((account) => { + const isSelected = + filters.accountIds.includes( + account.id, + ); + return ( + + handleAccountToggle( + account.id, + ) + } + > + + ); - return ( - - handleAccountToggle( - account.id, - ) - } - > - - - ); - })} + })} +
-
+ )}
diff --git a/resources/js/components/transactions/transaction-list.tsx b/resources/js/components/transactions/transaction-list.tsx new file mode 100644 index 00000000..8ab7901b --- /dev/null +++ b/resources/js/components/transactions/transaction-list.tsx @@ -0,0 +1,1267 @@ +import { + Cell, + ColumnFiltersState, + Row, + SortingState, + VisibilityState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table'; +import { VirtualItem, Virtualizer } from '@tanstack/react-virtual'; +import { isWithinInterval, parseISO } from 'date-fns'; +import { useLiveQuery } from 'dexie-react-hooks'; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { toast } from 'sonner'; + +import { BulkActionsBar } from '@/components/transactions/bulk-actions-bar'; +import { EditTransactionDialog } from '@/components/transactions/edit-transaction-dialog'; +import { TransactionActionsMenu } from '@/components/transactions/transaction-actions-menu'; +import { createTransactionColumns } from '@/components/transactions/transaction-columns'; +import { TransactionFilters as TransactionFiltersComponent } from '@/components/transactions/transaction-filters'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuLabel, + ContextMenuTrigger, +} from '@/components/ui/context-menu'; +import { DataTable } from '@/components/ui/data-table'; +import { DataTablePagination } from '@/components/ui/data-table-pagination'; +import { DataTableViewOptions } from '@/components/ui/data-table-view-options'; +import { Skeleton } from '@/components/ui/skeleton'; +import { Spinner } from '@/components/ui/spinner'; +import { TableCell, TableRow } from '@/components/ui/table'; +import { useEncryptionKey } from '@/contexts/encryption-key-context'; +import { decrypt, encrypt, importKey } from '@/lib/crypto'; +import { consoleDebug } from '@/lib/debug'; +import { db } from '@/lib/dexie-db'; +import { getStoredKey } from '@/lib/key-storage'; +import { evaluateRules } from '@/lib/rule-engine'; +import { appendNoteIfNotPresent } from '@/lib/utils'; +import { automationRuleSyncService } from '@/services/automation-rule-sync'; +import { transactionSyncService } from '@/services/transaction-sync'; +import { type Account, type Bank } from '@/types/account'; +import { type Category } from '@/types/category'; +import { + type DecryptedTransaction, + type TransactionFilters as Filters, +} from '@/types/transaction'; +import { UUID } from '@/types/uuid'; + +const COLUMN_VISIBILITY_KEY = 'transactions-column-visibility'; + +interface TransactionRowProps { + row: Row; + virtualRow: VirtualItem; + rowVirtualizer: Virtualizer; + onEdit: (transaction: DecryptedTransaction) => void; + onReEvaluateRules: (transaction: DecryptedTransaction) => void; + onDelete: (transaction: DecryptedTransaction) => void; +} + +function TransactionRowComponent({ + row, + virtualRow, + rowVirtualizer, + onEdit, + onReEvaluateRules, + onDelete, +}: TransactionRowProps) { + const transaction = row.original; + const [contextMenuOpen, setContextMenuOpen] = useState(false); + + return ( + + + {} + + {row + .getVisibleCells() + .map((cell: Cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + + + Actions + onEdit(transaction)}> + Edit + + onReEvaluateRules(transaction)}> + Re-evaluate rules + + onDelete(transaction)} + variant="destructive" + > + Delete + + + + ); +} + +function getInitialColumnVisibility(): VisibilityState { + try { + const stored = localStorage.getItem(COLUMN_VISIBILITY_KEY); + if (stored) { + return JSON.parse(stored); + } + } catch (error) { + console.error( + 'Failed to load column visibility from localStorage:', + error, + ); + } + return { account: false }; +} + +export interface TransactionListProps { + categories: Category[]; + accounts: Account[]; + banks: Bank[]; + accountId?: UUID; + pageSize?: number; + hideAccountFilter?: boolean; + showActionsMenu?: boolean; + headerActions?: ReactNode; + maxHeight?: number; + hideColumns?: string[]; +} + +export function TransactionList({ + categories, + accounts, + banks, + accountId, + pageSize = 25, + hideAccountFilter = false, + showActionsMenu = true, + headerActions, + maxHeight, + hideColumns = [], +}: TransactionListProps) { + const { isKeySet } = useEncryptionKey(); + + const transactionIds = useLiveQuery( + async () => { + const txs = await db.transactions.toArray(); + return txs + .map((t) => t.id) + .sort() + .join(','); + }, + [], + '', + ); + + const [transactions, setTransactions] = useState( + [], + ); + const [isLoading, setIsLoading] = useState(true); + const [sorting, setSorting] = useState([ + { id: 'transaction_date', desc: true }, + ]); + const [columnFilters, setColumnFilters] = useState([]); + const [columnVisibility, setColumnVisibility] = useState( + getInitialColumnVisibility(), + ); + const [rowSelection, setRowSelection] = useState({}); + const [filters, setFilters] = useState({ + dateFrom: null, + dateTo: null, + amountMin: null, + amountMax: null, + categoryIds: [], + accountIds: accountId ? [accountId] : [], + searchText: '', + }); + const [editTransaction, setEditTransaction] = + useState(null); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [deleteTransaction, setDeleteTransaction] = + useState(null); + const [isBulkDeleteMode, setIsBulkDeleteMode] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [isBulkDeleting, setIsBulkDeleting] = useState(false); + const [isBulkUpdating, setIsBulkUpdating] = useState(false); + const [isReEvaluating, setIsReEvaluating] = useState(false); + const [displayedCount, setDisplayedCount] = useState(pageSize); + const [isLoadingMore, setIsLoadingMore] = useState(false); + + const updateTransaction = useCallback( + (updatedTransaction: DecryptedTransaction) => { + setTransactions((previous) => + previous.map((transaction) => { + if (transaction.id !== updatedTransaction.id) { + return transaction; + } + + return { + ...transaction, + ...updatedTransaction, + account: + updatedTransaction.account === undefined + ? transaction.account + : updatedTransaction.account, + bank: + updatedTransaction.bank === undefined + ? transaction.bank + : updatedTransaction.bank, + category: + updatedTransaction.category === undefined + ? (transaction.category ?? null) + : updatedTransaction.category, + }; + }), + ); + }, + [setTransactions], + ); + + useEffect(() => { + async function processTransactions() { + if (transactionIds === undefined) { + setIsLoading(true); + return; + } + + setIsLoading(true); + try { + let rawTransactions = await db.transactions.toArray(); + + if (accountId) { + rawTransactions = rawTransactions.filter( + (t) => t.account_id === accountId, + ); + } + + const accountsMap = new Map( + accounts.map((account) => [account.id, account]), + ); + const categoriesMap = new Map( + categories.map((category) => [category.id, category]), + ); + const banksMap = new Map(banks.map((bank) => [bank.id, bank])); + + const keyString = getStoredKey(); + let key: CryptoKey | null = null; + + if (keyString && isKeySet) { + try { + key = await importKey(keyString); + } catch (error) { + console.error( + 'Failed to import encryption key:', + error, + ); + } + } + + const decrypted = await Promise.all( + rawTransactions.map(async (transaction) => { + try { + let decryptedDescription = ''; + let decryptedNotes: string | null = null; + + if (key) { + try { + decryptedDescription = await decrypt( + transaction.description, + key, + transaction.description_iv, + ); + + if ( + transaction.notes && + transaction.notes_iv + ) { + decryptedNotes = await decrypt( + transaction.notes, + key, + transaction.notes_iv, + ); + } + } catch (error) { + console.error( + 'Failed to decrypt transaction:', + transaction.id, + error, + ); + } + } + + const account = accountsMap.get( + transaction.account_id, + ); + const category = transaction.category_id + ? categoriesMap.get(transaction.category_id) + : null; + const bank = account?.bank?.id + ? banksMap.get(account.bank.id) + : undefined; + + return { + ...transaction, + decryptedDescription, + decryptedNotes, + account, + category: category || null, + bank, + } as DecryptedTransaction; + } catch (error) { + console.error( + 'Failed to process transaction:', + transaction.id, + error, + ); + return null; + } + }), + ); + + const validTransactions = decrypted.filter( + (transaction): transaction is DecryptedTransaction => + transaction !== null, + ); + + validTransactions.sort((a, b) => { + const dateA = parseISO(a.transaction_date).getTime(); + const dateB = parseISO(b.transaction_date).getTime(); + return dateB - dateA; + }); + + setTransactions(validTransactions); + } catch (error) { + console.error('Failed to load transactions:', error); + } finally { + setIsLoading(false); + } + } + + processTransactions(); + }, [transactionIds, accounts, banks, categories, isKeySet, accountId]); + + useEffect(() => { + try { + localStorage.setItem( + COLUMN_VISIBILITY_KEY, + JSON.stringify(columnVisibility), + ); + } catch (error) { + console.error( + 'Failed to save column visibility to localStorage:', + error, + ); + } + }, [columnVisibility]); + + useEffect(() => { + async function reDecryptTransactions() { + if (transactions.length === 0) { + return; + } + + const keyString = getStoredKey(); + let key: CryptoKey | null = null; + + if (keyString && isKeySet) { + try { + key = await importKey(keyString); + } catch (error) { + console.error('Failed to import encryption key:', error); + } + } + + const reDecrypted = await Promise.all( + transactions.map(async (transaction) => { + try { + let decryptedDescription = ''; + let decryptedNotes: string | null = null; + + if (key) { + try { + decryptedDescription = await decrypt( + transaction.description, + key, + transaction.description_iv, + ); + + if (transaction.notes && transaction.notes_iv) { + decryptedNotes = await decrypt( + transaction.notes, + key, + transaction.notes_iv, + ); + } + } catch (error) { + console.error( + 'Failed to decrypt transaction:', + transaction.id, + error, + ); + } + } + + return { + ...transaction, + decryptedDescription, + decryptedNotes, + } as DecryptedTransaction; + } catch (error) { + console.error( + 'Failed to process transaction:', + transaction.id, + error, + ); + return transaction; + } + }), + ); + + setTransactions(reDecrypted); + } + + reDecryptTransactions(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isKeySet]); + + const filteredTransactions = useMemo(() => { + return transactions.filter((transaction) => { + if (filters.dateFrom || filters.dateTo) { + const transactionDate = parseISO(transaction.transaction_date); + if ( + filters.dateFrom && + filters.dateTo && + !isWithinInterval(transactionDate, { + start: filters.dateFrom, + end: filters.dateTo, + }) + ) { + return false; + } + if (filters.dateFrom && transactionDate < filters.dateFrom) { + return false; + } + if (filters.dateTo && transactionDate > filters.dateTo) { + return false; + } + } + + if ( + filters.amountMin !== null && + transaction.amount / 100 < filters.amountMin + ) { + return false; + } + if ( + filters.amountMax !== null && + transaction.amount / 100 > filters.amountMax + ) { + return false; + } + + if ( + filters.categoryIds.length > 0 && + !filters.categoryIds.includes(transaction.category_id || -1) + ) { + return false; + } + + if ( + !accountId && + filters.accountIds.length > 0 && + !filters.accountIds.includes(transaction.account_id) + ) { + return false; + } + + if (filters.searchText && isKeySet) { + const searchLower = filters.searchText.toLowerCase(); + const matchesDescription = transaction.decryptedDescription + .toLowerCase() + .includes(searchLower); + const matchesNotes = + transaction.decryptedNotes + ?.toLowerCase() + .includes(searchLower) || false; + + if (!matchesDescription && !matchesNotes) { + return false; + } + } + + return true; + }); + }, [transactions, filters, isKeySet, accountId]); + + const sortedTransactions = useMemo(() => { + if (sorting.length === 0) { + return filteredTransactions; + } + + const sorted = [...filteredTransactions]; + sorted.sort((a, b) => { + for (const sort of sorting) { + const { id, desc } = sort; + let comparison = 0; + + if (id === 'transaction_date') { + const dateA = parseISO(a.transaction_date).getTime(); + const dateB = parseISO(b.transaction_date).getTime(); + comparison = dateA - dateB; + } else if (id === 'amount') { + comparison = parseFloat(a.amount) - parseFloat(b.amount); + } else if (id === 'description') { + comparison = a.decryptedDescription.localeCompare( + b.decryptedDescription, + ); + } else if (id === 'account') { + const accountA = a.account?.name || ''; + const accountB = b.account?.name || ''; + comparison = accountA.localeCompare(accountB); + } else if (id === 'category') { + const categoryA = a.category?.name || ''; + const categoryB = b.category?.name || ''; + comparison = categoryA.localeCompare(categoryB); + } + + if (comparison !== 0) { + return desc ? -comparison : comparison; + } + } + return 0; + }); + + return sorted; + }, [filteredTransactions, sorting]); + + const displayedTransactions = useMemo(() => { + return sortedTransactions.slice(0, displayedCount); + }, [sortedTransactions, displayedCount]); + + const handleReEvaluateRules = useCallback( + async (transaction: DecryptedTransaction) => { + consoleDebug('=== Re-evaluating rules for single transaction ==='); + consoleDebug('Transaction:', { + id: transaction.id, + description: transaction.decryptedDescription, + amount: transaction.amount, + currentCategory: transaction.category?.name || 'None', + }); + + setIsReEvaluating(true); + try { + const keyString = getStoredKey(); + if (!keyString || !isKeySet) { + consoleDebug('❌ Encryption key not set'); + console.error('Encryption key not set'); + toast.error( + 'Please unlock your encryption key to re-evaluate rules', + ); + return; + } + consoleDebug('✓ Encryption key found'); + + const key = await importKey(keyString); + const rules = await automationRuleSyncService.getAll(); + consoleDebug(`Found ${rules.length} automation rules`); + + if (rules.length === 0) { + consoleDebug('❌ No rules to evaluate'); + return; + } + + consoleDebug('Evaluating rules against transaction...'); + const result = await evaluateRules( + transaction, + rules, + categories, + accounts, + banks, + key, + ); + + consoleDebug('Rule evaluation result:', result); + + if (result) { + consoleDebug('✓ Rule matched! Applying changes...'); + let finalNotes = transaction.notes; + let finalNotesIv = transaction.notes_iv; + + if (result.note && result.noteIv) { + consoleDebug('Adding note from rule'); + const decryptedRuleNote = await decrypt( + result.note, + key, + result.noteIv, + ); + const combinedNote = appendNoteIfNotPresent( + transaction.decryptedNotes, + decryptedRuleNote, + ); + + if (combinedNote !== transaction.decryptedNotes) { + const encrypted = await encrypt(combinedNote, key); + finalNotes = encrypted.encrypted; + finalNotesIv = encrypted.iv; + consoleDebug('Combined notes with rule note'); + } else { + consoleDebug('Rule note already present, skipping'); + } + } + + const updateData = { + category_id: result.categoryId, + notes: finalNotes, + notes_iv: finalNotesIv, + }; + consoleDebug('Updating transaction with:', updateData); + + await transactionSyncService.update( + transaction.id, + updateData, + ); + consoleDebug('✓ Transaction updated in IndexedDB'); + + const selectedCategory = result.categoryId + ? categories.find((c) => c.id === result.categoryId) || + null + : null; + + let decryptedNotes = transaction.decryptedNotes; + if (finalNotes && finalNotesIv) { + decryptedNotes = await decrypt( + finalNotes, + key, + finalNotesIv, + ); + } + + const updatedTransaction = { + ...transaction, + category_id: result.categoryId, + category: selectedCategory, + notes: finalNotes, + notes_iv: finalNotesIv, + decryptedNotes, + }; + consoleDebug('Updating UI state with:', { + id: updatedTransaction.id, + newCategory: selectedCategory?.name || 'None', + hasNotes: !!decryptedNotes, + }); + + updateTransaction(updatedTransaction); + consoleDebug('✓ UI state updated successfully'); + } else { + consoleDebug('❌ No rules matched this transaction'); + } + } catch (error) { + consoleDebug('❌ Error during re-evaluation:', error); + console.error('Failed to re-evaluate rules:', error); + } finally { + setIsReEvaluating(false); + consoleDebug('=== Re-evaluation complete ==='); + } + }, + [isKeySet, categories, accounts, banks, updateTransaction], + ); + + async function handleBulkReEvaluateRules() { + const selectedIds = Object.keys(rowSelection); + consoleDebug('=== Re-evaluating rules for bulk transactions ==='); + consoleDebug(`Selected ${selectedIds.length} transactions`); + + if (selectedIds.length === 0) { + consoleDebug('❌ No transactions selected'); + return; + } + + setIsReEvaluating(true); + try { + const keyString = getStoredKey(); + if (!keyString || !isKeySet) { + consoleDebug('❌ Encryption key not set'); + console.error('Encryption key not set'); + toast.error( + 'Please unlock your encryption key to re-evaluate rules', + ); + return; + } + consoleDebug('✓ Encryption key found'); + + const key = await importKey(keyString); + const rules = await automationRuleSyncService.getAll(); + consoleDebug(`Found ${rules.length} automation rules`); + + if (rules.length === 0) { + consoleDebug('❌ No rules to evaluate'); + return; + } + + const selectedTransactions = transactions.filter((t) => + selectedIds.includes(t.id.toString()), + ); + consoleDebug( + 'Processing transactions:', + selectedTransactions.map((t) => ({ + id: t.id, + description: t.decryptedDescription, + currentCategory: t.category?.name || 'None', + })), + ); + + const updates: Array<{ + transaction: DecryptedTransaction; + categoryId: number | null; + category: Category | null; + notes: string | null; + notesIv: string | null; + decryptedNotes: string | null; + }> = []; + + for (const transaction of selectedTransactions) { + consoleDebug(`\nEvaluating transaction ${transaction.id}...`); + const result = await evaluateRules( + transaction, + rules, + categories, + accounts, + banks, + key, + ); + + consoleDebug('Rule evaluation result:', result); + + if (result) { + consoleDebug('✓ Rule matched! Applying changes...'); + let finalNotes = transaction.notes; + let finalNotesIv = transaction.notes_iv; + + if (result.note && result.noteIv) { + consoleDebug('Adding note from rule'); + const decryptedRuleNote = await decrypt( + result.note, + key, + result.noteIv, + ); + const combinedNote = appendNoteIfNotPresent( + transaction.decryptedNotes, + decryptedRuleNote, + ); + + if (combinedNote !== transaction.decryptedNotes) { + const encrypted = await encrypt(combinedNote, key); + finalNotes = encrypted.encrypted; + finalNotesIv = encrypted.iv; + consoleDebug('Combined notes with rule note'); + } else { + consoleDebug('Rule note already present, skipping'); + } + } + + const updateData = { + category_id: result.categoryId, + notes: finalNotes, + notes_iv: finalNotesIv, + }; + consoleDebug('Updating transaction with:', updateData); + + await transactionSyncService.update( + transaction.id, + updateData, + ); + consoleDebug('✓ Transaction updated in IndexedDB'); + + const selectedCategory = result.categoryId + ? categories.find((c) => c.id === result.categoryId) || + null + : null; + + let decryptedNotes = transaction.decryptedNotes; + if (finalNotes && finalNotesIv) { + decryptedNotes = await decrypt( + finalNotes, + key, + finalNotesIv, + ); + } + + updates.push({ + transaction, + categoryId: result.categoryId, + category: selectedCategory, + notes: finalNotes, + notesIv: finalNotesIv, + decryptedNotes, + }); + consoleDebug( + `✓ Queued update for transaction ${transaction.id}`, + ); + } else { + consoleDebug( + `❌ No rules matched transaction ${transaction.id}`, + ); + } + } + + consoleDebug(`\nApplying ${updates.length} updates to UI state...`); + if (updates.length > 0) { + setTransactions((previous) => + previous.map((transaction) => { + const update = updates.find( + (u) => u.transaction.id === transaction.id, + ); + if (update) { + consoleDebug( + `Updating UI for transaction ${transaction.id}:`, + { + newCategory: + update.category?.name || 'None', + hasNotes: !!update.decryptedNotes, + }, + ); + return { + ...transaction, + category_id: update.categoryId, + category: update.category, + notes: update.notes, + notes_iv: update.notesIv, + decryptedNotes: update.decryptedNotes, + }; + } + return transaction; + }), + ); + consoleDebug('✓ UI state updated successfully'); + } else { + consoleDebug('❌ No updates to apply'); + } + + consoleDebug('Clearing selection...'); + setRowSelection({}); + } catch (error) { + consoleDebug('❌ Error during bulk re-evaluation:', error); + console.error('Failed to re-evaluate rules:', error); + } finally { + setIsReEvaluating(false); + consoleDebug('=== Bulk re-evaluation complete ==='); + } + } + + const columns = useMemo(() => { + const allColumns = createTransactionColumns({ + categories, + accounts, + banks, + onEdit: setEditTransaction, + onDelete: setDeleteTransaction, + onUpdate: updateTransaction, + onReEvaluateRules: handleReEvaluateRules, + }); + + if (hideColumns.length === 0) { + return allColumns; + } + + return allColumns.filter((column) => { + const columnId = + 'accessorKey' in column ? column.accessorKey : column.id; + return !hideColumns.includes(columnId as string); + }); + }, [ + accounts, + banks, + categories, + updateTransaction, + handleReEvaluateRules, + hideColumns, + ]); + + const table = useReactTable({ + data: displayedTransactions, + columns, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + getRowId: (row) => row.id.toString(), + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + onColumnVisibilityChange: setColumnVisibility, + onRowSelectionChange: setRowSelection, + enableRowSelection: true, + state: { + sorting, + columnFilters, + columnVisibility, + rowSelection, + }, + }); + + const loadMore = useCallback(() => { + if (displayedCount < sortedTransactions.length && !isLoadingMore) { + setIsLoadingMore(true); + requestAnimationFrame(() => { + setDisplayedCount((prev) => + Math.min(prev + pageSize, sortedTransactions.length), + ); + requestAnimationFrame(() => { + setIsLoadingMore(false); + }); + }); + } + }, [displayedCount, sortedTransactions.length, isLoadingMore, pageSize]); + + useEffect(() => { + setDisplayedCount(pageSize); + }, [filters, sorting, pageSize]); + + async function handleDelete() { + if (!deleteTransaction) { + return; + } + + setIsDeleting(true); + try { + await transactionSyncService.delete(deleteTransaction.id); + setTransactions((previous) => + previous.filter( + (transaction) => transaction.id !== deleteTransaction.id, + ), + ); + setDeleteTransaction(null); + setIsBulkDeleteMode(false); + setRowSelection({}); + } catch (error) { + console.error('Failed to delete transaction:', error); + } finally { + setIsDeleting(false); + } + } + + async function handleBulkCategoryChange(categoryId: number | null) { + if (!isKeySet) { + toast.error( + 'Please unlock your encryption key to update transactions', + ); + return; + } + + const selectedIds = Object.keys(rowSelection); + if (selectedIds.length === 0) { + return; + } + + setIsBulkUpdating(true); + try { + await transactionSyncService.updateMany(selectedIds, { + category_id: categoryId, + }); + + const categoriesMap = new Map( + categories.map((category) => [category.id, category]), + ); + const selectedCategory = categoryId + ? categoriesMap.get(categoryId) || null + : null; + + setTransactions((previous) => + previous.map((transaction) => { + if (selectedIds.includes(transaction.id.toString())) { + return { + ...transaction, + category_id: categoryId, + category: selectedCategory, + }; + } + return transaction; + }), + ); + + setRowSelection({}); + } catch (error) { + console.error('Failed to update transactions:', error); + } finally { + setIsBulkUpdating(false); + } + } + + function handleBulkDeleteClick() { + const selectedIds = Object.keys(rowSelection); + + if (selectedIds.length === 0) { + return; + } + + const firstSelectedTransaction = filteredTransactions.find( + (t) => t.id.toString() === selectedIds[0], + ); + + if (firstSelectedTransaction) { + setIsBulkDeleteMode(true); + setDeleteTransaction(firstSelectedTransaction); + } + } + + async function handleBulkDelete() { + const selectedIds = Object.keys(rowSelection); + if (selectedIds.length === 0) { + return; + } + + setIsBulkDeleting(true); + try { + await transactionSyncService.deleteMany(selectedIds); + setTransactions((previous) => + previous.filter( + (transaction) => !selectedIds.includes(transaction.id), + ), + ); + setDeleteTransaction(null); + setIsBulkDeleteMode(false); + setRowSelection({}); + } catch (error) { + console.error('Failed to delete transactions:', error); + } finally { + setIsBulkDeleting(false); + } + } + + function handleClearSelection() { + setRowSelection({}); + } + + const renderTransactionRow = useCallback( + ( + row: Row, + virtualRow: VirtualItem, + rowVirtualizer: Virtualizer, + ) => { + return ( + + ); + }, + [handleReEvaluateRules], + ); + + return ( + <> +
+ + {showActionsMenu && ( + + setCreateDialogOpen(true) + } + transactions={transactions} + onReEvaluateComplete={() => { + setRowSelection({}); + setTimeout(() => { + window.location.reload(); + }, 500); + }} + /> + )} + {headerActions} + + + } + /> + + {isLoading ? ( +
+
+
+ + + + + + +
+
+ {Array.from({ length: 6 }).map((_, index) => ( +
+ + + + + + +
+ ))} +
+
+
+ + +
+
+ ) : ( + <> + + + + {displayedCount < sortedTransactions.length && ( + + )} + + + )} +
+ + !open && setEditTransaction(null)} + onSuccess={updateTransaction} + mode="edit" + /> + + {}} + mode="create" + /> + + { + if (!open) { + setDeleteTransaction(null); + setIsBulkDeleteMode(false); + } + }} + > + + + + Delete Transaction + {isBulkDeleteMode ? 's' : ''} + + + {isBulkDeleteMode + ? `Are you sure you want to delete ${Object.keys(rowSelection).length} transactions? This action cannot be undone.` + : 'Are you sure you want to delete this transaction? This action cannot be undone.'} + + + + + Cancel + + + {isDeleting || isBulkDeleting + ? 'Deleting...' + : 'Delete'} + + + + + + + + ); +} diff --git a/resources/js/components/ui/alert-dialog.tsx b/resources/js/components/ui/alert-dialog.tsx index a703076f..d9896ea6 100644 --- a/resources/js/components/ui/alert-dialog.tsx +++ b/resources/js/components/ui/alert-dialog.tsx @@ -5,150 +5,152 @@ import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import { cn } from "@/lib/utils" import { buttonVariants } from "@/components/ui/button" +import { VariantProps } from "class-variance-authority" function AlertDialog({ - ...props + ...props }: React.ComponentProps) { - return + return } function AlertDialogTrigger({ - ...props + ...props }: React.ComponentProps) { - return + return } function AlertDialogPortal({ - ...props + ...props }: React.ComponentProps) { - return + return } function AlertDialogOverlay({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ) } function AlertDialogContent({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - - - - ) + return ( + + + + + ) } function AlertDialogHeader({ - className, - ...props + className, + ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return ( +
+ ) } function AlertDialogFooter({ - className, - ...props + className, + ...props }: React.ComponentProps<"div">) { - return ( -
- ) + return ( +
+ ) } function AlertDialogTitle({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ) } function AlertDialogDescription({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ) } function AlertDialogAction({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) + className, + variant, + ...props +}: React.ComponentProps & { variant?: VariantProps['variant'] }) { + return ( + + ) } function AlertDialogCancel({ - className, - ...props + className, + ...props }: React.ComponentProps) { - return ( - - ) + return ( + + ) } export { - AlertDialog, - AlertDialogPortal, - AlertDialogOverlay, - AlertDialogTrigger, - AlertDialogContent, - AlertDialogHeader, - AlertDialogFooter, - AlertDialogTitle, - AlertDialogDescription, - AlertDialogAction, - AlertDialogCancel, + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, } diff --git a/resources/js/components/ui/amount-input.tsx b/resources/js/components/ui/amount-input.tsx index aff78046..264ea9be 100644 --- a/resources/js/components/ui/amount-input.tsx +++ b/resources/js/components/ui/amount-input.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Input } from '@/components/ui/input'; +import { cn } from '@/lib/utils'; interface AmountInputProps { value: number; @@ -10,6 +11,7 @@ interface AmountInputProps { required?: boolean; placeholder?: string; id?: string; + className?: string; } const getCurrencySymbol = (currencyCode: string): string => { @@ -32,13 +34,30 @@ const formatCurrency = (value: number): string => { const parseInputValue = (input: string): number => { const cleaned = input.replace(/[^\d.,]/g, ''); - const normalized = cleaned.replace(',', '.'); + + if (!cleaned) { + return 0; + } + + const lastComma = cleaned.lastIndexOf(','); + const lastDot = cleaned.lastIndexOf('.'); + + let normalized: string; + + if (lastComma > lastDot) { + normalized = cleaned.replace(/\./g, '').replace(',', '.'); + } else if (lastDot > lastComma) { + normalized = cleaned.replace(/,/g, ''); + } else { + normalized = cleaned.replace(',', '.'); + } + const parsed = parseFloat(normalized); - + if (isNaN(parsed)) { return 0; } - + return Math.round(parsed * 100); }; @@ -52,6 +71,7 @@ export const AmountInput = React.forwardRef( required = false, placeholder = '0.00', id, + className = '', }, ref, ) => { @@ -107,7 +127,7 @@ export const AmountInput = React.forwardRef( placeholder={placeholder} disabled={disabled} required={required} - className="bg-background pl-9" + className={cn(["bg-background pl-9", className])} />
); diff --git a/resources/js/components/ui/data-table.tsx b/resources/js/components/ui/data-table.tsx index 6796a3fa..c61904d6 100644 --- a/resources/js/components/ui/data-table.tsx +++ b/resources/js/components/ui/data-table.tsx @@ -21,6 +21,7 @@ interface DataTableProps { columns: ColumnDef[]; emptyMessage?: string; renderRow?: (row: Row, virtualRow: VirtualItem, rowVirtualizer: Virtualizer) => React.ReactNode; + maxHeight?: number; } export function DataTable({ @@ -28,6 +29,7 @@ export function DataTable({ columns, emptyMessage = 'No results found.', renderRow, + maxHeight, }: DataTableProps) { const tableContainerRef = useRef(null); const rows = table.getRowModel().rows; @@ -54,9 +56,10 @@ export function DataTable({
- + {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { diff --git a/resources/js/lib/rule-engine.ts b/resources/js/lib/rule-engine.ts index 7b4e0b9d..dc77c0c6 100644 --- a/resources/js/lib/rule-engine.ts +++ b/resources/js/lib/rule-engine.ts @@ -62,7 +62,11 @@ async function decryptAccountName( key: CryptoKey, ): Promise { try { - const decryptedAccountName = await decrypt(account.name, key, account.name_iv); + const decryptedAccountName = await decrypt( + account.name, + key, + account.name_iv, + ); return decryptedAccountName.trim(); } catch (error) { console.error('Failed to decrypt account name:', account.id, error); diff --git a/resources/js/pages/Accounts/Index.tsx b/resources/js/pages/Accounts/Index.tsx new file mode 100644 index 00000000..53b9ced5 --- /dev/null +++ b/resources/js/pages/Accounts/Index.tsx @@ -0,0 +1,115 @@ +import { index } from '@/actions/App/Http/Controllers/AccountController'; +import { AccountListCard } from '@/components/accounts/account-list-card'; +import HeadingSmall from '@/components/heading-small'; +import { useDashboardData } from '@/hooks/use-dashboard-data'; +import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'; +import { BreadcrumbItem } from '@/types'; +import { Account, AccountType } from '@/types/account'; +import { Head } from '@inertiajs/react'; +import { useMemo } from 'react'; + +const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Accounts', + href: index().url, + }, +]; + +const ACCOUNT_TYPE_ORDER: AccountType[] = [ + 'checking', + 'savings', + 'investment', + 'retirement', + 'loan', + 'credit_card', + 'others', +]; + +interface Props { + accounts: Account[]; +} + +export default function AccountsIndex({ accounts }: Props) { + const { accounts: accountMetrics, isLoading } = useDashboardData(); + + const accountsWithMetrics = useMemo(() => { + return accounts.map((account) => { + const metrics = accountMetrics.find((m) => m.id === account.id); + return { + ...account, + currentBalance: metrics?.currentBalance ?? 0, + previousBalance: metrics?.previousBalance ?? 0, + diff: metrics?.diff ?? 0, + history: metrics?.history ?? [], + }; + }); + }, [accounts, accountMetrics]); + + const groupedAccounts = useMemo(() => { + const groups: Record = { + checking: [], + savings: [], + investment: [], + retirement: [], + loan: [], + credit_card: [], + others: [], + }; + + accountsWithMetrics.forEach((account) => { + const type = account.type as AccountType; + if (groups[type]) { + groups[type].push(account); + } else { + groups.others.push(account); + } + }); + + return groups; + }, [accountsWithMetrics]); + + return ( + + + +
+ + +
+ {ACCOUNT_TYPE_ORDER.map((type) => { + const accountsInGroup = groupedAccounts[type]; + if (accountsInGroup.length === 0) return null; + + return ( + <> + {isLoading + ? accountsInGroup.map((account) => ( + + )) + : accountsInGroup.map((account) => ( + + ))} + + ); + })} +
+ + {accounts.length === 0 && !isLoading && ( +
+ No accounts found. Add your first account in Settings. +
+ )} +
+
+ ); +} diff --git a/resources/js/pages/Accounts/Show.tsx b/resources/js/pages/Accounts/Show.tsx new file mode 100644 index 00000000..15dcb04b --- /dev/null +++ b/resources/js/pages/Accounts/Show.tsx @@ -0,0 +1,196 @@ +import { index, show } from '@/actions/App/Http/Controllers/AccountController'; +import { AccountBalanceChart } from '@/components/accounts/account-balance-chart'; +import { BalancesModal } from '@/components/accounts/balances-modal'; +import { DeleteAccountDialog } from '@/components/accounts/delete-account-dialog'; +import { EditAccountDialog } from '@/components/accounts/edit-account-dialog'; +import { UpdateBalanceDialog } from '@/components/accounts/update-balance-dialog'; +import { EncryptedText } from '@/components/encrypted-text'; +import HeadingSmall from '@/components/heading-small'; +import { TransactionList } from '@/components/transactions/transaction-list'; +import { Button } from '@/components/ui/button'; +import { ButtonGroup } from '@/components/ui/button-group'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'; +import { BreadcrumbItem } from '@/types'; +import { Account, Bank, formatAccountType, isTransactionalAccount } from '@/types/account'; +import { Category } from '@/types/category'; +import { Head } from '@inertiajs/react'; +import { ChevronDown } from 'lucide-react'; +import { useState } from 'react'; + +interface Props { + account: Account; + categories: Category[]; + accounts: Account[]; + banks: Bank[]; +} + +export default function AccountShow({ + account, + categories, + accounts, + banks, +}: Props) { + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [updateBalanceOpen, setUpdateBalanceOpen] = useState(false); + const [balancesOpen, setBalancesOpen] = useState(false); + const [chartRefreshKey, setChartRefreshKey] = useState(0); + + function handleBalanceUpdated() { + setChartRefreshKey((prev) => prev + 1); + } + + const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Accounts', + href: index().url, + }, + { + title: ( + + ), + href: show.url(account.id), + }, + ]; + + return ( + + + +
+
+
+ {account.bank?.logo ? ( + {account.bank.name} + ) : ( +
+ + {account.bank?.name?.charAt(0) || '?'} + +
+ )} + + } + description={`${account.bank?.name || 'Unknown Bank'} · ${formatAccountType(account.type)}`} + /> +
+ + + + + + + + + + + + + setBalancesOpen(true)} + > + See balances + + setEditOpen(true)} + > + Edit account + + + setDeleteOpen(true)} + variant="destructive" + > + Delete + + + + + +
+ + + + {isTransactionalAccount(account) && } +
+ + + + + + + + +
+ ); +} diff --git a/routes/web.php b/routes/web.php index 2c3eece8..1d2294a1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ group(function () { Route::post('api/sync/account-balances', [AccountBalanceSyncController::class, 'store']); Route::patch('api/sync/account-balances/{accountBalance}', [AccountBalanceSyncController::class, 'update']); Route::put('api/accounts/{account}/balance/current', [AccountBalanceController::class, 'updateCurrent'])->name('accounts.balance.update-current'); + Route::get('api/accounts/{account}/balances', [AccountBalanceController::class, 'index'])->name('accounts.balances.index'); + Route::delete('api/accounts/{account}/balances/{accountBalance}', [AccountBalanceController::class, 'destroy'])->name('accounts.balances.destroy'); // Dashboard Analytics Route::prefix('api/dashboard')->group(function () { @@ -73,12 +76,16 @@ Route::middleware(['auth'])->group(function () { Route::get('cash-flow', [\App\Http\Controllers\Api\DashboardAnalyticsController::class, 'cashFlow']); Route::get('net-worth-evolution', [\App\Http\Controllers\Api\DashboardAnalyticsController::class, 'netWorthEvolution']); Route::get('top-categories', [\App\Http\Controllers\Api\DashboardAnalyticsController::class, 'topCategories']); + Route::get('account/{account}/balance-evolution', [\App\Http\Controllers\Api\DashboardAnalyticsController::class, 'accountBalanceEvolution']); }); }); Route::middleware(['auth', 'verified', 'redirect.encryption'])->group(function () { Route::get('dashboard', DashboardController::class)->name('dashboard'); + Route::get('accounts', [AccountController::class, 'index'])->name('accounts.list'); + Route::get('accounts/{account}', [AccountController::class, 'show'])->name('accounts.show'); + Route::get('transactions', [TransactionController::class, 'index'])->name('transactions.index'); Route::post('transactions', [TransactionController::class, 'store'])->name('transactions.store'); Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('transactions.bulk-update'); diff --git a/tests/Feature/AccountBalanceControllerTest.php b/tests/Feature/AccountBalanceControllerTest.php index f9d80e70..ecd76e3d 100644 --- a/tests/Feature/AccountBalanceControllerTest.php +++ b/tests/Feature/AccountBalanceControllerTest.php @@ -103,3 +103,88 @@ it('validates balance is an integer', function () { $response->assertUnprocessable() ->assertJsonValidationErrors(['balance']); }); + +it('can list balances for an account', function () { + $user = User::factory()->create(); + $account = Account::factory()->for($user)->create(); + + AccountBalance::factory()->for($account)->count(3)->create(); + + $response = $this->actingAs($user)->getJson("/api/accounts/{$account->id}/balances"); + + $response->assertSuccessful() + ->assertJsonStructure([ + 'data' => [ + '*' => [ + 'id', + 'account_id', + 'balance_date', + 'balance', + 'created_at', + 'updated_at', + ], + ], + 'current_page', + 'last_page', + 'per_page', + 'total', + ]) + ->assertJsonPath('total', 3) + ->assertJsonPath('per_page', 50); +}); + +it('cannot list balances for another user account', function () { + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + $account = Account::factory()->for($otherUser)->create(); + + AccountBalance::factory()->for($account)->count(3)->create(); + + $response = $this->actingAs($user)->getJson("/api/accounts/{$account->id}/balances"); + + $response->assertForbidden(); +}); + +it('can delete a balance record', function () { + $user = User::factory()->create(); + $account = Account::factory()->for($user)->create(); + $balance = AccountBalance::factory()->for($account)->create(); + + $response = $this->actingAs($user)->deleteJson("/api/accounts/{$account->id}/balances/{$balance->id}"); + + $response->assertNoContent(); + + $this->assertDatabaseMissing('account_balances', [ + 'id' => $balance->id, + ]); +}); + +it('cannot delete a balance for another user account', function () { + $user = User::factory()->create(); + $otherUser = User::factory()->create(); + $account = Account::factory()->for($otherUser)->create(); + $balance = AccountBalance::factory()->for($account)->create(); + + $response = $this->actingAs($user)->deleteJson("/api/accounts/{$account->id}/balances/{$balance->id}"); + + $response->assertForbidden(); + + $this->assertDatabaseHas('account_balances', [ + 'id' => $balance->id, + ]); +}); + +it('returns 404 when deleting balance from wrong account', function () { + $user = User::factory()->create(); + $account1 = Account::factory()->for($user)->create(); + $account2 = Account::factory()->for($user)->create(); + $balance = AccountBalance::factory()->for($account1)->create(); + + $response = $this->actingAs($user)->deleteJson("/api/accounts/{$account2->id}/balances/{$balance->id}"); + + $response->assertNotFound(); + + $this->assertDatabaseHas('account_balances', [ + 'id' => $balance->id, + ]); +}); diff --git a/tests/Feature/AccountControllerTest.php b/tests/Feature/AccountControllerTest.php new file mode 100644 index 00000000..de95f537 --- /dev/null +++ b/tests/Feature/AccountControllerTest.php @@ -0,0 +1,221 @@ +user = User::factory()->create([ + 'encryption_salt' => str_repeat('a', 24), + ]); + $this->actingAs($this->user); +}); + +test('guests are redirected to the login page for accounts index', function () { + auth()->logout(); + + $this->get(route('accounts.list'))->assertRedirect(route('login')); +}); + +test('guests are redirected to the login page for account show', function () { + auth()->logout(); + + $account = Account::factory()->create(['user_id' => $this->user->id]); + + $this->get(route('accounts.show', $account))->assertRedirect(route('login')); +}); + +test('authenticated users can visit the accounts index', function () { + $this->get(route('accounts.list'))->assertOk(); +}); + +test('accounts index returns accounts grouped by type', function () { + $checking = Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Checking, + ]); + $savings = Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Savings, + ]); + $investment = Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Investment, + ]); + + $response = $this->get(route('accounts.list')); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Accounts/Index') + ->has('accounts', 3) + ); +}); + +test('accounts are ordered by type then name', function () { + Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Savings, + 'name' => 'A Savings', + ]); + Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Checking, + 'name' => 'B Checking', + ]); + Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Checking, + 'name' => 'A Checking', + ]); + + $response = $this->get(route('accounts.list')); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Accounts/Index') + ->has('accounts', 3) + ->where('accounts.0.type', 'checking') + ->where('accounts.0.name', 'A Checking') + ->where('accounts.1.type', 'checking') + ->where('accounts.1.name', 'B Checking') + ->where('accounts.2.type', 'savings') + ); +}); + +test('accounts index only shows user accounts', function () { + $myAccount = Account::factory()->create([ + 'user_id' => $this->user->id, + ]); + $otherAccount = Account::factory()->create([ + 'user_id' => User::factory()->create()->id, + ]); + + $response = $this->get(route('accounts.list')); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Accounts/Index') + ->has('accounts', 1) + ->where('accounts.0.id', $myAccount->id) + ); +}); + +test('authenticated users can view their own account', function () { + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + ]); + + $response = $this->get(route('accounts.show', $account)); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Accounts/Show') + ->has('account') + ->where('account.id', $account->id) + ); +}); + +test('users cannot view other users accounts', function () { + $otherUser = User::factory()->create(); + $account = Account::factory()->create([ + 'user_id' => $otherUser->id, + ]); + + $response = $this->get(route('accounts.show', $account)); + + $response->assertForbidden(); +}); + +test('account show includes categories, accounts, and banks', function () { + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + ]); + + Category::factory()->count(3)->create([ + 'user_id' => $this->user->id, + ]); + + $response = $this->get(route('accounts.show', $account)); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Accounts/Show') + ->has('account') + ->has('categories', 3) + ->has('accounts', 1) + ->has('banks') + ); +}); + +test('account balance evolution returns data for single account', function () { + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'type' => AccountType::Checking, + ]); + + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now()->subMonth()->endOfMonth(), + 'balance' => 100000, + ]); + AccountBalance::factory()->create([ + 'account_id' => $account->id, + 'balance_date' => now()->endOfMonth(), + 'balance' => 150000, + ]); + + $response = $this->getJson('/api/dashboard/account/'.$account->id.'/balance-evolution?'.http_build_query([ + 'from' => now()->subMonths(2)->startOfMonth()->toDateString(), + 'to' => now()->endOfMonth()->toDateString(), + ])); + + $response->assertOk(); + $data = $response->json(); + + expect($data)->toHaveKeys(['data', 'account']); + expect($data['data'])->toHaveCount(3); + expect($data['data'][0])->toHaveKeys(['month', 'timestamp', 'value']); + expect($data['account']['id'])->toBe($account->id); +}); + +test('account balance evolution denies access to other users accounts', function () { + $otherUser = User::factory()->create(); + $account = Account::factory()->create([ + 'user_id' => $otherUser->id, + ]); + + $response = $this->getJson('/api/dashboard/account/'.$account->id.'/balance-evolution?'.http_build_query([ + 'from' => now()->subMonth()->toDateString(), + 'to' => now()->toDateString(), + ])); + + $response->assertForbidden(); +}); + +test('account show includes bank information', function () { + $bank = Bank::factory()->create([ + 'name' => 'Test Bank', + 'logo' => 'https://example.com/logo.png', + ]); + + $account = Account::factory()->create([ + 'user_id' => $this->user->id, + 'bank_id' => $bank->id, + ]); + + $response = $this->get(route('accounts.show', $account)); + + $response->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Accounts/Show') + ->has('account.bank') + ->where('account.bank.name', 'Test Bank') + ); +});