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:
parent
2d92afa66d
commit
8be7ea98e0
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ class AccountPolicy
|
|||
*/
|
||||
public function view(User $user, Account $account): bool
|
||||
{
|
||||
return false;
|
||||
return $user->id === $account->user_id;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
);
|
||||
});
|
||||
Loading…
Reference in New Issue