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?.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 (
+ <>
+
+
+
+
+ !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({
) : (
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 (
+
+ );
+}
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?.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')
+ );
+});