Accounts: new pages to list, and see account details (#13)

## 🆕  Accounts page
<img width="1222" height="1118" alt="image"
src="https://github.com/user-attachments/assets/2ab659b3-c368-4824-9f00-cdeb6ae8e713"
/>

## 🆕  Accounts details page
<img width="1220" height="1171" alt="image"
src="https://github.com/user-attachments/assets/7c64b1cf-c1fd-4ada-8ab0-17ee4f6991de"
/>

## 🆕 Update current account balance
<img width="1069" height="778" alt="image"
src="https://github.com/user-attachments/assets/76586b8a-7088-4ef8-a890-22910644a2d3"
/>

## 🆕 Edit/update account balance history
<img width="1200" height="681" alt="image"
src="https://github.com/user-attachments/assets/fa589553-7be8-4049-bdf4-89996b81a863"
/>
This commit is contained in:
Víctor Falcón 2025-12-05 14:33:18 +01:00 committed by GitHub
parent 2d92afa66d
commit 8be7ea98e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 3254 additions and 144 deletions

View File

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

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Controllers;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Category;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class AccountController extends Controller
{
use AuthorizesRequests;
public function index(Request $request): Response
{
$user = $request->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,
]);
}
}

View File

@ -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([

View File

@ -20,7 +20,7 @@ class AccountPolicy
*/
public function view(User $user, Account $account): bool
{
return false;
return $user->id === $account->user_id;
}
/**

View File

@ -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 (
<div className="flex items-center gap-1">
<span
className={
isPositive ? 'bg-green-100/25 dark:bg-green-900/25' : ''
}
>
{isPositive ? '+' : ''}
{trend.toFixed(1)}%
</span>
<span className="text-muted-foreground">{label}</span>
<Icon className={`h-4 w-4 ${iconColorClass}`} />
</div>
);
}
export function AccountBalanceChart({
account,
loading: initialLoading,
refreshKey,
}: AccountBalanceChartProps) {
const [balanceData, setBalanceData] = useState<AccountBalanceData | null>(
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: (
<EncryptedText
encryptedText={account.name}
iv={account.name_iv}
length={{ min: 5, max: 20 }}
/>
),
color: 'var(--color-chart-2)',
},
};
const valueFormatter = (value: number): string => {
return formatCurrency(value, account.currency_code);
};
if (initialLoading || isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>Balance evolution</CardTitle>
<CardDescription>
<div className="h-4 w-48 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px] w-full animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</CardContent>
</Card>
);
}
if (chartData.length === 0) {
return (
<Card>
<CardHeader>
<CardTitle>Balance evolution</CardTitle>
</CardHeader>
<CardContent>
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
No balance data available
</div>
</CardContent>
</Card>
);
}
return (
<Card>
<CardHeader>
<div className="flex items-start justify-between">
<div className="flex flex-col gap-2">
<CardTitle>Balance evolution</CardTitle>
<CardDescription className="flex flex-col gap-1 text-sm">
<TrendIndicator
trend={monthlyTrend}
label="this month"
/>
<TrendIndicator
trend={yearlyTrend}
label="for the last 12 months"
/>
</CardDescription>
</div>
<div>
<span className="text-4xl font-semibold tabular-nums">
{formatCurrency(
currentBalance,
account.currency_code,
)}
</span>
</div>
</div>
</CardHeader>
<CardContent>
<ChartContainer
config={chartConfig}
className="h-[300px] w-full"
>
<BarChart accessibilityLayer data={chartData}>
<XAxis
dataKey="month"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={formatXAxisLabel}
/>
<ChartTooltip
content={
<ChartTooltipContent
hideLabel
valueFormatter={valueFormatter}
/>
}
/>
<Bar
dataKey="value"
fill="var(--color-chart-2)"
radius={[4, 4, 0, 0]}
/>
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@ -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 (
<Card className="w-full">
<CardContent className="p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
<div className="flex flex-col gap-2">
<div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-6 w-40 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</div>
</div>
<div className="flex flex-col items-end gap-2">
<div className="h-8 w-32 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</div>
</div>
<div className="h-[100px] w-full animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</div>
</CardContent>
</Card>
);
}
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 (
<Card className="w-full py-0">
<CardContent className="p-4">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-start gap-3">
<div className="flex flex-col gap-1">
<h3 className="flex items-center gap-2 font-semibold">
{account.bank?.logo ? (
<img
src={account.bank.logo}
alt={account.bank.name}
className="size-4 rounded-full object-contain"
/>
) : (
<div className="flex size-4 items-center justify-center rounded-full bg-muted">
<span className="text-sm font-medium text-muted-foreground">
{account.bank?.name?.charAt(
0,
) || '?'}
</span>
</div>
)}
<EncryptedText
encryptedText={account.name}
iv={account.name_iv}
length={{ min: 8, max: 25 }}
/>
</h3>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
{account.bank?.name || 'Unknown Bank'}
</span>
</div>
</div>
</div>
<div className="flex flex-col items-end">
<span className="text-2xl font-bold tabular-nums">
{formatter.format(account.currentBalance / 100)}
</span>
<div
className={`flex items-center gap-1 text-sm ${trendColorClass}`}
>
<TrendIcon className="h-4 w-4" />
<span className="tabular-nums">
{formatter.format(
Math.abs(account.diff) / 100,
)}
</span>
<span className="text-muted-foreground">
vs last month
</span>
</div>
</div>
</div>
<div className="h-[100px] w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={account.history}>
<Tooltip
content={({ active, payload }) => {
if (!active || !payload?.length)
return null;
const data = payload[0].payload as {
date: string;
value: number;
};
return (
<div className="rounded-lg border border-border/50 bg-background px-3 py-2 text-sm shadow-xl">
<p className="mb-0.5 text-muted-foreground">
{data.date}
</p>
<p className="font-mono font-medium text-foreground tabular-nums">
{formatter.format(
data.value / 100,
)}
</p>
</div>
);
}}
/>
<Line
type="monotone"
dataKey="value"
stroke="var(--color-chart-2)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
<div className="flex justify-end">
<Link href={show.url(account.id)}>
<Button className="cursor-pointer" variant="ghost">
Go to details
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -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<AccountBalance[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [lastPage, setLastPage] = useState(1);
const [total, setTotal] = useState(0);
const [editingBalance, setEditingBalance] = useState<AccountBalance | null>(
null,
);
const [editDate, setEditDate] = useState('');
const [editAmount, setEditAmount] = useState(0);
const [isEditSubmitting, setIsEditSubmitting] = useState(false);
const [deletingBalance, setDeletingBalance] =
useState<AccountBalance | null>(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 (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<DialogTitle>Balance History</DialogTitle>
<DialogDescription>
View and manage balance records for this account.
</DialogDescription>
</DialogHeader>
<div
className="overflow-hidden rounded-md border"
style={{ maxHeight: 400 }}
>
<div
className="overflow-y-auto"
style={{ maxHeight: 400 }}
>
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
<TableHead>Date</TableHead>
<TableHead className="text-right">
Balance
</TableHead>
<TableHead className="w-[100px] text-right">
Actions
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell
colSpan={3}
className="h-24 text-center"
>
Loading...
</TableCell>
</TableRow>
) : balances.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className="h-24 text-center"
>
No balance records found.
</TableCell>
</TableRow>
) : (
balances.map((balance) => (
<TableRow key={balance.id}>
<TableCell>
{formatDate(
balance.balance_date,
)}
</TableCell>
<TableCell className="text-right font-mono">
{formatter.format(
balance.balance / 100,
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() =>
handleEditClick(
balance,
)
}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() =>
setDeletingBalance(
balance,
)
}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
{lastPage > 1 && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
{total} balance record{total !== 1 ? 's' : ''}
</span>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() =>
fetchBalances(currentPage - 1)
}
disabled={currentPage <= 1 || isLoading}
>
Previous
</Button>
<span className="text-sm">
Page {currentPage} of {lastPage}
</span>
<Button
variant="outline"
size="sm"
onClick={() =>
fetchBalances(currentPage + 1)
}
disabled={
currentPage >= lastPage || isLoading
}
>
Next
</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
<Dialog
open={editingBalance !== null}
onOpenChange={(open) => !open && setEditingBalance(null)}
>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Edit Balance</DialogTitle>
<DialogDescription>
Update the balance record.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleEditSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="edit-amount">Balance</Label>
<AmountInput
id="edit-amount"
className="mt-1"
value={editAmount}
onChange={setEditAmount}
currencyCode={account.currency_code}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-date">Date</Label>
<Input
id="edit-date"
className="mt-1"
type="date"
value={editDate}
onChange={(e) => setEditDate(e.target.value)}
required
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setEditingBalance(null)}
disabled={isEditSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isEditSubmitting}>
{isEditSubmitting ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog
open={deletingBalance !== null}
onOpenChange={(open) => !open && setDeletingBalance(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete balance</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete this balance record?
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
Cancel
</AlertDialogCancel>
<AlertDialogAction
variant={'destructive'}
onClick={handleDelete}
disabled={isDeleting}
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@ -143,7 +143,7 @@ export function CustomBankForm({
<img
src={value.logoPreview}
alt="Bank logo preview"
className="rounded-full size-5 object-contain"
className="size-5 rounded-full object-contain"
/>
) : (
<ImagePlus className="size-4 text-muted-foreground" />

View File

@ -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({
<Label htmlFor="confirm">Confirmation</Label>
<Input
id="confirm"
className="mt-1"
value={confirmText}
onChange={(e) => 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 }) => (

View File

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

View File

@ -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<string | null>(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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>Update balance</DialogTitle>
<DialogDescription>
Set the balance for this account on a specific date.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="balance-amount">Balance</Label>
<AmountInput
id="balance-amount"
className="mt-1"
value={balance}
onChange={setBalance}
currencyCode={account.currency_code}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="balance-date">Date</Label>
<Input
id="balance-date"
type="date"
className="mt-1"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => handleOpenChange(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@ -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(),

View File

@ -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 (
<div className="space-y-4">
@ -336,39 +338,46 @@ export function TransactionFilters({
</div>
</div>
<div className="space-y-2">
<Label>Accounts</Label>
<div className="flex flex-wrap gap-2 pt-2">
{accounts.map((account) => {
const isSelected =
filters.accountIds.includes(
account.id,
{!hideAccountFilter && (
<div className="space-y-2">
<Label>Accounts</Label>
<div className="flex flex-wrap gap-2 pt-2">
{accounts.map((account) => {
const isSelected =
filters.accountIds.includes(
account.id,
);
return (
<Badge
key={account.id}
variant={
isSelected
? 'default'
: 'outline'
}
className="cursor-pointer px-2 py-1"
onClick={() =>
handleAccountToggle(
account.id,
)
}
>
<EncryptedText
encryptedText={
account.name
}
iv={account.name_iv}
length={{
min: 6,
max: 28,
}}
/>
</Badge>
);
return (
<Badge
key={account.id}
variant={
isSelected
? 'default'
: 'outline'
}
className="cursor-pointer px-2 py-1"
onClick={() =>
handleAccountToggle(
account.id,
)
}
>
<EncryptedText
encryptedText={account.name}
iv={account.name_iv}
length={{ min: 6, max: 28 }}
/>
</Badge>
);
})}
})}
</div>
</div>
</div>
)}
</div>
</PopoverContent>
</Popover>

File diff suppressed because it is too large Load Diff

View File

@ -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<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root {...props} />
return <AlertDialogPrimitive.Root {...props} />
}
function AlertDialogTrigger({
...props
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return <AlertDialogPrimitive.Trigger {...props} />
return <AlertDialogPrimitive.Trigger {...props} />
}
function AlertDialogPortal({
...props
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal {...props} />
return <AlertDialogPrimitive.Portal {...props} />
}
function AlertDialogOverlay({
className,
...props
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
return (
<AlertDialogPrimitive.Overlay
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex flex-col gap-2 text-center sm:text-left",
className
)}
{...props}
/>
)
return (
<div
className={cn(
"flex flex-col gap-2 text-center sm:text-left",
className
)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
return (
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
return (
<AlertDialogPrimitive.Title
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
return (
<AlertDialogPrimitive.Description
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
className,
variant,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> & { variant?: VariantProps<typeof buttonVariants>['variant'] }) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants({ variant }), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(
buttonVariants({ variant: "outline" }),
className
)}
{...props}
/>
)
return (
<AlertDialogPrimitive.Cancel
className={cn(
buttonVariants({ variant: "outline" }),
className
)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@ -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<HTMLInputElement, AmountInputProps>(
required = false,
placeholder = '0.00',
id,
className = '',
},
ref,
) => {
@ -107,7 +127,7 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
placeholder={placeholder}
disabled={disabled}
required={required}
className="bg-background pl-9"
className={cn(["bg-background pl-9", className])}
/>
</div>
);

View File

@ -21,6 +21,7 @@ interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[];
emptyMessage?: string;
renderRow?: (row: Row<TData>, virtualRow: VirtualItem, rowVirtualizer: Virtualizer<HTMLDivElement, Element>) => React.ReactNode;
maxHeight?: number;
}
export function DataTable<TData, TValue>({
@ -28,6 +29,7 @@ export function DataTable<TData, TValue>({
columns,
emptyMessage = 'No results found.',
renderRow,
maxHeight,
}: DataTableProps<TData, TValue>) {
const tableContainerRef = useRef<HTMLDivElement>(null);
const rows = table.getRowModel().rows;
@ -54,9 +56,10 @@ export function DataTable<TData, TValue>({
<div className="overflow-hidden rounded-md border">
<div
ref={tableContainerRef}
style={maxHeight ? { maxHeight, overflowY: 'auto' } : undefined}
>
<Table>
<TableHeader>
<TableHeader className={maxHeight ? 'sticky top-0 z-10 bg-background' : undefined}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {

View File

@ -62,7 +62,11 @@ async function decryptAccountName(
key: CryptoKey,
): Promise<string> {
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);

View File

@ -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<AccountType, typeof accountsWithMetrics> = {
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 (
<AppSidebarLayout breadcrumbs={breadcrumbs}>
<Head title="Accounts" />
<div className="space-y-8 p-6">
<HeadingSmall
title="Accounts"
description="View and manage your bank accounts"
/>
<div className="grid gap-4 md:grid-cols-2">
{ACCOUNT_TYPE_ORDER.map((type) => {
const accountsInGroup = groupedAccounts[type];
if (accountsInGroup.length === 0) return null;
return (
<>
{isLoading
? accountsInGroup.map((account) => (
<AccountListCard
key={account.id}
account={account}
loading={true}
/>
))
: accountsInGroup.map((account) => (
<AccountListCard
key={account.id}
account={account}
/>
))}
</>
);
})}
</div>
{accounts.length === 0 && !isLoading && (
<div className="flex h-[300px] items-center justify-center text-muted-foreground">
No accounts found. Add your first account in Settings.
</div>
)}
</div>
</AppSidebarLayout>
);
}

View File

@ -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: (
<EncryptedText
encryptedText={account.name}
iv={account.name_iv}
length={{ min: 5, max: 20 }}
/>
),
href: show.url(account.id),
},
];
return (
<AppSidebarLayout breadcrumbs={breadcrumbs}>
<Head title="Account Details" />
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 pl-1">
{account.bank?.logo ? (
<img
src={account.bank.logo}
alt={account.bank.name}
className="size-12 rounded-full object-contain"
/>
) : (
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
<span className="text-lg font-medium text-muted-foreground">
{account.bank?.name?.charAt(0) || '?'}
</span>
</div>
)}
<HeadingSmall
title={
<EncryptedText
encryptedText={account.name}
iv={account.name_iv}
length={{ min: 8, max: 30 }}
/>
}
description={`${account.bank?.name || 'Unknown Bank'} · ${formatAccountType(account.type)}`}
/>
</div>
<ButtonGroup>
<ButtonGroup>
<Button
variant="outline"
onClick={() => setUpdateBalanceOpen(true)}
>
Update balance
</Button>
</ButtonGroup>
<ButtonGroup>
<Button
variant="outline"
onClick={() => alert("Work in progress!")}
>
Import balances
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="icon"
aria-label="More options"
>
<ChevronDown className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setBalancesOpen(true)}
>
See balances
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setEditOpen(true)}
>
Edit account
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
variant="destructive"
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ButtonGroup>
</ButtonGroup>
</div>
<AccountBalanceChart
account={account}
refreshKey={chartRefreshKey}
/>
{isTransactionalAccount(account) && <TransactionList
categories={categories}
accounts={accounts}
banks={banks}
accountId={account.id}
pageSize={10}
hideAccountFilter={true}
showActionsMenu={false}
maxHeight={600}
hideColumns={['bank', 'account']}
/>}
</div>
<EditAccountDialog
account={account}
open={editOpen}
onOpenChange={setEditOpen}
redirectTo={show.url(account.id)}
/>
<DeleteAccountDialog
account={account}
open={deleteOpen}
onOpenChange={setDeleteOpen}
redirectTo={index().url}
/>
<UpdateBalanceDialog
account={account}
open={updateBalanceOpen}
onOpenChange={setUpdateBalanceOpen}
onSuccess={handleBalanceUpdated}
/>
<BalancesModal
account={account}
open={balancesOpen}
onOpenChange={setBalancesOpen}
onBalanceChange={handleBalanceUpdated}
/>
</AppSidebarLayout>
);
}

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\AccountBalanceController;
use App\Http\Controllers\AccountController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\EncryptionController;
use App\Http\Controllers\RobotsController;
@ -65,6 +66,8 @@ Route::middleware(['auth'])->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');

View File

@ -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,
]);
});

View File

@ -0,0 +1,221 @@
<?php
use App\Enums\AccountType;
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\Bank;
use App\Models\Category;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function () {
$this->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')
);
});