diff --git a/app/Http/Controllers/AnalysisController.php b/app/Http/Controllers/AnalysisController.php new file mode 100644 index 00000000..25d437b3 --- /dev/null +++ b/app/Http/Controllers/AnalysisController.php @@ -0,0 +1,50 @@ +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, + ]); + } +} diff --git a/app/Http/Controllers/Api/AnalysisController.php b/app/Http/Controllers/Api/AnalysisController.php new file mode 100644 index 00000000..61a308cc --- /dev/null +++ b/app/Http/Controllers/Api/AnalysisController.php @@ -0,0 +1,150 @@ +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 $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 $converted + * @return Collection + */ + 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 $converted + * @return Collection + */ + 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 $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); + } +} diff --git a/app/Http/Requests/Api/AnalysisRequest.php b/app/Http/Requests/Api/AnalysisRequest.php new file mode 100644 index 00000000..d2b19289 --- /dev/null +++ b/app/Http/Requests/Api/AnalysisRequest.php @@ -0,0 +1,67 @@ +input($field); + if (is_string($value)) { + $this->merge([ + $field => array_filter(explode(',', $value)), + ]); + } + } + } + + /** @return array */ + 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 */ + 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); + } +} diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index eb99543f..983fc19c 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -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); diff --git a/resources/js/components/analysis/analysis-breakdown.tsx b/resources/js/components/analysis/analysis-breakdown.tsx new file mode 100644 index 00000000..e8c4b589 --- /dev/null +++ b/resources/js/components/analysis/analysis-breakdown.tsx @@ -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 ( + + + {title} + + + {loading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+ ) : rows.length === 0 ? ( +
+ {__('No transactions match these filters')} +
+ ) : ( +
+ {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 ( + + ); + })} +
+ )} + + + ); +} + +function RowIcon({ row }: { row: ResolvedRow }) { + if (row.category) { + return ( +
+ +
+ ); + } + + if (row.account) { + return ( +
+ +
+ ); + } + + if (row.labelColor) { + const colorClasses = getLabelColorClasses(row.labelColor); + return ( +
+ +
+ ); + } + + return null; +} diff --git a/resources/js/components/analysis/analysis-chart.tsx b/resources/js/components/analysis/analysis-chart.tsx new file mode 100644 index 00000000..05ca52f2 --- /dev/null +++ b/resources/js/components/analysis/analysis-chart.tsx @@ -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 ( +
+
{point.label}
+ +
+ ); +} + +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 ( + + + {title} + + + {loading ? ( +
+ ) : data.length === 0 ? ( +
+ {__('No transactions match these filters')} +
+ ) : ( +
+ + + + + + formatCompactNumber( + value / 100, + locale, + currency, + ) + } + /> + + + } + cursor={{ + fill: 'var(--color-muted)', + opacity: 0.3, + }} + /> + + {data.map((point) => ( + = 0 + ? cashflowIncomeColor + : cashflowExpenseColor + } + /> + ))} + + + +
+ )} + + + ); +} diff --git a/resources/js/components/analysis/analysis-summary-cards.tsx b/resources/js/components/analysis/analysis-summary-cards.tsx new file mode 100644 index 00000000..6de57ebd --- /dev/null +++ b/resources/js/components/analysis/analysis-summary-cards.tsx @@ -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 ( +
+ + + + + + + + + + = 0 + ? 'text-emerald-600 dark:text-emerald-400' + : 'text-rose-600 dark:text-rose-400', + )} + /> + + + + + {summary.count.toLocaleString()} + + +
+ ); +} + +function SummaryCard({ + label, + loading, + children, +}: { + label: string; + loading?: boolean; + children: React.ReactNode; +}) { + return ( + + + {label} + {loading ? ( +
+ ) : ( + children + )} + + + ); +} diff --git a/resources/js/components/transactions/transaction-filters.tsx b/resources/js/components/transactions/transaction-filters.tsx index defdaf29..15c8e69d 100644 --- a/resources/js/components/transactions/transaction-filters.tsx +++ b/resources/js/components/transactions/transaction-filters.tsx @@ -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 (
-
- setSearchText(e.target.value)} - className="min-w-0 flex-1 md:min-w-[350px]" - /> +
+ {!hideSearch && ( + setSearchText(e.target.value)} + className="min-w-0 flex-1 md:min-w-[350px]" + /> + )} + + {inlineCategoryLabel && ( + <> + + + + )} @@ -295,254 +314,37 @@ export function TransactionFilters({
-
- {__('Categories')} -
- - - - - - - - - - {__( - 'No category found.', - )} - - - handleCategoryToggle( - UNCATEGORIZED_CATEGORY_ID, - ) - } - > -
- -
-
-
- -
- {__( - 'Uncategorized', - )} -
-
- {categories.map( - (category) => { - const isSelected = - filters.categoryIds.includes( - category.id, - ); - const colorClasses = - getCategoryColorClasses( - category.color, - ); - return ( - - handleCategoryToggle( - category.id, - ) - } - > -
- -
-
-
- -
- - { - category.name - } - -
-
- ); - }, - )} -
-
-
-
+ {!inlineCategoryLabel && ( +
+ + {__('Categories')} + +
+ +
-
+ )} -
- {__('Labels')} -
- - - - - - - - - - {__( - 'No labels found.', - )} - - {labels.map((label) => { - const isSelected = - filters.labelIds.includes( - label.id, - ); - const colorClasses = - getLabelColorClasses( - label.color, - ); - return ( - - handleLabelToggle( - label.id, - ) - } - > -
- -
-
-
- -
- - { - label.name - } - -
-
- ); - })} -
-
-
-
+ {!inlineCategoryLabel && ( +
+ {__('Labels')} +
+ +
-
+ )} {!hideAccountFilter && (
@@ -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 ( + + + + + + + + + {__('No category found.')} + onToggle(UNCATEGORIZED_CATEGORY_ID)} + > +
+ +
+
+
+ +
+ {__('Uncategorized')} +
+
+ {categories.map((category) => { + const isSelected = selectedIds.includes( + category.id, + ); + const colorClasses = getCategoryColorClasses( + category.color, + ); + return ( + onToggle(category.id)} + > +
+ +
+
+
+ +
+ {category.name} +
+
+ ); + })} +
+
+
+
+ ); +} + +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 ( + + + + + + + + + {__('No labels found.')} + {labels.map((label) => { + const isSelected = selectedIds.includes(label.id); + const colorClasses = getLabelColorClasses( + label.color, + ); + return ( + onToggle(label.id)} + > +
+ +
+
+
+ +
+ {label.name} +
+
+ ); + })} +
+
+
+
+ ); +} + const UNCATEGORIZED_CATEGORY_ID = 'uncategorized'; diff --git a/resources/js/hooks/use-analysis-data.ts b/resources/js/hooks/use-analysis-data.ts new file mode 100644 index 00000000..881b08d0 --- /dev/null +++ b/resources/js/hooks/use-analysis-data.ts @@ -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(emptySummary); + const [groups, setGroups] = useState([]); + 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 }; +} diff --git a/resources/js/pages/analysis/index.tsx b/resources/js/pages/analysis/index.tsx new file mode 100644 index 00000000..742b2509 --- /dev/null +++ b/resources/js/pages/analysis/index.tsx @@ -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 = { + 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(emptyFilters); + const [dimension, setDimension] = useState('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 ( + + + +
+ + + + + + +
+ + {__('Group by')} + + {ANALYSIS_DIMENSIONS.map((option) => ( + + ))} +
+ + + + +
+
+ ); +} diff --git a/resources/js/providers/menu-item-provider.tsx b/resources/js/providers/menu-item-provider.tsx index 602db216..0e487d8f 100644 --- a/resources/js/providers/menu-item-provider.tsx +++ b/resources/js/providers/menu-item-provider.tsx @@ -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> = { accounts: 'Accounts', transactions: 'Movements', budgets: 'Budget', + analysis: 'Analysis', }, es: { dashboard: 'Inicio', @@ -25,6 +27,7 @@ const mobileLabels: Record> = { 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; diff --git a/routes/api.php b/routes/api.php index 81476136..09263b59 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); }); diff --git a/routes/web.php b/routes/web.php index 27641c48..1d1fba79 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ 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'); diff --git a/tests/Feature/AnalysisTest.php b/tests/Feature/AnalysisTest.php new file mode 100644 index 00000000..b3c483ce --- /dev/null +++ b/tests/Feature/AnalysisTest.php @@ -0,0 +1,200 @@ +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]]); +}); diff --git a/tests/Feature/TransactionFilterTest.php b/tests/Feature/TransactionFilterTest.php index d3bf6c16..26e47a8f 100644 --- a/tests/Feature/TransactionFilterTest.php +++ b/tests/Feature/TransactionFilterTest.php @@ -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,