feat(analysis): add expense/income analysis screen
New top-level Analysis screen to drill into income and expenses across category, month, account, and label dimensions. Reuses transaction filters (category + labels surfaced inline, search hidden) and converts all amounts to the user currency. Click a breakdown row to drill in. Also make category and label filters combine with OR in the shared transaction filter scope (affects the transactions screen too).
This commit is contained in:
parent
d68fee6c2d
commit
f66e9cda8a
|
|
@ -0,0 +1,50 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\Bank;
|
||||
use App\Models\Category;
|
||||
use App\Models\Label;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class AnalysisController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$categories = Category::query()
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'icon', 'color', 'type', 'cashflow_direction']);
|
||||
|
||||
$accounts = Account::query()
|
||||
->where('user_id', $user->id)
|
||||
->with('bank:id,name,logo')
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
|
||||
|
||||
$banks = Bank::query()
|
||||
->where(function ($query) use ($user) {
|
||||
$query->whereNull('user_id')
|
||||
->orWhere('user_id', $user->id);
|
||||
})
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'logo']);
|
||||
|
||||
$labels = Label::query()
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'color']);
|
||||
|
||||
return Inertia::render('analysis/index', [
|
||||
'categories' => $categories,
|
||||
'accounts' => $accounts,
|
||||
'banks' => $banks,
|
||||
'labels' => $labels,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\AnalysisRequest;
|
||||
use App\Models\Transaction;
|
||||
use App\Services\ExchangeRateService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class AnalysisController extends Controller
|
||||
{
|
||||
public function __construct(private ExchangeRateService $exchangeRateService) {}
|
||||
|
||||
public function index(AnalysisRequest $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$userCurrency = $user->currency_code;
|
||||
$groupBy = $request->validated()['group_by'];
|
||||
|
||||
$transactions = Transaction::query()
|
||||
->where('user_id', $user->id)
|
||||
->with(['account:id,currency_code', 'category:id,name,icon,color,type', 'labels:id,name,color'])
|
||||
->applyFilters($request->filters())
|
||||
->get();
|
||||
|
||||
$this->preloadExchangeRates($transactions, $userCurrency);
|
||||
|
||||
$converted = $transactions->map(fn (Transaction $transaction): array => [
|
||||
'transaction' => $transaction,
|
||||
'amount' => $this->convertTransactionAmount($transaction, $userCurrency),
|
||||
]);
|
||||
|
||||
return response()
|
||||
->json([
|
||||
'summary' => $this->summary($converted),
|
||||
'groups' => $this->groups($converted, $groupBy)->values(),
|
||||
])
|
||||
->header('Cache-Control', 'no-store, private');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array{transaction: Transaction, amount: int}> $converted
|
||||
* @return array{income: int, expense: int, net: int, count: int}
|
||||
*/
|
||||
private function summary(Collection $converted): array
|
||||
{
|
||||
$income = $converted->filter(fn (array $row): bool => $row['amount'] > 0)->sum('amount');
|
||||
$expense = abs($converted->filter(fn (array $row): bool => $row['amount'] < 0)->sum('amount'));
|
||||
|
||||
return [
|
||||
'income' => $income,
|
||||
'expense' => $expense,
|
||||
'net' => $income - $expense,
|
||||
'count' => $converted->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, array{transaction: Transaction, amount: int}> $converted
|
||||
* @return Collection<int, array{key: ?string, amount: int, count: int}>
|
||||
*/
|
||||
private function groups(Collection $converted, string $groupBy): Collection
|
||||
{
|
||||
if ($groupBy === 'label') {
|
||||
return $this->labelGroups($converted);
|
||||
}
|
||||
|
||||
return $converted
|
||||
->groupBy(fn (array $row): string => $this->groupKey($row['transaction'], $groupBy) ?? '__null__')
|
||||
->map(fn (Collection $rows, string $key): array => [
|
||||
'key' => $key === '__null__' ? null : $key,
|
||||
'amount' => $rows->sum('amount'),
|
||||
'count' => $rows->count(),
|
||||
])
|
||||
->sortByDesc(fn (array $group): int => abs($group['amount']));
|
||||
}
|
||||
|
||||
/**
|
||||
* A transaction with several labels contributes to each label's bucket.
|
||||
* Transactions without labels fall into a single null bucket.
|
||||
*
|
||||
* @param Collection<int, array{transaction: Transaction, amount: int}> $converted
|
||||
* @return Collection<int, array{key: ?string, amount: int, count: int}>
|
||||
*/
|
||||
private function labelGroups(Collection $converted): Collection
|
||||
{
|
||||
$buckets = [];
|
||||
|
||||
foreach ($converted as $row) {
|
||||
$labels = $row['transaction']->labels;
|
||||
|
||||
if ($labels->isEmpty()) {
|
||||
$buckets['__null__'] ??= ['key' => null, 'amount' => 0, 'count' => 0];
|
||||
$buckets['__null__']['amount'] += $row['amount'];
|
||||
$buckets['__null__']['count']++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($labels as $label) {
|
||||
$buckets[$label->id] ??= ['key' => $label->id, 'amount' => 0, 'count' => 0];
|
||||
$buckets[$label->id]['amount'] += $row['amount'];
|
||||
$buckets[$label->id]['count']++;
|
||||
}
|
||||
}
|
||||
|
||||
return collect(array_values($buckets))
|
||||
->sortByDesc(fn (array $group): int => abs($group['amount']));
|
||||
}
|
||||
|
||||
private function groupKey(Transaction $transaction, string $groupBy): ?string
|
||||
{
|
||||
return match ($groupBy) {
|
||||
'category' => $transaction->category_id,
|
||||
'account' => $transaction->account_id,
|
||||
'month' => $transaction->transaction_date->format('Y-m'),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function convertTransactionAmount(Transaction $transaction, string $userCurrency): int
|
||||
{
|
||||
return $this->exchangeRateService->convert(
|
||||
$transaction->currency_code ?: $transaction->account?->currency_code ?: $userCurrency,
|
||||
$userCurrency,
|
||||
$transaction->amount,
|
||||
$transaction->transaction_date->toDateString(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Transaction> $transactions
|
||||
*/
|
||||
private function preloadExchangeRates(Collection $transactions, string $userCurrency): void
|
||||
{
|
||||
$dates = $transactions
|
||||
->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $userCurrency, $userCurrency) !== 0)
|
||||
->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString())
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($dates->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->exchangeRateService->preloadRates($userCurrency, $dates);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class AnalysisRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$fields = ['category_ids', 'account_ids', 'label_ids'];
|
||||
|
||||
foreach ($fields as $field) {
|
||||
$value = $this->input($field);
|
||||
if (is_string($value)) {
|
||||
$this->merge([
|
||||
$field => array_filter(explode(',', $value)),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'group_by' => ['required', 'string', 'in:category,month,account,label'],
|
||||
'date_from' => ['nullable', 'date'],
|
||||
'date_to' => ['nullable', 'date'],
|
||||
'amount_min' => ['nullable', 'numeric'],
|
||||
'amount_max' => ['nullable', 'numeric'],
|
||||
'category_ids' => ['nullable', 'array'],
|
||||
'category_ids.*' => ['string'],
|
||||
'account_ids' => ['nullable', 'array'],
|
||||
'account_ids.*' => ['string', 'uuid'],
|
||||
'label_ids' => ['nullable', 'array'],
|
||||
'label_ids.*' => ['string', 'uuid'],
|
||||
'creditor_name' => ['nullable', 'string', 'max:255'],
|
||||
'debtor_name' => ['nullable', 'string', 'max:255'],
|
||||
'search' => ['nullable', 'string', 'max:200'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function filters(): array
|
||||
{
|
||||
$validated = $this->validated();
|
||||
|
||||
return array_filter([
|
||||
'date_from' => $validated['date_from'] ?? null,
|
||||
'date_to' => $validated['date_to'] ?? null,
|
||||
'amount_min' => $validated['amount_min'] ?? null,
|
||||
'amount_max' => $validated['amount_max'] ?? null,
|
||||
'category_ids' => $validated['category_ids'] ?? null,
|
||||
'account_ids' => $validated['account_ids'] ?? null,
|
||||
'label_ids' => $validated['label_ids'] ?? null,
|
||||
'creditor_name' => $validated['creditor_name'] ?? null,
|
||||
'debtor_name' => $validated['debtor_name'] ?? null,
|
||||
'search' => $validated['search'] ?? null,
|
||||
], fn ($value): bool => $value !== null);
|
||||
}
|
||||
}
|
||||
|
|
@ -117,17 +117,29 @@ class Transaction extends Model
|
|||
$query->where('amount', '<=', $filters['amount_max'] * 100);
|
||||
}
|
||||
|
||||
if (! empty($filters['category_ids'])) {
|
||||
$ids = collect($filters['category_ids']);
|
||||
$hasUncategorized = $ids->contains('uncategorized');
|
||||
$realIds = $ids->reject(fn ($id) => $id === 'uncategorized')->values()->all();
|
||||
$hasCategoryFilter = ! empty($filters['category_ids']);
|
||||
$hasLabelFilter = ! empty($filters['label_ids']);
|
||||
|
||||
$query->where(function (Builder $q) use ($realIds, $hasUncategorized) {
|
||||
if (! empty($realIds)) {
|
||||
$q->whereIn('category_id', $realIds);
|
||||
if ($hasCategoryFilter || $hasLabelFilter) {
|
||||
$query->where(function (Builder $q) use ($filters, $hasCategoryFilter, $hasLabelFilter) {
|
||||
if ($hasCategoryFilter) {
|
||||
$ids = collect($filters['category_ids']);
|
||||
$hasUncategorized = $ids->contains('uncategorized');
|
||||
$realIds = $ids->reject(fn ($id) => $id === 'uncategorized')->values()->all();
|
||||
|
||||
$q->where(function (Builder $categoryQuery) use ($realIds, $hasUncategorized) {
|
||||
if (! empty($realIds)) {
|
||||
$categoryQuery->whereIn('category_id', $realIds);
|
||||
}
|
||||
if ($hasUncategorized) {
|
||||
$categoryQuery->orWhereNull('category_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
if ($hasUncategorized) {
|
||||
$q->orWhereNull('category_id');
|
||||
|
||||
if ($hasLabelFilter) {
|
||||
$method = $hasCategoryFilter ? 'orWhereHas' : 'whereHas';
|
||||
$q->{$method}('labels', fn (Builder $labelQuery) => $labelQuery->whereIn('labels.id', $filters['label_ids']));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -136,10 +148,6 @@ class Transaction extends Model
|
|||
$query->whereIn('account_id', $filters['account_ids']);
|
||||
}
|
||||
|
||||
if (! empty($filters['label_ids'])) {
|
||||
$query->whereHas('labels', fn (Builder $q) => $q->whereIn('labels.id', $filters['label_ids']));
|
||||
}
|
||||
|
||||
if (! empty($filters['creditor_name'])) {
|
||||
$term = '%'.$filters['creditor_name'].'%';
|
||||
$query->where('creditor_name', 'LIKE', $term);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,151 @@
|
|||
import { CategoryIcon } from '@/components/shared/category-combobox';
|
||||
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { useChartColors } from '@/hooks/use-chart-color-scheme';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type Account } from '@/types/account';
|
||||
import { type Category } from '@/types/category';
|
||||
import { getLabelColorClasses } from '@/types/label';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { CreditCard, Tag } from 'lucide-react';
|
||||
|
||||
export interface ResolvedRow {
|
||||
key: string | null;
|
||||
label: string;
|
||||
amount: number;
|
||||
count: number;
|
||||
category?: Category;
|
||||
labelColor?: string;
|
||||
account?: Account;
|
||||
}
|
||||
|
||||
interface AnalysisBreakdownProps {
|
||||
title: string;
|
||||
rows: ResolvedRow[];
|
||||
currency: string;
|
||||
loading?: boolean;
|
||||
onDrill?: (row: ResolvedRow) => void;
|
||||
}
|
||||
|
||||
export function AnalysisBreakdown({
|
||||
title,
|
||||
rows,
|
||||
currency,
|
||||
loading,
|
||||
onDrill,
|
||||
}: AnalysisBreakdownProps) {
|
||||
const { categoryBarColor } = useChartColors();
|
||||
const maxAmount = Math.max(...rows.map((row) => Math.abs(row.amount)), 1);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-6 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="ml-auto h-4 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<div className="h-1.5 w-full animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : rows.length === 0 ? (
|
||||
<div className="py-10 text-center text-sm text-muted-foreground">
|
||||
{__('No transactions match these filters')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{rows.map((row, index) => {
|
||||
const barColor = row.category
|
||||
? categoryBarColor(row.category.color, index)
|
||||
: 'var(--chart-1)';
|
||||
const widthPercentage =
|
||||
(Math.abs(row.amount) / maxAmount) * 100;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={row.key ?? '__null__'}
|
||||
type="button"
|
||||
onClick={() => onDrill?.(row)}
|
||||
disabled={!onDrill}
|
||||
className={cn(
|
||||
'-mx-1.5 block w-full space-y-1.5 rounded-md px-1.5 py-1.5 text-left transition-colors',
|
||||
onDrill &&
|
||||
'cursor-pointer hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<RowIcon row={row} />
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{row.label}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
({row.count})
|
||||
</span>
|
||||
</div>
|
||||
<AmountDisplay
|
||||
amountInCents={row.amount}
|
||||
currencyCode={currency}
|
||||
variant="compact"
|
||||
showSign
|
||||
minimumFractionDigits={0}
|
||||
maximumFractionDigits={0}
|
||||
/>
|
||||
</div>
|
||||
<Progress
|
||||
value={widthPercentage}
|
||||
className="h-1.5"
|
||||
indicatorColor={barColor}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function RowIcon({ row }: { row: ResolvedRow }) {
|
||||
if (row.category) {
|
||||
return (
|
||||
<div className="shrink-0">
|
||||
<CategoryIcon category={row.category} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.account) {
|
||||
return (
|
||||
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-200">
|
||||
<CreditCard className="size-3.5" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (row.labelColor) {
|
||||
const colorClasses = getLabelColorClasses(row.labelColor);
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex size-6 shrink-0 items-center justify-center rounded-full',
|
||||
colorClasses.bg,
|
||||
)}
|
||||
>
|
||||
<Tag className={cn('size-3.5', colorClasses.text)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
|
||||
import { useChartColors } from '@/hooks/use-chart-color-scheme';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import { formatCompactNumber } from '@/utils/date';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
ReferenceLine,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts';
|
||||
|
||||
export interface AnalysisChartPoint {
|
||||
key: string;
|
||||
label: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
interface AnalysisChartProps {
|
||||
title: string;
|
||||
data: AnalysisChartPoint[];
|
||||
currency: string;
|
||||
loading?: boolean;
|
||||
scrollable?: boolean;
|
||||
}
|
||||
|
||||
interface TooltipPayloadItem {
|
||||
payload?: AnalysisChartPoint;
|
||||
}
|
||||
|
||||
function CustomTooltip({
|
||||
active,
|
||||
payload,
|
||||
currency,
|
||||
}: {
|
||||
active?: boolean;
|
||||
payload?: TooltipPayloadItem[];
|
||||
currency: string;
|
||||
}) {
|
||||
if (!active || !payload?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const point = payload[0].payload;
|
||||
if (!point) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border/50 bg-background px-3 py-2 text-xs shadow-xl">
|
||||
<div className="mb-1 font-medium">{point.label}</div>
|
||||
<AmountDisplay
|
||||
amountInCents={point.amount}
|
||||
currencyCode={currency}
|
||||
showSign
|
||||
className="font-mono font-medium tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AnalysisChart({
|
||||
title,
|
||||
data,
|
||||
currency,
|
||||
loading,
|
||||
scrollable,
|
||||
}: AnalysisChartProps) {
|
||||
const locale = useLocale();
|
||||
const { cashflowIncomeColor, cashflowExpenseColor } = useChartColors();
|
||||
|
||||
const chartConfig: ChartConfig = {
|
||||
amount: { label: __('Amount'), color: 'var(--color-chart-1)' },
|
||||
};
|
||||
|
||||
const minChartWidth = scrollable ? data.length * 56 : undefined;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="h-[300px] animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
) : data.length === 0 ? (
|
||||
<div className="flex h-[300px] items-center justify-center text-sm text-muted-foreground">
|
||||
{__('No transactions match these filters')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<ChartContainer
|
||||
config={chartConfig}
|
||||
className="h-[300px] w-full"
|
||||
style={
|
||||
minChartWidth
|
||||
? { minWidth: `${minChartWidth}px` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<BarChart accessibilityLayer data={data}>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
vertical={false}
|
||||
stroke="var(--color-border)"
|
||||
opacity={0.3}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
tickMargin={10}
|
||||
axisLine={false}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={60}
|
||||
tickFormatter={(value: number) =>
|
||||
formatCompactNumber(
|
||||
value / 100,
|
||||
locale,
|
||||
currency,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<ReferenceLine
|
||||
y={0}
|
||||
stroke="var(--color-border)"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
<Tooltip
|
||||
content={
|
||||
<CustomTooltip currency={currency} />
|
||||
}
|
||||
cursor={{
|
||||
fill: 'var(--color-muted)',
|
||||
opacity: 0.3,
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="amount" radius={[4, 4, 0, 0]}>
|
||||
{data.map((point) => (
|
||||
<Cell
|
||||
key={point.key}
|
||||
fill={
|
||||
point.amount >= 0
|
||||
? cashflowIncomeColor
|
||||
: cashflowExpenseColor
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { AmountDisplay } from '@/components/ui/amount-display';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { type AnalysisSummary } from '@/hooks/use-analysis-data';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { __ } from '@/utils/i18n';
|
||||
|
||||
interface AnalysisSummaryCardsProps {
|
||||
summary: AnalysisSummary;
|
||||
currency: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function AnalysisSummaryCards({
|
||||
summary,
|
||||
currency,
|
||||
loading,
|
||||
}: AnalysisSummaryCardsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<SummaryCard label={__('Income')} loading={loading}>
|
||||
<AmountDisplay
|
||||
amountInCents={summary.income}
|
||||
currencyCode={currency}
|
||||
weight="semibold"
|
||||
size="xl"
|
||||
className="text-emerald-600 dark:text-emerald-400"
|
||||
/>
|
||||
</SummaryCard>
|
||||
|
||||
<SummaryCard label={__('Spent')} loading={loading}>
|
||||
<AmountDisplay
|
||||
amountInCents={summary.expense}
|
||||
currencyCode={currency}
|
||||
weight="semibold"
|
||||
size="xl"
|
||||
className="text-rose-600 dark:text-rose-400"
|
||||
/>
|
||||
</SummaryCard>
|
||||
|
||||
<SummaryCard label={__('Net')} loading={loading}>
|
||||
<AmountDisplay
|
||||
amountInCents={summary.net}
|
||||
currencyCode={currency}
|
||||
weight="semibold"
|
||||
size="xl"
|
||||
showSign
|
||||
className={cn(
|
||||
summary.net >= 0
|
||||
? 'text-emerald-600 dark:text-emerald-400'
|
||||
: 'text-rose-600 dark:text-rose-400',
|
||||
)}
|
||||
/>
|
||||
</SummaryCard>
|
||||
|
||||
<SummaryCard label={__('Transactions')} loading={loading}>
|
||||
<span className="text-xl font-semibold tabular-nums">
|
||||
{summary.count.toLocaleString()}
|
||||
</span>
|
||||
</SummaryCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({
|
||||
label,
|
||||
loading,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
loading?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col gap-1 p-4">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
{loading ? (
|
||||
<div className="h-7 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -37,6 +37,8 @@ interface TransactionFiltersProps {
|
|||
isKeySet: boolean;
|
||||
actions?: ReactNode;
|
||||
hideAccountFilter?: boolean;
|
||||
hideSearch?: boolean;
|
||||
inlineCategoryLabel?: boolean;
|
||||
}
|
||||
|
||||
export function TransactionFilters({
|
||||
|
|
@ -47,16 +49,13 @@ export function TransactionFilters({
|
|||
accounts,
|
||||
actions,
|
||||
hideAccountFilter = false,
|
||||
hideSearch = false,
|
||||
inlineCategoryLabel = false,
|
||||
}: TransactionFiltersProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
|
||||
const [labelDropdownOpen, setLabelDropdownOpen] = useState(false);
|
||||
const [searchText, setSearchText] = useState(filters.searchText);
|
||||
const [creditorName, setCreditorName] = useState(filters.creditorName);
|
||||
const [debtorName, setDebtorName] = useState(filters.debtorName);
|
||||
const isUncategorizedSelected = filters.categoryIds.includes(
|
||||
UNCATEGORIZED_CATEGORY_ID,
|
||||
);
|
||||
|
||||
// Debounce text filter updates
|
||||
useEffect(() => {
|
||||
|
|
@ -101,7 +100,7 @@ export function TransactionFilters({
|
|||
onFiltersChange({ ...filters, categoryIds: newCategoryIds });
|
||||
}
|
||||
|
||||
function handleAccountToggle(accountId: number) {
|
||||
function handleAccountToggle(accountId: string) {
|
||||
const newAccountIds = filters.accountIds.includes(accountId)
|
||||
? filters.accountIds.filter((id) => id !== accountId)
|
||||
: [...filters.accountIds, accountId];
|
||||
|
|
@ -140,22 +139,42 @@ export function TransactionFilters({
|
|||
(filters.dateTo ? 1 : 0) +
|
||||
(filters.amountMin !== null ? 1 : 0) +
|
||||
(filters.amountMax !== null ? 1 : 0) +
|
||||
filters.categoryIds.length +
|
||||
filters.labelIds.length +
|
||||
(filters.creditorName ? 1 : 0) +
|
||||
(filters.debtorName ? 1 : 0) +
|
||||
(inlineCategoryLabel
|
||||
? 0
|
||||
: filters.categoryIds.length + filters.labelIds.length) +
|
||||
(hideAccountFilter ? 0 : filters.accountIds.length);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
||||
<div className="flex w-full flex-row items-center gap-2">
|
||||
<Input
|
||||
placeholder={__('Search description or notes...')}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="min-w-0 flex-1 md:min-w-[350px]"
|
||||
/>
|
||||
<div className="flex w-full flex-wrap items-center gap-2">
|
||||
{!hideSearch && (
|
||||
<Input
|
||||
placeholder={__('Search description or notes...')}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="min-w-0 flex-1 md:min-w-[350px]"
|
||||
/>
|
||||
)}
|
||||
|
||||
{inlineCategoryLabel && (
|
||||
<>
|
||||
<CategoryMultiSelect
|
||||
categories={categories}
|
||||
selectedIds={filters.categoryIds}
|
||||
onToggle={handleCategoryToggle}
|
||||
triggerClassName="w-[180px]"
|
||||
/>
|
||||
<LabelMultiSelect
|
||||
labels={labels}
|
||||
selectedIds={filters.labelIds}
|
||||
onToggle={handleLabelToggle}
|
||||
triggerClassName="w-[160px]"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
|
|
@ -295,254 +314,37 @@ export function TransactionFilters({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{__('Categories')}</FormLabel>
|
||||
<div className="pt-2">
|
||||
<Popover
|
||||
open={categoryDropdownOpen}
|
||||
onOpenChange={
|
||||
setCategoryDropdownOpen
|
||||
}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{filters.categoryIds
|
||||
.length > 0 ? (
|
||||
<span className="truncate">
|
||||
{
|
||||
filters
|
||||
.categoryIds
|
||||
.length
|
||||
}{' '}
|
||||
selected
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{__(
|
||||
'Select categories...',
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={__(
|
||||
'Search categories...',
|
||||
)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{__(
|
||||
'No category found.',
|
||||
)}
|
||||
</CommandEmpty>
|
||||
<CommandItem
|
||||
onSelect={() =>
|
||||
handleCategoryToggle(
|
||||
UNCATEGORIZED_CATEGORY_ID,
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
|
||||
isUncategorizedSelected
|
||||
? 'bg-primary/10 text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible',
|
||||
)}
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<Icons.HelpCircle className="h-3 w-3 text-zinc-500" />
|
||||
</div>
|
||||
{__(
|
||||
'Uncategorized',
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
{categories.map(
|
||||
(category) => {
|
||||
const isSelected =
|
||||
filters.categoryIds.includes(
|
||||
category.id,
|
||||
);
|
||||
const colorClasses =
|
||||
getCategoryColorClasses(
|
||||
category.color,
|
||||
);
|
||||
return (
|
||||
<CommandItem
|
||||
key={
|
||||
category.id
|
||||
}
|
||||
onSelect={() =>
|
||||
handleCategoryToggle(
|
||||
category.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
|
||||
isSelected
|
||||
? 'bg-primary/10 text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible',
|
||||
)}
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 items-center justify-center rounded-full',
|
||||
colorClasses.bg,
|
||||
)}
|
||||
>
|
||||
<CategoryIcon
|
||||
category={
|
||||
category
|
||||
}
|
||||
size={
|
||||
4
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
{
|
||||
category.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{!inlineCategoryLabel && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>
|
||||
{__('Categories')}
|
||||
</FormLabel>
|
||||
<div className="pt-2">
|
||||
<CategoryMultiSelect
|
||||
categories={categories}
|
||||
selectedIds={
|
||||
filters.categoryIds
|
||||
}
|
||||
onToggle={handleCategoryToggle}
|
||||
triggerClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{__('Labels')}</FormLabel>
|
||||
<div className="pt-2">
|
||||
<Popover
|
||||
open={labelDropdownOpen}
|
||||
onOpenChange={setLabelDropdownOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between"
|
||||
>
|
||||
{filters.labelIds.length >
|
||||
0 ? (
|
||||
<span className="truncate">
|
||||
{
|
||||
filters.labelIds
|
||||
.length
|
||||
}{' '}
|
||||
selected
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{__(
|
||||
'Select labels...',
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-full p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={__(
|
||||
'Search labels...',
|
||||
)}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{__(
|
||||
'No labels found.',
|
||||
)}
|
||||
</CommandEmpty>
|
||||
{labels.map((label) => {
|
||||
const isSelected =
|
||||
filters.labelIds.includes(
|
||||
label.id,
|
||||
);
|
||||
const colorClasses =
|
||||
getLabelColorClasses(
|
||||
label.color,
|
||||
);
|
||||
return (
|
||||
<CommandItem
|
||||
key={
|
||||
label.id
|
||||
}
|
||||
onSelect={() =>
|
||||
handleLabelToggle(
|
||||
label.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
|
||||
isSelected
|
||||
? 'bg-primary/10 text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible',
|
||||
)}
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 items-center justify-center rounded-full',
|
||||
colorClasses.bg,
|
||||
)}
|
||||
>
|
||||
<Tag
|
||||
className={cn(
|
||||
'h-3 w-3',
|
||||
colorClasses.text,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
{
|
||||
label.name
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{!inlineCategoryLabel && (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{__('Labels')}</FormLabel>
|
||||
<div className="pt-2">
|
||||
<LabelMultiSelect
|
||||
labels={labels}
|
||||
selectedIds={filters.labelIds}
|
||||
onToggle={handleLabelToggle}
|
||||
triggerClassName="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hideAccountFilter && (
|
||||
<div className="space-y-2">
|
||||
|
|
@ -604,4 +406,196 @@ export function TransactionFilters({
|
|||
);
|
||||
}
|
||||
|
||||
interface CategoryMultiSelectProps {
|
||||
categories: Category[];
|
||||
selectedIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
function CategoryMultiSelect({
|
||||
categories,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
triggerClassName,
|
||||
}: CategoryMultiSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isUncategorizedSelected = selectedIds.includes(
|
||||
UNCATEGORIZED_CATEGORY_ID,
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn('justify-between', triggerClassName)}
|
||||
>
|
||||
{selectedIds.length > 0 ? (
|
||||
<span className="truncate">
|
||||
{selectedIds.length} {__('selected')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{__('Categories')}
|
||||
</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={__('Search categories...')} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{__('No category found.')}</CommandEmpty>
|
||||
<CommandItem
|
||||
onSelect={() => onToggle(UNCATEGORIZED_CATEGORY_ID)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
|
||||
isUncategorizedSelected
|
||||
? 'bg-primary/10 text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible',
|
||||
)}
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<Icons.HelpCircle className="h-3 w-3 text-zinc-500" />
|
||||
</div>
|
||||
{__('Uncategorized')}
|
||||
</div>
|
||||
</CommandItem>
|
||||
{categories.map((category) => {
|
||||
const isSelected = selectedIds.includes(
|
||||
category.id,
|
||||
);
|
||||
const colorClasses = getCategoryColorClasses(
|
||||
category.color,
|
||||
);
|
||||
return (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
onSelect={() => onToggle(category.id)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
|
||||
isSelected
|
||||
? 'bg-primary/10 text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible',
|
||||
)}
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 items-center justify-center rounded-full',
|
||||
colorClasses.bg,
|
||||
)}
|
||||
>
|
||||
<CategoryIcon category={category} />
|
||||
</div>
|
||||
<span>{category.name}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
interface LabelMultiSelectProps {
|
||||
labels: Label[];
|
||||
selectedIds: string[];
|
||||
onToggle: (id: string) => void;
|
||||
triggerClassName?: string;
|
||||
}
|
||||
|
||||
function LabelMultiSelect({
|
||||
labels,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
triggerClassName,
|
||||
}: LabelMultiSelectProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className={cn('justify-between', triggerClassName)}
|
||||
>
|
||||
{selectedIds.length > 0 ? (
|
||||
<span className="truncate">
|
||||
{selectedIds.length} {__('selected')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">
|
||||
{__('Labels')}
|
||||
</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={__('Search labels...')} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{__('No labels found.')}</CommandEmpty>
|
||||
{labels.map((label) => {
|
||||
const isSelected = selectedIds.includes(label.id);
|
||||
const colorClasses = getLabelColorClasses(
|
||||
label.color,
|
||||
);
|
||||
return (
|
||||
<CommandItem
|
||||
key={label.id}
|
||||
onSelect={() => onToggle(label.id)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
|
||||
isSelected
|
||||
? 'bg-primary/10 text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible',
|
||||
)}
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 items-center justify-center rounded-full',
|
||||
colorClasses.bg,
|
||||
)}
|
||||
>
|
||||
<Tag
|
||||
className={cn(
|
||||
'h-3 w-3',
|
||||
colorClasses.text,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<span>{label.name}</span>
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const UNCATEGORIZED_CATEGORY_ID = 'uncategorized';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
import { type TransactionFilters } from '@/types/transaction';
|
||||
import { format } from 'date-fns';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
export const ANALYSIS_DIMENSIONS = [
|
||||
'category',
|
||||
'month',
|
||||
'account',
|
||||
'label',
|
||||
] as const;
|
||||
|
||||
export type AnalysisDimension = (typeof ANALYSIS_DIMENSIONS)[number];
|
||||
|
||||
export interface AnalysisSummary {
|
||||
income: number;
|
||||
expense: number;
|
||||
net: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AnalysisGroup {
|
||||
key: string | null;
|
||||
amount: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface AnalysisData {
|
||||
summary: AnalysisSummary;
|
||||
groups: AnalysisGroup[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const emptySummary: AnalysisSummary = {
|
||||
income: 0,
|
||||
expense: 0,
|
||||
net: 0,
|
||||
count: 0,
|
||||
};
|
||||
|
||||
function buildQuery(
|
||||
filters: TransactionFilters,
|
||||
groupBy: AnalysisDimension,
|
||||
): string {
|
||||
const params = new URLSearchParams({ group_by: groupBy });
|
||||
|
||||
if (filters.dateFrom) {
|
||||
params.set('date_from', format(filters.dateFrom, 'yyyy-MM-dd'));
|
||||
}
|
||||
if (filters.dateTo) {
|
||||
params.set('date_to', format(filters.dateTo, 'yyyy-MM-dd'));
|
||||
}
|
||||
if (filters.amountMin !== null) {
|
||||
params.set('amount_min', filters.amountMin.toString());
|
||||
}
|
||||
if (filters.amountMax !== null) {
|
||||
params.set('amount_max', filters.amountMax.toString());
|
||||
}
|
||||
if (filters.categoryIds.length > 0) {
|
||||
params.set('category_ids', filters.categoryIds.join(','));
|
||||
}
|
||||
if (filters.accountIds.length > 0) {
|
||||
params.set('account_ids', filters.accountIds.join(','));
|
||||
}
|
||||
if (filters.labelIds.length > 0) {
|
||||
params.set('label_ids', filters.labelIds.join(','));
|
||||
}
|
||||
if (filters.creditorName) {
|
||||
params.set('creditor_name', filters.creditorName);
|
||||
}
|
||||
if (filters.debtorName) {
|
||||
params.set('debtor_name', filters.debtorName);
|
||||
}
|
||||
if (filters.searchText) {
|
||||
params.set('search', filters.searchText);
|
||||
}
|
||||
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export function useAnalysisData(
|
||||
filters: TransactionFilters,
|
||||
groupBy: AnalysisDimension,
|
||||
): AnalysisData & { refetch: () => void } {
|
||||
const [summary, setSummary] = useState<AnalysisSummary>(emptySummary);
|
||||
const [groups, setGroups] = useState<AnalysisGroup[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const query = buildQuery(filters, groupBy);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/analysis?${query}`);
|
||||
const data = await response.json();
|
||||
setSummary(data.summary ?? emptySummary);
|
||||
setGroups(data.groups ?? []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch analysis data:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
return { summary, groups, isLoading, refetch: fetchData };
|
||||
}
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
import {
|
||||
AnalysisBreakdown,
|
||||
type ResolvedRow,
|
||||
} from '@/components/analysis/analysis-breakdown';
|
||||
import {
|
||||
AnalysisChart,
|
||||
type AnalysisChartPoint,
|
||||
} from '@/components/analysis/analysis-chart';
|
||||
import { AnalysisSummaryCards } from '@/components/analysis/analysis-summary-cards';
|
||||
import HeadingSmall from '@/components/heading-small';
|
||||
import { TransactionFilters as TransactionFiltersComponent } from '@/components/transactions/transaction-filters';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
ANALYSIS_DIMENSIONS,
|
||||
type AnalysisDimension,
|
||||
useAnalysisData,
|
||||
} from '@/hooks/use-analysis-data';
|
||||
import { useLocale } from '@/hooks/use-locale';
|
||||
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { analysis } from '@/routes';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type Account } from '@/types/account';
|
||||
import { type Category } from '@/types/category';
|
||||
import { type Label } from '@/types/label';
|
||||
import { type TransactionFilters } from '@/types/transaction';
|
||||
import { formatMonthFromYearMonth } from '@/utils/date';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Head, usePage } from '@inertiajs/react';
|
||||
import { endOfMonth, parseISO, startOfMonth } from 'date-fns';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Analysis',
|
||||
href: analysis().url,
|
||||
},
|
||||
];
|
||||
|
||||
const emptyFilters: TransactionFilters = {
|
||||
dateFrom: null,
|
||||
dateTo: null,
|
||||
amountMin: null,
|
||||
amountMax: null,
|
||||
categoryIds: [],
|
||||
accountIds: [],
|
||||
labelIds: [],
|
||||
creditorName: '',
|
||||
debtorName: '',
|
||||
searchText: '',
|
||||
};
|
||||
|
||||
const dimensionLabels: Record<AnalysisDimension, string> = {
|
||||
category: __('Category'),
|
||||
month: __('Month'),
|
||||
account: __('Account'),
|
||||
label: __('Label'),
|
||||
};
|
||||
|
||||
const isTimeDimension = (dimension: AnalysisDimension): boolean =>
|
||||
dimension === 'month';
|
||||
|
||||
interface Props {
|
||||
categories: Category[];
|
||||
accounts: Account[];
|
||||
labels: Label[];
|
||||
}
|
||||
|
||||
export default function AnalysisPage({ categories, accounts, labels }: Props) {
|
||||
const locale = useLocale();
|
||||
const { auth } = usePage<{
|
||||
auth: { user: { currency_code: string } };
|
||||
}>().props;
|
||||
const currency = auth.user.currency_code;
|
||||
|
||||
const [filters, setFilters] = useState<TransactionFilters>(emptyFilters);
|
||||
const [dimension, setDimension] = useState<AnalysisDimension>('category');
|
||||
|
||||
const { summary, groups, isLoading } = useAnalysisData(filters, dimension);
|
||||
|
||||
const categoriesById = useMemo(
|
||||
() => new Map(categories.map((category) => [category.id, category])),
|
||||
[categories],
|
||||
);
|
||||
const accountsById = useMemo(
|
||||
() => new Map(accounts.map((account) => [account.id, account])),
|
||||
[accounts],
|
||||
);
|
||||
const labelsById = useMemo(
|
||||
() => new Map(labels.map((label) => [label.id, label])),
|
||||
[labels],
|
||||
);
|
||||
|
||||
const resolveLabel = (key: string | null): string => {
|
||||
switch (dimension) {
|
||||
case 'category':
|
||||
return key
|
||||
? (categoriesById.get(key)?.name ?? __('Uncategorized'))
|
||||
: __('Uncategorized');
|
||||
case 'account':
|
||||
return key
|
||||
? (accountsById.get(key)?.name ?? __('Unknown account'))
|
||||
: __('No account');
|
||||
case 'label':
|
||||
return key
|
||||
? (labelsById.get(key)?.name ?? __('Unknown label'))
|
||||
: __('No label');
|
||||
case 'month':
|
||||
return key ? formatMonthFromYearMonth(key, locale) : '';
|
||||
}
|
||||
};
|
||||
|
||||
const rows: ResolvedRow[] = useMemo(
|
||||
() =>
|
||||
groups.map((group) => ({
|
||||
key: group.key,
|
||||
label: resolveLabel(group.key),
|
||||
amount: group.amount,
|
||||
count: group.count,
|
||||
category: group.key ? categoriesById.get(group.key) : undefined,
|
||||
account:
|
||||
dimension === 'account' && group.key
|
||||
? accountsById.get(group.key)
|
||||
: undefined,
|
||||
labelColor:
|
||||
dimension === 'label' && group.key
|
||||
? labelsById.get(group.key)?.color
|
||||
: undefined,
|
||||
})),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[groups, dimension, categoriesById, accountsById, labelsById, locale],
|
||||
);
|
||||
|
||||
// Time dimensions read most naturally newest-first in the list.
|
||||
const breakdownRows = useMemo(() => {
|
||||
if (!isTimeDimension(dimension)) {
|
||||
return rows;
|
||||
}
|
||||
return [...rows].sort((a, b) =>
|
||||
(b.key ?? '').localeCompare(a.key ?? ''),
|
||||
);
|
||||
}, [rows, dimension]);
|
||||
|
||||
// The chart shows time ascending; categorical dimensions show the top buckets.
|
||||
const chartData: AnalysisChartPoint[] = useMemo(() => {
|
||||
const points = rows.map((row) => ({
|
||||
key: row.key ?? '__null__',
|
||||
label: row.label,
|
||||
amount: row.amount,
|
||||
}));
|
||||
|
||||
if (isTimeDimension(dimension)) {
|
||||
return points.sort((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
return points.slice(0, 12);
|
||||
}, [rows, dimension]);
|
||||
|
||||
function handleDrill(row: ResolvedRow) {
|
||||
switch (dimension) {
|
||||
case 'category':
|
||||
setFilters((previous) => ({
|
||||
...previous,
|
||||
categoryIds: [row.key ?? 'uncategorized'],
|
||||
}));
|
||||
setDimension('month');
|
||||
break;
|
||||
case 'account':
|
||||
if (!row.key) {
|
||||
return;
|
||||
}
|
||||
setFilters((previous) => ({
|
||||
...previous,
|
||||
accountIds: previous.accountIds.includes(row.key!)
|
||||
? previous.accountIds
|
||||
: [...previous.accountIds, row.key!],
|
||||
}));
|
||||
setDimension('category');
|
||||
break;
|
||||
case 'label':
|
||||
if (!row.key) {
|
||||
return;
|
||||
}
|
||||
setFilters((previous) => ({
|
||||
...previous,
|
||||
labelIds: previous.labelIds.includes(row.key!)
|
||||
? previous.labelIds
|
||||
: [...previous.labelIds, row.key!],
|
||||
}));
|
||||
setDimension('category');
|
||||
break;
|
||||
case 'month':
|
||||
if (!row.key) {
|
||||
return;
|
||||
}
|
||||
setFilters((previous) => ({
|
||||
...previous,
|
||||
dateFrom: startOfMonth(parseISO(`${row.key}-01`)),
|
||||
dateTo: endOfMonth(parseISO(`${row.key}-01`)),
|
||||
}));
|
||||
setDimension('category');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const breakdownTitle = __('Breakdown by :dimension', {
|
||||
dimension: dimensionLabels[dimension].toLowerCase(),
|
||||
});
|
||||
|
||||
return (
|
||||
<AppSidebarLayout breadcrumbs={breadcrumbs}>
|
||||
<Head title={__('Analysis')} />
|
||||
|
||||
<div className="space-y-6 p-6">
|
||||
<HeadingSmall
|
||||
title={__('Analysis')}
|
||||
description={__(
|
||||
'Drill into your income and expenses across any dimension',
|
||||
)}
|
||||
/>
|
||||
|
||||
<TransactionFiltersComponent
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
categories={categories}
|
||||
labels={labels}
|
||||
accounts={accounts}
|
||||
isKeySet={true}
|
||||
hideSearch
|
||||
inlineCategoryLabel
|
||||
/>
|
||||
|
||||
<AnalysisSummaryCards
|
||||
summary={summary}
|
||||
currency={currency}
|
||||
loading={isLoading}
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{__('Group by')}
|
||||
</span>
|
||||
{ANALYSIS_DIMENSIONS.map((option) => (
|
||||
<Button
|
||||
key={option}
|
||||
size="sm"
|
||||
variant={
|
||||
dimension === option ? 'default' : 'outline'
|
||||
}
|
||||
onClick={() => setDimension(option)}
|
||||
className={cn(dimension === option && 'shadow-sm')}
|
||||
>
|
||||
{dimensionLabels[option]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnalysisChart
|
||||
title={breakdownTitle}
|
||||
data={chartData}
|
||||
currency={currency}
|
||||
loading={isLoading}
|
||||
scrollable={dimension === 'month'}
|
||||
/>
|
||||
|
||||
<AnalysisBreakdown
|
||||
title={breakdownTitle}
|
||||
rows={breakdownRows}
|
||||
currency={currency}
|
||||
loading={isLoading}
|
||||
onDrill={handleDrill}
|
||||
/>
|
||||
</div>
|
||||
</AppSidebarLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
import { index as accountsIndex } from '@/actions/App/Http/Controllers/AccountController';
|
||||
import { index as budgetsIndex } from '@/actions/App/Http/Controllers/BudgetController';
|
||||
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
|
||||
import { cashflow, dashboard } from '@/routes';
|
||||
import { analysis, cashflow, dashboard } from '@/routes';
|
||||
import { Features, NavItem } from '@/types';
|
||||
import {
|
||||
ChartColumnBig,
|
||||
CreditCard,
|
||||
LayoutGrid,
|
||||
PiggyBank,
|
||||
|
|
@ -18,6 +19,7 @@ const mobileLabels: Record<string, Record<string, string>> = {
|
|||
accounts: 'Accounts',
|
||||
transactions: 'Movements',
|
||||
budgets: 'Budget',
|
||||
analysis: 'Analysis',
|
||||
},
|
||||
es: {
|
||||
dashboard: 'Inicio',
|
||||
|
|
@ -25,6 +27,7 @@ const mobileLabels: Record<string, Record<string, string>> = {
|
|||
accounts: 'Cuentas',
|
||||
transactions: 'Movim.',
|
||||
budgets: 'Presup.',
|
||||
analysis: 'Análisis',
|
||||
},
|
||||
};
|
||||
|
||||
|
|
@ -75,6 +78,13 @@ export function getMainNavItems(features: Features, locale: string): NavItem[] {
|
|||
href: budgetsIndex(),
|
||||
icon: PiggyBank,
|
||||
},
|
||||
{
|
||||
type: 'nav-item',
|
||||
title: 'Analysis',
|
||||
mobileTitle: getMobileLabel('analysis', locale),
|
||||
href: analysis(),
|
||||
icon: ChartColumnBig,
|
||||
},
|
||||
);
|
||||
|
||||
return items;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
use App\Http\Controllers\AccountBalanceController;
|
||||
use App\Http\Controllers\Api\AccountController;
|
||||
use App\Http\Controllers\Api\AnalysisController;
|
||||
use App\Http\Controllers\Api\CashflowAnalyticsController;
|
||||
use App\Http\Controllers\Api\DashboardAnalyticsController;
|
||||
use App\Http\Controllers\Api\ImportDataController;
|
||||
|
|
@ -56,4 +57,7 @@ Route::middleware(['web', 'auth'])->group(function () {
|
|||
Route::get('trend', [CashflowAnalyticsController::class, 'trend']);
|
||||
Route::get('breakdown', [CashflowAnalyticsController::class, 'breakdown']);
|
||||
});
|
||||
|
||||
// Expense / income analysis
|
||||
Route::get('analysis', [AnalysisController::class, 'index'])->name('api.analysis.index');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Http\Controllers\AccountController;
|
||||
use App\Http\Controllers\AnalysisController;
|
||||
use App\Http\Controllers\BudgetController;
|
||||
use App\Http\Controllers\CashflowController;
|
||||
use App\Http\Controllers\DashboardController;
|
||||
|
|
@ -108,6 +109,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
|
|||
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
|
||||
Route::get('dashboard', DashboardController::class)->name('dashboard');
|
||||
Route::get('cashflow', CashflowController::class)->name('cashflow');
|
||||
Route::get('analysis', AnalysisController::class)->name('analysis');
|
||||
|
||||
Route::get('accounts', [AccountController::class, 'index'])->name('accounts.list');
|
||||
Route::get('accounts/{account}', [AccountController::class, 'show'])->name('accounts.show');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,200 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\Account;
|
||||
use App\Models\Category;
|
||||
use App\Models\ExchangeRate;
|
||||
use App\Models\Label;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
Http::fake();
|
||||
|
||||
$this->user = User::factory()->create(['currency_code' => 'USD']);
|
||||
$this->actingAs($this->user);
|
||||
|
||||
$this->account = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'currency_code' => 'USD',
|
||||
]);
|
||||
});
|
||||
|
||||
function makeTransaction(array $attributes = []): Transaction
|
||||
{
|
||||
return Transaction::factory()->create(array_merge([
|
||||
'user_id' => test()->user->id,
|
||||
'account_id' => test()->account->id,
|
||||
'currency_code' => 'USD',
|
||||
'transaction_date' => now(),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
test('analysis endpoint requires authentication', function () {
|
||||
auth()->logout();
|
||||
|
||||
$this->getJson('/api/analysis?group_by=category')->assertUnauthorized();
|
||||
});
|
||||
|
||||
test('analysis responses are not cached between users', function () {
|
||||
$this->getJson('/api/analysis?group_by=category')
|
||||
->assertOk()
|
||||
->assertHeader('Cache-Control', 'no-store, private');
|
||||
});
|
||||
|
||||
test('group_by is required and constrained', function () {
|
||||
$this->getJson('/api/analysis')->assertStatus(422);
|
||||
$this->getJson('/api/analysis?group_by=bogus')->assertStatus(422);
|
||||
});
|
||||
|
||||
test('summary splits income, expense, net and count by sign', function () {
|
||||
makeTransaction(['amount' => 100000]);
|
||||
makeTransaction(['amount' => 30000]);
|
||||
makeTransaction(['amount' => -40000]);
|
||||
|
||||
$this->getJson('/api/analysis?group_by=category')
|
||||
->assertOk()
|
||||
->assertJson([
|
||||
'summary' => [
|
||||
'income' => 130000,
|
||||
'expense' => 40000,
|
||||
'net' => 90000,
|
||||
'count' => 3,
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
test('groups by category sum signed amounts and sort by magnitude', function () {
|
||||
$food = Category::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => CategoryType::Expense,
|
||||
]);
|
||||
$rent = Category::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'type' => CategoryType::Expense,
|
||||
]);
|
||||
|
||||
makeTransaction(['category_id' => $food->id, 'amount' => -2000]);
|
||||
makeTransaction(['category_id' => $food->id, 'amount' => -3000]);
|
||||
makeTransaction(['category_id' => $rent->id, 'amount' => -90000]);
|
||||
|
||||
$groups = $this->getJson('/api/analysis?group_by=category')
|
||||
->assertOk()
|
||||
->json('groups');
|
||||
|
||||
expect($groups)->toHaveCount(2);
|
||||
expect($groups[0])->toMatchArray(['key' => $rent->id, 'amount' => -90000, 'count' => 1]);
|
||||
expect($groups[1])->toMatchArray(['key' => $food->id, 'amount' => -5000, 'count' => 2]);
|
||||
});
|
||||
|
||||
test('uncategorized transactions group under a null key', function () {
|
||||
makeTransaction(['category_id' => null, 'amount' => -1500]);
|
||||
|
||||
$groups = $this->getJson('/api/analysis?group_by=category')
|
||||
->assertOk()
|
||||
->json('groups');
|
||||
|
||||
expect($groups)->toHaveCount(1);
|
||||
expect($groups[0]['key'])->toBeNull();
|
||||
expect($groups[0]['amount'])->toBe(-1500);
|
||||
});
|
||||
|
||||
test('groups by month bucket transactions by year-month', function () {
|
||||
makeTransaction(['amount' => -1000, 'transaction_date' => '2026-01-15']);
|
||||
makeTransaction(['amount' => -2000, 'transaction_date' => '2026-01-20']);
|
||||
makeTransaction(['amount' => -5000, 'transaction_date' => '2026-02-10']);
|
||||
|
||||
$groups = collect($this->getJson('/api/analysis?group_by=month')
|
||||
->assertOk()
|
||||
->json('groups'))
|
||||
->keyBy('key');
|
||||
|
||||
expect($groups['2026-01']['amount'])->toBe(-3000);
|
||||
expect($groups['2026-01']['count'])->toBe(2);
|
||||
expect($groups['2026-02']['amount'])->toBe(-5000);
|
||||
});
|
||||
|
||||
test('groups by label count a transaction once per label and bucket unlabeled separately', function () {
|
||||
$trip = Label::factory()->create(['user_id' => $this->user->id]);
|
||||
$work = Label::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
$multi = makeTransaction(['amount' => -6000]);
|
||||
$multi->labels()->sync([$trip->id, $work->id]);
|
||||
|
||||
makeTransaction(['amount' => -1000]); // no label
|
||||
|
||||
$groups = collect($this->getJson('/api/analysis?group_by=label')
|
||||
->assertOk()
|
||||
->json('groups'))
|
||||
->keyBy(fn (array $group): string => $group['key'] ?? '__null__');
|
||||
|
||||
expect($groups[$trip->id]['amount'])->toBe(-6000);
|
||||
expect($groups[$work->id]['amount'])->toBe(-6000);
|
||||
expect($groups['__null__']['amount'])->toBe(-1000);
|
||||
});
|
||||
|
||||
test('filters restrict the analyzed transactions', function () {
|
||||
$keep = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
$drop = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
makeTransaction(['category_id' => $keep->id, 'amount' => -1000]);
|
||||
makeTransaction(['category_id' => $drop->id, 'amount' => -9000]);
|
||||
|
||||
$response = $this->getJson('/api/analysis?'.http_build_query([
|
||||
'group_by' => 'category',
|
||||
'category_ids' => $keep->id,
|
||||
]))->assertOk();
|
||||
|
||||
expect($response->json('summary.expense'))->toBe(1000);
|
||||
expect($response->json('groups'))->toHaveCount(1);
|
||||
expect($response->json('groups.0.key'))->toBe($keep->id);
|
||||
});
|
||||
|
||||
test('category and label filters combine with OR', function () {
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
$label = Label::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
makeTransaction(['category_id' => $category->id, 'amount' => -1000]);
|
||||
|
||||
$labelled = makeTransaction(['category_id' => null, 'amount' => -2000]);
|
||||
$labelled->labels()->sync([$label->id]);
|
||||
|
||||
makeTransaction(['category_id' => null, 'amount' => -9000]); // matches neither
|
||||
|
||||
$response = $this->getJson('/api/analysis?'.http_build_query([
|
||||
'group_by' => 'category',
|
||||
'category_ids' => $category->id,
|
||||
'label_ids' => $label->id,
|
||||
]))->assertOk();
|
||||
|
||||
expect($response->json('summary.expense'))->toBe(3000);
|
||||
expect($response->json('summary.count'))->toBe(2);
|
||||
});
|
||||
|
||||
test('foreign currency amounts are converted to the user currency', function () {
|
||||
$date = '2026-03-10';
|
||||
|
||||
$eurAccount = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'currency_code' => 'EUR',
|
||||
]);
|
||||
|
||||
ExchangeRate::factory()->create([
|
||||
'base_currency' => 'usd',
|
||||
'date' => $date,
|
||||
'rates' => ['eur' => 0.80],
|
||||
]);
|
||||
|
||||
// 8000 EUR cents / 0.80 = 10000 USD cents
|
||||
makeTransaction([
|
||||
'account_id' => $eurAccount->id,
|
||||
'currency_code' => 'EUR',
|
||||
'amount' => -8000,
|
||||
'transaction_date' => $date,
|
||||
]);
|
||||
|
||||
$this->getJson('/api/analysis?group_by=category')
|
||||
->assertOk()
|
||||
->assertJson(['summary' => ['expense' => 10000]]);
|
||||
});
|
||||
|
|
@ -191,6 +191,42 @@ test('filter by label', function () {
|
|||
);
|
||||
});
|
||||
|
||||
test('category and label filters combine with OR', function () {
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
$label = Label::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
// Matches by category only
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
|
||||
// Matches by label only (different category)
|
||||
$txWithLabel = Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'category_id' => null,
|
||||
]);
|
||||
$txWithLabel->labels()->attach($label->id);
|
||||
|
||||
// Matches neither
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'category_id' => null,
|
||||
]);
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index', [
|
||||
'category_ids' => $category->id,
|
||||
'label_ids' => $label->id,
|
||||
]));
|
||||
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 2)
|
||||
);
|
||||
});
|
||||
|
||||
test('search matches description', function () {
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
|
|
|
|||
Loading…
Reference in New Issue