feat: parent/child category tree (#474)
## Summary Adds nested categories (parent → child, up to **3 levels**) across the app. Children inherit their parent's type and cashflow direction, and every category selector now renders the hierarchy as an indented tree. Gated behind the `CategoryTree` Pennant flag (off by default). ## Backend - **Migration**: nullable self-referencing `parent_id`; uniqueness scoped per-parent via a `parent_unique_marker` virtual column (root names stay unique). New composite unique created before dropping the old one so the `user_id` FK keeps a supporting index. - **`CategoryTree` service**: descendant/ancestor resolution, depth & cycle checks, type cascade, subtree deletion. - **Validation**: depth limit, cycle prevention, inherited + locked child type. - **Delete strategies**: reparent (default), promote to root, or cascade (uncategorizes affected transactions). - **Transaction filter** expands a selected parent to its descendants. - **Cashflow Sankey & breakdown** roll up to top-level parents with click-to-drill (children + a parent "direct" node). - **Budgets** tracking a parent also count their children's transactions. - **Unified** the frontend category query behind `Category::forDisplay()` / `FRONTEND_COLUMNS` (8 call sites) so every selector receives the full Category shape, including `parent_id`. ## Frontend - New `category-tree.ts` helpers (build/flatten/descendants/path, tree-aware selection toggle + tri-state). - **Settings page**: indented tree, sortable by name/color/type (siblings sorted, hierarchy preserved), parent picker, delete-strategy dialog. - **Combobox** (transaction table cell + edit/create modal + parent picker): indented tree, search keeps matches with their ancestors. - **Transaction filter**: indented tree, tri-state cascading selection (parent ↔ children), themed checkboxes, selected branches float to top on open, ancestor-aware search. - **Categorizer palette** and **budget multi-select**: same indented tree + ancestor-aware search. - **Sankey**: click a parent node to drill into its children, with breadcrumb. - Pennant `CategoryTree` flag gates the parent UI. ## Tests - Pest: model/validation, delete strategies, filter expansion, cashflow rollup/drill, budget child inclusion. - Vitest: tree-aware selection logic. - All green; Pint / ESLint / Prettier clean. ## Rollout Flag is off by default — enable per user with: \`\`\` php artisan feature:enable "App\\Features\\CategoryTree" you@example.com \`\`\`
This commit is contained in:
parent
faccbc3c5a
commit
1cc10566a3
|
|
@ -6,26 +6,27 @@ use App\Enums\CategoryCashflowDirection;
|
|||
use App\Enums\CategoryType;
|
||||
use App\Models\Category;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class CreateDefaultCategories
|
||||
{
|
||||
/**
|
||||
* Create default categories for a newly registered user.
|
||||
* Create default categories for a newly registered user, nesting child
|
||||
* categories under their configured parent.
|
||||
*/
|
||||
public function handle(User $user): void
|
||||
{
|
||||
$locale = $user->locale ?? app()->getLocale();
|
||||
$defaultCategories = self::getDefaultCategories($locale);
|
||||
|
||||
$existingCategoryNames = $user->categories()
|
||||
$existingCategories = $user->categories()
|
||||
->whereIn('name', array_column($defaultCategories, 'name'))
|
||||
->pluck('name')
|
||||
->all();
|
||||
->pluck('id', 'name');
|
||||
|
||||
$now = now();
|
||||
$categories = collect($defaultCategories)
|
||||
->reject(fn (array $category): bool => in_array($category['name'], $existingCategoryNames, true))
|
||||
$pending = collect($defaultCategories)
|
||||
->reject(fn (array $category): bool => $existingCategories->has($category['name']))
|
||||
->map(fn (array $category): array => [
|
||||
...$category,
|
||||
'cashflow_direction' => $category['cashflow_direction'] ?? CategoryCashflowDirection::Hidden->value,
|
||||
|
|
@ -33,20 +34,39 @@ class CreateDefaultCategories
|
|||
'user_id' => $user->id,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
])
|
||||
->all();
|
||||
]);
|
||||
|
||||
if ($categories === []) {
|
||||
if ($pending->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
Category::query()->insert($categories);
|
||||
$idsByName = $existingCategories->merge($pending->pluck('id', 'name'));
|
||||
|
||||
[$children, $roots] = $pending->partition(fn (array $category): bool => isset($category['parent']));
|
||||
|
||||
if ($roots->isNotEmpty()) {
|
||||
Category::query()->insert(
|
||||
$roots->map(fn (array $category): array => Arr::except($category, 'parent'))->values()->all()
|
||||
);
|
||||
}
|
||||
|
||||
if ($children->isNotEmpty()) {
|
||||
Category::query()->insert(
|
||||
$children
|
||||
->map(fn (array $category): array => [
|
||||
...Arr::except($category, 'parent'),
|
||||
'parent_id' => $idsByName[$category['parent']] ?? null,
|
||||
])
|
||||
->values()
|
||||
->all()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default categories configuration for a given locale.
|
||||
*
|
||||
* @return array<int, array{name: string, icon: string, color: string, type: string, cashflow_direction?: string}>
|
||||
* @return array<int, array{name: string, icon: string, color: string, type: string, cashflow_direction?: string, parent?: string}>
|
||||
*/
|
||||
public static function getDefaultCategories(string $locale = 'en'): array
|
||||
{
|
||||
|
|
@ -58,6 +78,10 @@ class CreateDefaultCategories
|
|||
return array_map(function (array $category) use ($translations) {
|
||||
$category['name'] = $translations[$category['name']] ?? $category['name'];
|
||||
|
||||
if (isset($category['parent'])) {
|
||||
$category['parent'] = $translations[$category['parent']] ?? $category['parent'];
|
||||
}
|
||||
|
||||
return $category;
|
||||
}, $categories);
|
||||
}
|
||||
|
|
@ -66,9 +90,10 @@ class CreateDefaultCategories
|
|||
}
|
||||
|
||||
/**
|
||||
* Get the base (English) categories configuration.
|
||||
* Get the base (English) categories configuration. A `parent` entry nests
|
||||
* the category under the default category with that (English) name.
|
||||
*
|
||||
* @return array<int, array{name: string, icon: string, color: string, type: string, cashflow_direction?: string}>
|
||||
* @return array<int, array{name: string, icon: string, color: string, type: string, cashflow_direction?: string, parent?: string}>
|
||||
*/
|
||||
private static function getBaseCategories(): array
|
||||
{
|
||||
|
|
@ -81,30 +106,35 @@ class CreateDefaultCategories
|
|||
],
|
||||
[
|
||||
'name' => 'Cafes, restaurants, bars',
|
||||
'parent' => 'Food',
|
||||
'icon' => 'Wine',
|
||||
'color' => 'red',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Groceries',
|
||||
'parent' => 'Food',
|
||||
'icon' => 'ShoppingBasket',
|
||||
'color' => 'red',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Tobacco and alcohol',
|
||||
'parent' => 'Food',
|
||||
'icon' => 'Cigarette',
|
||||
'color' => 'red',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Other groceries',
|
||||
'parent' => 'Food',
|
||||
'icon' => 'ShoppingBasket',
|
||||
'color' => 'red',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Food delivery',
|
||||
'parent' => 'Food',
|
||||
'icon' => 'Pizza',
|
||||
'color' => 'red',
|
||||
'type' => 'expense',
|
||||
|
|
@ -117,42 +147,49 @@ class CreateDefaultCategories
|
|||
],
|
||||
[
|
||||
'name' => 'Electricity',
|
||||
'parent' => 'Utility services',
|
||||
'icon' => 'Bolt',
|
||||
'color' => 'orange',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Natural gas',
|
||||
'parent' => 'Utility services',
|
||||
'icon' => 'Flame',
|
||||
'color' => 'orange',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Rent and maintanence',
|
||||
'parent' => 'Utility services',
|
||||
'icon' => 'Wrench',
|
||||
'color' => 'orange',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Telephone, internet, TV, computer',
|
||||
'parent' => 'Utility services',
|
||||
'icon' => 'Wifi',
|
||||
'color' => 'orange',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Water',
|
||||
'parent' => 'Utility services',
|
||||
'icon' => 'Droplets',
|
||||
'color' => 'orange',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Other utility expenses',
|
||||
'parent' => 'Utility services',
|
||||
'icon' => 'Receipt',
|
||||
'color' => 'orange',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Household goods',
|
||||
'parent' => 'Utility services',
|
||||
'icon' => 'Home',
|
||||
'color' => 'orange',
|
||||
'type' => 'expense',
|
||||
|
|
@ -165,24 +202,28 @@ class CreateDefaultCategories
|
|||
],
|
||||
[
|
||||
'name' => 'Parking',
|
||||
'parent' => 'Transportation',
|
||||
'icon' => 'ParkingMeter',
|
||||
'color' => 'amber',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Fuel',
|
||||
'parent' => 'Transportation',
|
||||
'icon' => 'Fuel',
|
||||
'color' => 'amber',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Transportation expenses',
|
||||
'parent' => 'Transportation',
|
||||
'icon' => 'Ticket',
|
||||
'color' => 'amber',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Vehicle purchase, maintenance',
|
||||
'parent' => 'Transportation',
|
||||
'icon' => 'Car',
|
||||
'color' => 'amber',
|
||||
'type' => 'expense',
|
||||
|
|
@ -201,36 +242,42 @@ class CreateDefaultCategories
|
|||
],
|
||||
[
|
||||
'name' => 'Gifts',
|
||||
'parent' => 'Leisure activities, traveling',
|
||||
'icon' => 'Gift',
|
||||
'color' => 'violet',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Books, newspapers, magazines',
|
||||
'parent' => 'Leisure activities, traveling',
|
||||
'icon' => 'BookOpen',
|
||||
'color' => 'violet',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Accommodation, travel expenses',
|
||||
'parent' => 'Leisure activities, traveling',
|
||||
'icon' => 'Hotel',
|
||||
'color' => 'violet',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Sport and sports goods',
|
||||
'parent' => 'Leisure activities, traveling',
|
||||
'icon' => 'Dumbbell',
|
||||
'color' => 'violet',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Theatre, music, cinema',
|
||||
'parent' => 'Leisure activities, traveling',
|
||||
'icon' => 'Clapperboard',
|
||||
'color' => 'violet',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Hobbies and other leisure time activites',
|
||||
'parent' => 'Leisure activities, traveling',
|
||||
'icon' => 'Puzzle',
|
||||
'color' => 'violet',
|
||||
'type' => 'expense',
|
||||
|
|
@ -243,18 +290,21 @@ class CreateDefaultCategories
|
|||
],
|
||||
[
|
||||
'name' => 'Education and courses',
|
||||
'parent' => 'Education, health and beauty',
|
||||
'icon' => 'GraduationCap',
|
||||
'color' => 'rose',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Beauty, cosmetics',
|
||||
'parent' => 'Education, health and beauty',
|
||||
'icon' => 'Sparkles',
|
||||
'color' => 'rose',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Health and pharmaceuticals',
|
||||
'parent' => 'Education, health and beauty',
|
||||
'icon' => 'HeartPulse',
|
||||
'color' => 'rose',
|
||||
'type' => 'expense',
|
||||
|
|
@ -267,6 +317,7 @@ class CreateDefaultCategories
|
|||
],
|
||||
[
|
||||
'name' => 'Online services',
|
||||
'parent' => 'Online transactions',
|
||||
'icon' => 'Server',
|
||||
'color' => 'fuchsia',
|
||||
'type' => 'expense',
|
||||
|
|
@ -293,6 +344,7 @@ class CreateDefaultCategories
|
|||
],
|
||||
[
|
||||
'name' => 'Other investments',
|
||||
'parent' => 'Investments',
|
||||
'icon' => 'TrendingUp',
|
||||
'color' => 'lime',
|
||||
'type' => CategoryType::Investment->value,
|
||||
|
|
@ -336,6 +388,7 @@ class CreateDefaultCategories
|
|||
],
|
||||
[
|
||||
'name' => 'Lottery',
|
||||
'parent' => 'Gambling',
|
||||
'icon' => 'TicketPercent',
|
||||
'color' => 'purple',
|
||||
'type' => 'expense',
|
||||
|
|
@ -360,6 +413,7 @@ class CreateDefaultCategories
|
|||
],
|
||||
[
|
||||
'name' => 'Other personal transfers',
|
||||
'parent' => 'Personal transfers',
|
||||
'icon' => 'ArrowLeftRight',
|
||||
'color' => 'cyan',
|
||||
'type' => 'transfer',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum CategoryDeletionStrategy: string
|
||||
{
|
||||
case Reparent = 'reparent';
|
||||
case Promote = 'promote';
|
||||
case Cascade = 'cascade';
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
<?php
|
||||
|
||||
namespace App\Features;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Gates the parent/child category tree UI for gradual rollout. The backend
|
||||
* always understands nesting; this flag only controls whether users can create
|
||||
* and manage nested categories from the interface.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class CategoryTree
|
||||
{
|
||||
/**
|
||||
* Resolve the feature's initial value.
|
||||
*/
|
||||
public function resolve(?User $user): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -39,16 +39,18 @@ class CashflowAnalyticsController extends Controller
|
|||
$validated = $request->validate([
|
||||
'from' => 'required|date',
|
||||
'to' => 'required|date',
|
||||
'parent' => 'nullable|uuid',
|
||||
]);
|
||||
|
||||
$from = Carbon::parse($validated['from']);
|
||||
$to = Carbon::parse($validated['to']);
|
||||
$user = $request->user();
|
||||
$drillParentId = $validated['parent'] ?? null;
|
||||
|
||||
// Split by sign, not by category type: a single category can appear on
|
||||
// both sides when it has both incoming and outgoing transactions.
|
||||
$incomeCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '>');
|
||||
$expenseCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '<');
|
||||
$incomeCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '>', $drillParentId);
|
||||
$expenseCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '<', $drillParentId);
|
||||
|
||||
$totalIncome = $incomeCategories->sum('amount');
|
||||
$totalExpense = $expenseCategories->sum('amount');
|
||||
|
|
@ -114,16 +116,18 @@ class CashflowAnalyticsController extends Controller
|
|||
'from' => 'required|date',
|
||||
'to' => 'required|date',
|
||||
'type' => 'required|in:income,expense',
|
||||
'parent' => 'nullable|uuid',
|
||||
]);
|
||||
|
||||
$period = PeriodComparator::fromRequest($validated);
|
||||
$previousPeriod = $period->previous();
|
||||
$user = $request->user();
|
||||
$drillParentId = $validated['parent'] ?? null;
|
||||
|
||||
$categoryType = $validated['type'] === 'income' ? CategoryType::Income : CategoryType::Expense;
|
||||
|
||||
$current = $this->getCategoryBreakdown($user->id, $user->currency_code, $period->from, $period->to, $categoryType);
|
||||
$previous = $this->getCategoryBreakdown($user->id, $user->currency_code, $previousPeriod->from, $previousPeriod->to, $categoryType);
|
||||
$current = $this->getCategoryBreakdown($user->id, $user->currency_code, $period->from, $period->to, $categoryType, $drillParentId);
|
||||
$previous = $this->getCategoryBreakdown($user->id, $user->currency_code, $previousPeriod->from, $previousPeriod->to, $categoryType, $drillParentId);
|
||||
|
||||
$currentTotal = $current->sum('amount');
|
||||
$previousTotal = $previous->sum('amount');
|
||||
|
|
@ -138,6 +142,8 @@ class CashflowAnalyticsController extends Controller
|
|||
'amount' => $item['amount'],
|
||||
'percentage' => $currentTotal > 0 ? round(($item['amount'] / $currentTotal) * 100, 1) : 0,
|
||||
'previous_amount' => $previousAmount,
|
||||
'has_children' => $item['has_children'] ?? false,
|
||||
'is_direct' => $item['is_direct'] ?? false,
|
||||
];
|
||||
})->sortByDesc('amount')->values();
|
||||
|
||||
|
|
@ -219,7 +225,7 @@ class CashflowAnalyticsController extends Controller
|
|||
->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency)));
|
||||
}
|
||||
|
||||
private function getSankeyBreakdown(string $userId, string $userCurrency, Carbon $from, Carbon $to, string $operator): Collection
|
||||
private function getSankeyBreakdown(string $userId, string $userCurrency, Carbon $from, Carbon $to, string $operator, ?string $drillParentId = null): Collection
|
||||
{
|
||||
$isIncome = $operator === '>';
|
||||
$type = $isIncome ? CategoryType::Income : CategoryType::Expense;
|
||||
|
|
@ -284,7 +290,11 @@ class CashflowAnalyticsController extends Controller
|
|||
'amount' => $item['amount'],
|
||||
]);
|
||||
|
||||
$categorized = $regularCategories->concat($transferCategories)->values();
|
||||
$categorized = collect($this->rollUpByTree(
|
||||
$regularCategories->concat($transferCategories)->values()->all(),
|
||||
$userId,
|
||||
$drillParentId,
|
||||
));
|
||||
|
||||
$uncategorized = $transactions
|
||||
->filter(function (Transaction $transaction) use ($operator): bool {
|
||||
|
|
@ -293,7 +303,7 @@ class CashflowAnalyticsController extends Controller
|
|||
})
|
||||
->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency));
|
||||
|
||||
if ($uncategorized != 0) {
|
||||
if ($drillParentId === null && $uncategorized != 0) {
|
||||
$categorized->push([
|
||||
'category_id' => null,
|
||||
'category' => (new Category)->forceFill([
|
||||
|
|
@ -304,6 +314,8 @@ class CashflowAnalyticsController extends Controller
|
|||
'icon' => 'HelpCircle',
|
||||
]),
|
||||
'amount' => abs($uncategorized),
|
||||
'has_children' => false,
|
||||
'is_direct' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -368,7 +380,7 @@ class CashflowAnalyticsController extends Controller
|
|||
});
|
||||
}
|
||||
|
||||
private function getCategoryBreakdown(string $userId, string $userCurrency, Carbon $from, Carbon $to, CategoryType $type): Collection
|
||||
private function getCategoryBreakdown(string $userId, string $userCurrency, Carbon $from, Carbon $to, CategoryType $type, ?string $drillParentId = null): Collection
|
||||
{
|
||||
$transactions = Transaction::query()
|
||||
->where('transactions.user_id', $userId)
|
||||
|
|
@ -398,6 +410,8 @@ class CashflowAnalyticsController extends Controller
|
|||
'amount' => $item['amount'],
|
||||
]);
|
||||
|
||||
$categorized = collect($this->rollUpByTree($categorized->values()->all(), $userId, $drillParentId));
|
||||
|
||||
$uncategorized = $transactions
|
||||
->filter(function (Transaction $transaction) use ($type): bool {
|
||||
return $transaction->category_id === null
|
||||
|
|
@ -406,7 +420,7 @@ class CashflowAnalyticsController extends Controller
|
|||
->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency));
|
||||
|
||||
// Add uncategorized as a special category if there are any
|
||||
if ($uncategorized != 0) {
|
||||
if ($drillParentId === null && $uncategorized != 0) {
|
||||
$categorized->push([
|
||||
'category_id' => null,
|
||||
'category' => (new Category)->forceFill([
|
||||
|
|
@ -417,6 +431,8 @@ class CashflowAnalyticsController extends Controller
|
|||
'icon' => 'HelpCircle',
|
||||
]),
|
||||
'amount' => abs($uncategorized),
|
||||
'has_children' => false,
|
||||
'is_direct' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -486,4 +502,123 @@ class CashflowAnalyticsController extends Controller
|
|||
{
|
||||
return $type === CategoryType::Income ? $amount > 0 : $amount < 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll category amounts up the tree.
|
||||
*
|
||||
* With no drill target, every amount folds into its top-level ancestor.
|
||||
* When drilling into a parent, the parent's children become the nodes (each
|
||||
* rolled up over its own subtree) plus a "(direct)" node for transactions
|
||||
* sitting on the parent itself. Items outside the drilled subtree drop out.
|
||||
*
|
||||
* @param array<int, array{category_id: ?string, category: Category|null, amount: int}> $categorized
|
||||
* @return array<int, array{category_id: ?string, category: Category|null, amount: int, has_children: bool, is_direct: bool}>
|
||||
*/
|
||||
private function rollUpByTree(array $categorized, string $userId, ?string $drillParentId): array
|
||||
{
|
||||
$categories = Category::query()
|
||||
->where('user_id', $userId)
|
||||
->forDisplay()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$parentMap = $categories->mapWithKeys(fn (Category $category): array => [$category->id => $category->parent_id])->all();
|
||||
$childCounts = [];
|
||||
foreach ($parentMap as $parentId) {
|
||||
if ($parentId !== null) {
|
||||
$childCounts[$parentId] = ($childCounts[$parentId] ?? 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
$nodes = [];
|
||||
foreach ($categorized as $item) {
|
||||
$categoryId = $item['category_id'];
|
||||
|
||||
if ($categoryId === null || ! array_key_exists($categoryId, $parentMap)) {
|
||||
// Uncategorized only belongs in the top-level view.
|
||||
if ($drillParentId === null) {
|
||||
$key = 'uncategorized';
|
||||
$nodes[$key] ??= ['category_id' => null, 'category' => $item['category'], 'amount' => 0, 'has_children' => false, 'is_direct' => false];
|
||||
$nodes[$key]['amount'] += $item['amount'];
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$target = $this->displayNodeFor($categoryId, $parentMap, $drillParentId);
|
||||
|
||||
if ($target === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$displayCategory = $categories->get($target['id']);
|
||||
|
||||
if ($displayCategory === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($target['is_direct']) {
|
||||
$key = $target['id'].':direct';
|
||||
$category = (new Category)->forceFill([
|
||||
'id' => $displayCategory->id,
|
||||
'name' => $displayCategory->name.' ('.__('direct').')',
|
||||
'icon' => $displayCategory->icon,
|
||||
'color' => $displayCategory->color,
|
||||
'type' => $displayCategory->type,
|
||||
'cashflow_direction' => $displayCategory->cashflow_direction,
|
||||
'parent_id' => $displayCategory->parent_id,
|
||||
]);
|
||||
$nodes[$key] ??= ['category_id' => $displayCategory->id, 'category' => $category, 'amount' => 0, 'has_children' => false, 'is_direct' => true];
|
||||
$nodes[$key]['amount'] += $item['amount'];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$key = $target['id'];
|
||||
$nodes[$key] ??= [
|
||||
'category_id' => $displayCategory->id,
|
||||
'category' => $displayCategory,
|
||||
'amount' => 0,
|
||||
'has_children' => ($childCounts[$displayCategory->id] ?? 0) > 0,
|
||||
'is_direct' => false,
|
||||
];
|
||||
$nodes[$key]['amount'] += $item['amount'];
|
||||
}
|
||||
|
||||
return array_values($nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which node a category's amount should be attributed to.
|
||||
*
|
||||
* @param array<string, ?string> $parentMap
|
||||
* @return array{id: string, is_direct: bool}|null
|
||||
*/
|
||||
private function displayNodeFor(string $categoryId, array $parentMap, ?string $drillParentId): ?array
|
||||
{
|
||||
$chain = [];
|
||||
$current = $categoryId;
|
||||
$guard = 0;
|
||||
|
||||
while ($current !== null && $guard++ < Category::MAX_DEPTH + 1) {
|
||||
array_unshift($chain, $current);
|
||||
$current = $parentMap[$current] ?? null;
|
||||
}
|
||||
|
||||
if ($drillParentId === null) {
|
||||
return ['id' => $chain[0], 'is_direct' => false];
|
||||
}
|
||||
|
||||
$index = array_search($drillParentId, $chain, true);
|
||||
|
||||
if ($index === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($index === count($chain) - 1) {
|
||||
return ['id' => $drillParentId, 'is_direct' => true];
|
||||
}
|
||||
|
||||
return ['id' => $chain[$index + 1], 'is_direct' => false];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,14 +7,17 @@ use App\Enums\CategoryType;
|
|||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Account;
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Services\AccountMetricsService;
|
||||
use App\Services\BalanceLookup;
|
||||
use App\Services\CategoryTree;
|
||||
use App\Services\ExchangeRateService;
|
||||
use App\Services\LoanAmortizationService;
|
||||
use App\Services\PeriodComparator;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardAnalyticsController extends Controller
|
||||
|
|
@ -23,6 +26,7 @@ class DashboardAnalyticsController extends Controller
|
|||
private ExchangeRateService $exchangeRateService,
|
||||
private AccountMetricsService $accountMetricsService,
|
||||
private LoanAmortizationService $loanAmortizationService,
|
||||
private CategoryTree $tree,
|
||||
) {}
|
||||
|
||||
public function netWorth(Request $request)
|
||||
|
|
@ -456,9 +460,17 @@ class DashboardAnalyticsController extends Controller
|
|||
return response()->json($top);
|
||||
}
|
||||
|
||||
private function getCategorySpending(string $userId, Carbon $from, Carbon $to)
|
||||
/**
|
||||
* Spending per top-level category: child category amounts roll up into
|
||||
* their root ancestor so the dashboard only lists parents.
|
||||
*
|
||||
* @return Collection<int, array{category_id: string, amount: int, category: Category}>
|
||||
*/
|
||||
private function getCategorySpending(string $userId, Carbon $from, Carbon $to): Collection
|
||||
{
|
||||
return Transaction::query()
|
||||
$rootMap = $this->tree->rootAncestorMap($userId);
|
||||
|
||||
$rolledUp = Transaction::query()
|
||||
->where('transactions.user_id', $userId)
|
||||
->whereBetween('transactions.transaction_date', [$from, $to])
|
||||
->join('categories', function ($join) {
|
||||
|
|
@ -468,16 +480,23 @@ class DashboardAnalyticsController extends Controller
|
|||
})
|
||||
->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount'))
|
||||
->groupBy('transactions.category_id')
|
||||
->with('category')
|
||||
->get()
|
||||
->filter(fn ($item): bool => (int) $item->total_amount < 0)
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'category_id' => $item->category_id,
|
||||
'category' => $item->category,
|
||||
'amount' => (int) -$item->total_amount,
|
||||
];
|
||||
});
|
||||
->groupBy(fn ($item): string => $rootMap[$item->category_id] ?? $item->category_id)
|
||||
->map(fn (Collection $items, string $rootId): array => [
|
||||
'category_id' => $rootId,
|
||||
'amount' => (int) -$items->sum('total_amount'),
|
||||
])
|
||||
->filter(fn (array $item): bool => $item['amount'] > 0);
|
||||
|
||||
$categories = Category::query()
|
||||
->whereIn('id', $rolledUp->keys())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return $rolledUp
|
||||
->map(fn (array $item): array => [...$item, 'category' => $categories->get($item['category_id'])])
|
||||
->filter(fn (array $item): bool => $item['category'] !== null)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function calculateNetWorthAt(Carbon $date, string $userCurrency): int
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ class ImportDataController extends Controller
|
|||
->orderBy('name')
|
||||
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']),
|
||||
'categories' => $user->categories()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'icon', 'color']),
|
||||
->forDisplay()
|
||||
->get(),
|
||||
'banks' => $user->banks()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'logo']),
|
||||
|
|
|
|||
|
|
@ -85,8 +85,8 @@ class BudgetController extends Controller
|
|||
|
||||
$categories = Category::query()
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'icon', 'color']);
|
||||
->forDisplay()
|
||||
->get();
|
||||
|
||||
$accounts = Account::query()
|
||||
->where('user_id', $user->id)
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ class CashflowController extends Controller
|
|||
|
||||
$categories = Category::query()
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'type', 'icon', 'color', 'cashflow_direction']);
|
||||
->forDisplay()
|
||||
->get();
|
||||
|
||||
$accounts = Account::query()
|
||||
->where('user_id', $user->id)
|
||||
|
|
|
|||
|
|
@ -4,18 +4,24 @@ namespace App\Http\Controllers;
|
|||
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\Account;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Services\AccountMetricsService;
|
||||
use App\Services\CategoryTree;
|
||||
use App\Services\PeriodComparator;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __construct(private AccountMetricsService $accountMetricsService) {}
|
||||
public function __construct(
|
||||
private AccountMetricsService $accountMetricsService,
|
||||
private CategoryTree $tree,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): Response
|
||||
{
|
||||
|
|
@ -90,9 +96,17 @@ class DashboardController extends Controller
|
|||
];
|
||||
}
|
||||
|
||||
private function getCategorySpending(string $userId, Carbon $from, Carbon $to)
|
||||
/**
|
||||
* Spending per top-level category: child category amounts roll up into
|
||||
* their root ancestor so the dashboard only lists parents.
|
||||
*
|
||||
* @return Collection<int, array{category_id: string, amount: int, category: Category}>
|
||||
*/
|
||||
private function getCategorySpending(string $userId, Carbon $from, Carbon $to): Collection
|
||||
{
|
||||
return Transaction::query()
|
||||
$rootMap = $this->tree->rootAncestorMap($userId);
|
||||
|
||||
$rolledUp = Transaction::query()
|
||||
->where('transactions.user_id', $userId)
|
||||
->whereBetween('transactions.transaction_date', [$from, $to])
|
||||
->join('categories', function ($join) {
|
||||
|
|
@ -102,16 +116,23 @@ class DashboardController extends Controller
|
|||
})
|
||||
->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount'))
|
||||
->groupBy('transactions.category_id')
|
||||
->with('category')
|
||||
->get()
|
||||
->filter(fn ($item): bool => (int) $item->total_amount < 0)
|
||||
->map(function ($item) {
|
||||
return [
|
||||
'category_id' => $item->category_id,
|
||||
'category' => $item->category,
|
||||
'amount' => (int) -$item->total_amount,
|
||||
];
|
||||
});
|
||||
->groupBy(fn ($item): string => $rootMap[$item->category_id] ?? $item->category_id)
|
||||
->map(fn (Collection $items, string $rootId): array => [
|
||||
'category_id' => $rootId,
|
||||
'amount' => (int) -$items->sum('total_amount'),
|
||||
])
|
||||
->filter(fn (array $item): bool => $item['amount'] > 0);
|
||||
|
||||
$categories = Category::query()
|
||||
->whereIn('id', $rolledUp->keys())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
return $rolledUp
|
||||
->map(fn (array $item): array => [...$item, 'category' => $categories->get($item['category_id'])])
|
||||
->filter(fn (array $item): bool => $item['category'] !== null)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function calculateCashflowSummary(string $userId, Carbon $from, Carbon $to): array
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ class OnboardingController extends Controller
|
|||
|
||||
$categories = Category::query()
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'icon', 'color', 'type']);
|
||||
->forDisplay()
|
||||
->get();
|
||||
|
||||
$transactions = Transaction::query()
|
||||
->where('user_id', $user->id)
|
||||
|
|
|
|||
|
|
@ -2,21 +2,28 @@
|
|||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Enums\CategoryDeletionStrategy;
|
||||
use App\Features\CategoryTree as CategoryTreeFeature;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\DeleteCategoryRequest;
|
||||
use App\Http\Requests\Settings\StoreCategoryRequest;
|
||||
use App\Http\Requests\Settings\UpdateCategoryRequest;
|
||||
use App\Models\Category;
|
||||
use App\Services\CategoryTree;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
class CategoryController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function __construct(private readonly CategoryTree $tree = new CategoryTree) {}
|
||||
|
||||
/**
|
||||
* Show the user's categories settings page.
|
||||
*/
|
||||
|
|
@ -24,11 +31,12 @@ class CategoryController extends Controller
|
|||
{
|
||||
$categories = auth()->user()
|
||||
->categories()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'icon', 'color', 'type', 'cashflow_direction']);
|
||||
->forDisplay()
|
||||
->get();
|
||||
|
||||
return Inertia::render('settings/categories', [
|
||||
'categories' => $categories,
|
||||
'categoryTreeEnabled' => Feature::for(auth()->user())->active(CategoryTreeFeature::class),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -59,25 +67,49 @@ class CategoryController extends Controller
|
|||
$this->throwDuplicateCategoryNameValidationException($exception);
|
||||
}
|
||||
|
||||
$this->tree->syncDescendantTypes($category);
|
||||
|
||||
return to_route('categories.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete the specified category.
|
||||
* Soft delete the specified category, handling its children according to
|
||||
* the chosen strategy.
|
||||
*/
|
||||
public function destroy(Category $category): RedirectResponse
|
||||
public function destroy(DeleteCategoryRequest $request, Category $category): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $category);
|
||||
|
||||
$category->delete();
|
||||
match ($request->strategy()) {
|
||||
CategoryDeletionStrategy::Cascade => $this->tree->deleteSubtree($category),
|
||||
CategoryDeletionStrategy::Promote => $this->detachChildrenAndDelete($category, null),
|
||||
CategoryDeletionStrategy::Reparent => $this->detachChildrenAndDelete($category, $category->parent_id),
|
||||
};
|
||||
|
||||
return to_route('categories.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the category's direct children to a new parent, then soft delete it.
|
||||
*/
|
||||
private function detachChildrenAndDelete(Category $category, ?string $newParentId): void
|
||||
{
|
||||
try {
|
||||
$category->children()->update(['parent_id' => $newParentId]);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
throw ValidationException::withMessages([
|
||||
'strategy' => __('A category with the same name already exists at the destination level. Rename it first.'),
|
||||
]);
|
||||
}
|
||||
|
||||
$category->delete();
|
||||
}
|
||||
|
||||
private function throwDuplicateCategoryNameValidationException(UniqueConstraintViolationException $exception): never
|
||||
{
|
||||
if (! str_contains($exception->getMessage(), 'categories_user_id_name_unique')
|
||||
&& ! str_contains($exception->getMessage(), 'categories_user_id_name_active_unique')) {
|
||||
&& ! str_contains($exception->getMessage(), 'categories_user_id_name_active_unique')
|
||||
&& ! str_contains($exception->getMessage(), 'categories_user_id_parent_name_active_unique')) {
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,8 +72,8 @@ class TransactionController extends Controller
|
|||
|
||||
$categories = Category::query()
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'icon', 'color']);
|
||||
->forDisplay()
|
||||
->get();
|
||||
|
||||
$accounts = Account::query()
|
||||
->where('user_id', $user->id)
|
||||
|
|
@ -114,8 +114,8 @@ class TransactionController extends Controller
|
|||
|
||||
$categories = Category::query()
|
||||
->where('user_id', $user->id)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'icon', 'color', 'type']);
|
||||
->forDisplay()
|
||||
->get();
|
||||
|
||||
$accounts = Account::query()
|
||||
->where('user_id', $user->id)
|
||||
|
|
|
|||
|
|
@ -144,8 +144,8 @@ class HandleInertiaRequests extends Middleware
|
|||
return $data;
|
||||
}) : [],
|
||||
'categories' => fn () => $user ? $user->categories()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'icon', 'color']) : [],
|
||||
->forDisplay()
|
||||
->get() : [],
|
||||
'banks' => fn () => $user ? $user->banks()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'logo']) : [],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings\Concerns;
|
||||
|
||||
use App\Http\Requests\Concerns\ResolvesCategoryCashflowDirection;
|
||||
use App\Models\Category;
|
||||
use Illuminate\Contracts\Database\Query\Builder;
|
||||
|
||||
trait PreparesCategoryAttributes
|
||||
{
|
||||
use ResolvesCategoryCashflowDirection {
|
||||
prepareForValidation as resolveCashflowDirectionFromType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and cache the requested parent category (owned by the user).
|
||||
*/
|
||||
private ?Category $resolvedParent = null;
|
||||
|
||||
private bool $parentResolved = false;
|
||||
|
||||
/**
|
||||
* Derive type and cashflow direction.
|
||||
*
|
||||
* Children inherit both from their parent so a whole subtree stays on a
|
||||
* single type. Roots keep the existing type-driven cashflow rules.
|
||||
*/
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$parent = $this->parentCategory();
|
||||
|
||||
if ($parent !== null) {
|
||||
$this->merge([
|
||||
'type' => $parent->type->value,
|
||||
'cashflow_direction' => $parent->cashflow_direction->value,
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->resolveCashflowDirectionFromType();
|
||||
}
|
||||
|
||||
/**
|
||||
* The requested parent category, or null when creating/keeping a root.
|
||||
*/
|
||||
protected function parentCategory(): ?Category
|
||||
{
|
||||
if ($this->parentResolved) {
|
||||
return $this->resolvedParent;
|
||||
}
|
||||
|
||||
$this->parentResolved = true;
|
||||
|
||||
$parentId = $this->input('parent_id');
|
||||
|
||||
if (blank($parentId)) {
|
||||
return $this->resolvedParent = null;
|
||||
}
|
||||
|
||||
return $this->resolvedParent = Category::query()
|
||||
->where('user_id', auth()->id())
|
||||
->whereKey($parentId)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope a unique-name check to the user and the category's siblings (same
|
||||
* parent), treating a null parent as the root level.
|
||||
*/
|
||||
protected function scopeUniqueToSiblings(Builder $query): Builder
|
||||
{
|
||||
$query->where('user_id', auth()->id());
|
||||
|
||||
$parentId = $this->input('parent_id');
|
||||
|
||||
return blank($parentId)
|
||||
? $query->whereNull('parent_id')
|
||||
: $query->where('parent_id', $parentId);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Enums\CategoryDeletionStrategy;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class DeleteCategoryRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'strategy' => ['nullable', 'string', Rule::enum(CategoryDeletionStrategy::class)],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The chosen strategy for handling children, defaulting to lifting them up
|
||||
* to the deleted category's own parent.
|
||||
*/
|
||||
public function strategy(): CategoryDeletionStrategy
|
||||
{
|
||||
return CategoryDeletionStrategy::tryFrom((string) $this->input('strategy'))
|
||||
?? CategoryDeletionStrategy::Reparent;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,14 +5,15 @@ namespace App\Http\Requests\Settings;
|
|||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryColor;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Http\Requests\Concerns\ResolvesCategoryCashflowDirection;
|
||||
use App\Http\Requests\Settings\Concerns\PreparesCategoryAttributes;
|
||||
use App\Rules\ValidCategoryParent;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreCategoryRequest extends FormRequest
|
||||
{
|
||||
use ResolvesCategoryCashflowDirection;
|
||||
use PreparesCategoryAttributes;
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
|
|
@ -35,9 +36,15 @@ class StoreCategoryRequest extends FormRequest
|
|||
'string',
|
||||
'max:255',
|
||||
Rule::unique('categories', 'name')
|
||||
->where('user_id', auth()->id())
|
||||
->where(fn ($query) => $this->scopeUniqueToSiblings($query))
|
||||
->withoutTrashed(),
|
||||
],
|
||||
'parent_id' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'uuid',
|
||||
new ValidCategoryParent(null),
|
||||
],
|
||||
'icon' => ['required', 'string'],
|
||||
'color' => [
|
||||
'required',
|
||||
|
|
|
|||
|
|
@ -5,14 +5,16 @@ namespace App\Http\Requests\Settings;
|
|||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryColor;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Http\Requests\Concerns\ResolvesCategoryCashflowDirection;
|
||||
use App\Http\Requests\Settings\Concerns\PreparesCategoryAttributes;
|
||||
use App\Models\Category;
|
||||
use App\Rules\ValidCategoryParent;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateCategoryRequest extends FormRequest
|
||||
{
|
||||
use ResolvesCategoryCashflowDirection;
|
||||
use PreparesCategoryAttributes;
|
||||
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
|
|
@ -29,15 +31,23 @@ class UpdateCategoryRequest extends FormRequest
|
|||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$category = $this->route('category');
|
||||
|
||||
return [
|
||||
'name' => [
|
||||
'required',
|
||||
'string',
|
||||
'max:255',
|
||||
Rule::unique('categories', 'name')
|
||||
->where('user_id', auth()->id())
|
||||
->where(fn ($query) => $this->scopeUniqueToSiblings($query))
|
||||
->withoutTrashed()
|
||||
->ignore($this->route('category')),
|
||||
->ignore($category),
|
||||
],
|
||||
'parent_id' => [
|
||||
'nullable',
|
||||
'string',
|
||||
'uuid',
|
||||
new ValidCategoryParent($category instanceof Category ? $category : null),
|
||||
],
|
||||
'icon' => ['required', 'string'],
|
||||
'color' => [
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ namespace App\Models;
|
|||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryType;
|
||||
use Database\Factories\CategoryFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
|
@ -12,11 +13,42 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
/**
|
||||
* @property string $id
|
||||
* @property string $name
|
||||
* @property string $icon
|
||||
* @property string $color
|
||||
* @property CategoryType $type
|
||||
* @property CategoryCashflowDirection $cashflow_direction
|
||||
* @property string $user_id
|
||||
* @property string|null $parent_id
|
||||
*/
|
||||
class Category extends Model
|
||||
{
|
||||
/** @use HasFactory<CategoryFactory> */
|
||||
use HasFactory, HasUuids, SoftDeletes;
|
||||
|
||||
/**
|
||||
* Maximum allowed nesting depth (a root counts as level 1).
|
||||
*/
|
||||
public const int MAX_DEPTH = 3;
|
||||
|
||||
/**
|
||||
* Columns sent to the frontend. Always returns the full Category shape so
|
||||
* every selector receives the same object regardless of what it needs.
|
||||
*
|
||||
* @var list<string>
|
||||
*/
|
||||
private const array FRONTEND_COLUMNS = [
|
||||
'id',
|
||||
'name',
|
||||
'icon',
|
||||
'color',
|
||||
'type',
|
||||
'cashflow_direction',
|
||||
'parent_id',
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'name',
|
||||
'icon',
|
||||
|
|
@ -24,6 +56,7 @@ class Category extends Model
|
|||
'type',
|
||||
'cashflow_direction',
|
||||
'user_id',
|
||||
'parent_id',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
|
|
@ -45,4 +78,46 @@ class Category extends Model
|
|||
{
|
||||
return $this->hasMany(Transaction::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Category, $this> */
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Category::class, 'parent_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<Category, $this> */
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(Category::class, 'parent_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively eager-load the whole subtree (bounded by MAX_DEPTH).
|
||||
*
|
||||
* @return HasMany<Category, $this>
|
||||
*/
|
||||
public function descendants(): HasMany
|
||||
{
|
||||
return $this->children()->with('descendants');
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this category sits at the top of the tree.
|
||||
*/
|
||||
public function isRoot(): bool
|
||||
{
|
||||
return $this->parent_id === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scope for fetching categories to send to the frontend: the full, ordered
|
||||
* Category shape used by every category selector.
|
||||
*
|
||||
* @param Builder<Category> $query
|
||||
* @return Builder<Category>
|
||||
*/
|
||||
public function scopeForDisplay(Builder $query): Builder
|
||||
{
|
||||
return $query->orderBy('name')->select(self::FRONTEND_COLUMNS);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ use App\Enums\TransactionSource;
|
|||
use App\Events\TransactionCreated;
|
||||
use App\Events\TransactionDeleted;
|
||||
use App\Events\TransactionUpdated;
|
||||
use App\Services\CategoryTree;
|
||||
use Carbon\Carbon;
|
||||
use Database\Factories\TransactionFactory;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
|
@ -122,6 +123,14 @@ class Transaction extends Model
|
|||
$hasUncategorized = $ids->contains('uncategorized');
|
||||
$realIds = $ids->reject(fn ($id) => $id === 'uncategorized')->values()->all();
|
||||
|
||||
if ($realIds !== []) {
|
||||
$userId = $filters['user_id'] ?? Category::query()->whereIn('id', $realIds)->value('user_id');
|
||||
|
||||
if ($userId !== null) {
|
||||
$realIds = app(CategoryTree::class)->expand($userId, $realIds);
|
||||
}
|
||||
}
|
||||
|
||||
$query->where(function (Builder $q) use ($realIds, $hasUncategorized) {
|
||||
if (! empty($realIds)) {
|
||||
$q->whereIn('category_id', $realIds);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
<?php
|
||||
|
||||
namespace App\Rules;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Services\CategoryTree;
|
||||
use Closure;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Translation\PotentiallyTranslatedString;
|
||||
|
||||
class ValidCategoryParent implements ValidationRule
|
||||
{
|
||||
/**
|
||||
* @param Category|null $category The category being moved (null when creating).
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly ?Category $category,
|
||||
private readonly CategoryTree $tree = new CategoryTree,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param Closure(string): PotentiallyTranslatedString $fail
|
||||
*/
|
||||
public function validate(string $attribute, mixed $value, Closure $fail): void
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$parent = Category::query()
|
||||
->where('user_id', auth()->id())
|
||||
->whereKey($value)
|
||||
->first();
|
||||
|
||||
if ($parent === null) {
|
||||
$fail(__('The selected parent category is invalid.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->category !== null && $this->tree->wouldCreateCycle($this->category, $parent->id)) {
|
||||
$fail(__('A category cannot be nested under itself or one of its children.'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$subtreeDepth = $this->category !== null
|
||||
? $this->tree->subtreeDepth($this->category)
|
||||
: 1;
|
||||
|
||||
if ($this->tree->depth($parent) + $subtreeDepth > Category::MAX_DEPTH) {
|
||||
$fail(__('Categories can only be nested up to :max levels deep.', ['max' => Category::MAX_DEPTH]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -10,6 +10,8 @@ use Illuminate\Support\Facades\Log;
|
|||
|
||||
class BudgetTransactionService
|
||||
{
|
||||
public function __construct(private readonly CategoryTree $tree = new CategoryTree) {}
|
||||
|
||||
public function assignTransaction(Transaction $transaction): void
|
||||
{
|
||||
$userId = $transaction->user_id;
|
||||
|
|
@ -23,13 +25,20 @@ class BudgetTransactionService
|
|||
|
||||
$transactionLabelIds = $transaction->labels->pluck('id');
|
||||
|
||||
// A budget tracking a parent category also covers its children, so a
|
||||
// transaction matches a budget when any of its category's ancestors
|
||||
// (or itself) is attached to that budget.
|
||||
$categoryMatchIds = $transaction->category_id
|
||||
? $this->tree->ancestorAndSelfIds($userId, $transaction->category_id)
|
||||
: [];
|
||||
|
||||
// Find budget periods that potentially match this transaction.
|
||||
$budgetPeriods = BudgetPeriod::query()
|
||||
->whereHas('budget', function ($query) use ($transaction, $transactionLabelIds, $userId) {
|
||||
->whereHas('budget', function ($query) use ($categoryMatchIds, $transactionLabelIds, $userId) {
|
||||
$query->where('user_id', $userId)
|
||||
->where(function ($q) use ($transaction, $transactionLabelIds) {
|
||||
$q->whereHas('categories', function ($cq) use ($transaction) {
|
||||
$cq->whereKey($transaction->category_id);
|
||||
->where(function ($q) use ($categoryMatchIds, $transactionLabelIds) {
|
||||
$q->whereHas('categories', function ($cq) use ($categoryMatchIds) {
|
||||
$cq->whereIn('categories.id', $categoryMatchIds);
|
||||
})
|
||||
->orWhereHas('labels', function ($lq) use ($transactionLabelIds) {
|
||||
$lq->whereIn('labels.id', $transactionLabelIds);
|
||||
|
|
@ -47,8 +56,8 @@ class BudgetTransactionService
|
|||
foreach ($budgetPeriods as $period) {
|
||||
$budget = $period->budget;
|
||||
|
||||
$matchesCategory = $transaction->category_id
|
||||
&& $budget->categories->contains('id', $transaction->category_id);
|
||||
$matchesCategory = $categoryMatchIds !== []
|
||||
&& $budget->categories->pluck('id')->intersect($categoryMatchIds)->isNotEmpty();
|
||||
$matchesLabel = $budget->labels
|
||||
->pluck('id')
|
||||
->intersect($transactionLabelIds)
|
||||
|
|
@ -105,7 +114,8 @@ class BudgetTransactionService
|
|||
|
||||
$assignedCount = 0;
|
||||
|
||||
$categoryIds = $budget->categories->pluck('id');
|
||||
// Tracking a parent category also tracks its children's spending.
|
||||
$categoryIds = collect($this->tree->expand($budget->user_id, $budget->categories->pluck('id')->all()));
|
||||
$labelIds = $budget->labels->pluck('id');
|
||||
|
||||
Log::info('Building query for historical transactions', [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,219 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
|
||||
class CategoryTree
|
||||
{
|
||||
/**
|
||||
* Expand a set of category ids to also include every descendant id.
|
||||
*
|
||||
* Used when filtering transactions: selecting a parent must also match
|
||||
* transactions assigned to any of its children. Bounded by MAX_DEPTH, so
|
||||
* this resolves in at most a couple of cheap queries.
|
||||
*
|
||||
* @param array<int, string> $ids
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function expand(string $userId, array $ids): array
|
||||
{
|
||||
$ids = array_values(array_unique(array_filter($ids)));
|
||||
|
||||
if ($ids === []) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$collected = $ids;
|
||||
$frontier = $ids;
|
||||
|
||||
for ($level = 1; $level < Category::MAX_DEPTH; $level++) {
|
||||
$childIds = Category::query()
|
||||
->where('user_id', $userId)
|
||||
->whereIn('parent_id', $frontier)
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
$childIds = array_values(array_diff($childIds, $collected));
|
||||
|
||||
if ($childIds === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
$collected = array_merge($collected, $childIds);
|
||||
$frontier = $childIds;
|
||||
}
|
||||
|
||||
return $collected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the descendant ids of a single category (excluding itself).
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function descendantIds(Category $category): array
|
||||
{
|
||||
return array_values(array_diff(
|
||||
$this->expand($category->user_id, [$category->id]),
|
||||
[$category->id],
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* A category's id together with every ancestor id, walking up to the root.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function ancestorAndSelfIds(string $userId, string $categoryId): array
|
||||
{
|
||||
$ids = [$categoryId];
|
||||
$parentId = Category::query()
|
||||
->where('user_id', $userId)
|
||||
->whereKey($categoryId)
|
||||
->value('parent_id');
|
||||
|
||||
$guard = 0;
|
||||
|
||||
while ($parentId !== null && $guard++ < Category::MAX_DEPTH) {
|
||||
$ids[] = $parentId;
|
||||
$parentId = Category::query()
|
||||
->where('user_id', $userId)
|
||||
->whereKey($parentId)
|
||||
->value('parent_id');
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* The depth of a category, where a root category is level 1.
|
||||
*/
|
||||
public function depth(Category $category): int
|
||||
{
|
||||
$depth = 1;
|
||||
$parentId = $category->parent_id;
|
||||
|
||||
while ($parentId !== null && $depth <= Category::MAX_DEPTH) {
|
||||
$parentId = Category::query()
|
||||
->where('user_id', $category->user_id)
|
||||
->where('id', $parentId)
|
||||
->value('parent_id');
|
||||
|
||||
$depth++;
|
||||
}
|
||||
|
||||
return $depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* The number of levels contained in a category's subtree, including itself
|
||||
* (a leaf is 1).
|
||||
*/
|
||||
public function subtreeDepth(Category $category): int
|
||||
{
|
||||
$depth = 1;
|
||||
$frontier = [$category->id];
|
||||
|
||||
for ($level = 1; $level < Category::MAX_DEPTH; $level++) {
|
||||
$childIds = Category::query()
|
||||
->where('user_id', $category->user_id)
|
||||
->whereIn('parent_id', $frontier)
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
if ($childIds === []) {
|
||||
break;
|
||||
}
|
||||
|
||||
$depth++;
|
||||
$frontier = $childIds;
|
||||
}
|
||||
|
||||
return $depth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map every category id of a user to its top-level ancestor id (roots map
|
||||
* to themselves). Used to roll child amounts up into their parent.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function rootAncestorMap(string $userId): array
|
||||
{
|
||||
$parents = Category::query()
|
||||
->where('user_id', $userId)
|
||||
->pluck('parent_id', 'id')
|
||||
->all();
|
||||
|
||||
$map = [];
|
||||
|
||||
foreach ($parents as $id => $parentId) {
|
||||
$rootId = $id;
|
||||
$guard = 0;
|
||||
|
||||
while ($parentId !== null && array_key_exists($parentId, $parents) && $guard++ < Category::MAX_DEPTH) {
|
||||
$rootId = $parentId;
|
||||
$parentId = $parents[$parentId];
|
||||
}
|
||||
|
||||
$map[$id] = $rootId;
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether assigning $parentId as the parent of $category would create a
|
||||
* cycle (the parent is the category itself or one of its descendants).
|
||||
*/
|
||||
public function wouldCreateCycle(Category $category, string $parentId): bool
|
||||
{
|
||||
if ($parentId === $category->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($parentId, $this->descendantIds($category), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cascade a category's type and cashflow direction down to every
|
||||
* descendant, keeping the whole subtree on a single type.
|
||||
*/
|
||||
public function syncDescendantTypes(Category $category): void
|
||||
{
|
||||
$descendantIds = $this->descendantIds($category);
|
||||
|
||||
if ($descendantIds === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
Category::query()
|
||||
->where('user_id', $category->user_id)
|
||||
->whereIn('id', $descendantIds)
|
||||
->update([
|
||||
'type' => $category->type,
|
||||
'cashflow_direction' => $category->cashflow_direction,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete a category together with its whole subtree, uncategorizing
|
||||
* any transactions that pointed at the removed categories.
|
||||
*/
|
||||
public function deleteSubtree(Category $category): void
|
||||
{
|
||||
$ids = [$category->id, ...$this->descendantIds($category)];
|
||||
|
||||
Transaction::query()
|
||||
->where('user_id', $category->user_id)
|
||||
->whereIn('category_id', $ids)
|
||||
->update(['category_id' => null]);
|
||||
|
||||
Category::query()
|
||||
->where('user_id', $category->user_id)
|
||||
->whereIn('id', $ids)
|
||||
->delete();
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,20 @@ class CategoryFactory extends Factory
|
|||
'type' => fake()->randomElement(CategoryType::cases()),
|
||||
'cashflow_direction' => CategoryCashflowDirection::Hidden,
|
||||
'user_id' => User::factory(),
|
||||
'parent_id' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Nest the category under a parent, inheriting its owner and type.
|
||||
*/
|
||||
public function childOf(Category $parent): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'parent_id' => $parent->id,
|
||||
'user_id' => $parent->user_id,
|
||||
'type' => $parent->type,
|
||||
'cashflow_direction' => $parent->cashflow_direction,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('categories', function (Blueprint $table) {
|
||||
$table->uuid('parent_id')->nullable()->after('user_id');
|
||||
$table->foreign('parent_id')->references('id')->on('categories')->nullOnDelete();
|
||||
$table->index('parent_id');
|
||||
});
|
||||
|
||||
Schema::table('categories', function (Blueprint $table) {
|
||||
$table->string('parent_unique_marker')
|
||||
->nullable()
|
||||
->virtualAs("coalesce(`parent_id`, 'root')");
|
||||
});
|
||||
|
||||
// Create the new uniqueness index before dropping the old one so the
|
||||
// user_id foreign key always has a supporting (user_id-leading) index.
|
||||
Schema::table('categories', function (Blueprint $table) {
|
||||
$table->unique(
|
||||
['user_id', 'parent_unique_marker', 'name', 'active_unique_marker'],
|
||||
'categories_user_id_parent_name_active_unique'
|
||||
);
|
||||
});
|
||||
|
||||
Schema::table('categories', function (Blueprint $table) {
|
||||
$table->dropUnique('categories_user_id_name_active_unique');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('categories', function (Blueprint $table) {
|
||||
$table->unique(['user_id', 'name', 'active_unique_marker'], 'categories_user_id_name_active_unique');
|
||||
});
|
||||
|
||||
Schema::table('categories', function (Blueprint $table) {
|
||||
$table->dropUnique('categories_user_id_parent_name_active_unique');
|
||||
$table->dropColumn('parent_unique_marker');
|
||||
});
|
||||
|
||||
Schema::table('categories', function (Blueprint $table) {
|
||||
$table->dropForeign(['parent_id']);
|
||||
$table->dropIndex(['parent_id']);
|
||||
$table->dropColumn('parent_id');
|
||||
});
|
||||
}
|
||||
};
|
||||
17
lang/es.json
17
lang/es.json
|
|
@ -91,6 +91,7 @@
|
|||
"@sarahm_finance": "@sarahm_finance",
|
||||
"A Few Quick Questions": "Unas Preguntas Rápidas",
|
||||
"A Special Offer for You": "Una Oferta Especial para Ti",
|
||||
"A category with the same name already exists at the destination level. Rename it first.": "Ya existe una categoría con el mismo nombre en el nivel de destino. Renómbrala primero.",
|
||||
"A new verification link has been sent to the email address you provided during registration.": "Se ha enviado un nuevo enlace de verificación a la dirección de correo electrónico que proporcionaste durante el registro.",
|
||||
"A new verification link has been sent to your email address.": "Se ha enviado un nuevo enlace de verificación a tu dirección de correo electrónico.",
|
||||
"A new verification link has\\n been sent to your email\\n address.": "Se ha enviado un nuevo enlace de verificación a tu dirección de correo electrónico.",
|
||||
|
|
@ -160,6 +161,7 @@
|
|||
"All Set!": "¡Todo Listo!",
|
||||
"All Your Accounts": "Todas Tus Cuentas",
|
||||
"All associated accounts, transactions and balances will be permanently deleted.": "Todas las cuentas, transacciones y saldos asociados serán eliminados permanentemente.",
|
||||
"All categories": "Todas las categorías",
|
||||
"All data is stored on secure servers with encryption at rest": "Todos los datos se almacenan en servidores seguros con encriptación en reposo",
|
||||
"All fees are non-refundable except as required by law or explicitly stated otherwise": "Todas las tarifas no son reembolsables excepto según lo requiera la ley o se indique explícitamente de otra manera",
|
||||
"All fees are non-refundable except as\\n required by law or explicitly stated\\n otherwise": "Todas las tarifas no son reembolsables excepto cuando lo exija la ley o se indique explícitamente lo contrario",
|
||||
|
|
@ -205,6 +207,7 @@
|
|||
"Applying rule…": "Aplicando regla…",
|
||||
"Applying rule… :processed of :total processed": "Aplicando regla… :processed de :total procesadas",
|
||||
"Are you overspending? Know exactly where you stand.": "¿Estás gastando de más? Conoce exactamente dónde estás parado.",
|
||||
"Are you sure you want to delete": "¿Estás seguro de que deseas eliminar",
|
||||
"Are you sure you want to delete \"": "¿Estás seguro de que deseas eliminar \"",
|
||||
"Are you sure you want to delete this balance record? This action cannot be undone.": "¿Estás seguro de que deseas eliminar este registro de balance? Esta acción no se puede deshacer.",
|
||||
"Are you sure you want to delete this owed amount record? This action cannot be undone.": "¿Estás seguro de que deseas eliminar este registro de monto adeudado? Esta acción no se puede deshacer.",
|
||||
|
|
@ -311,7 +314,6 @@
|
|||
"Cashflow income vs expenses bar chart": "Gráfico de barras de ingresos frente a gastos",
|
||||
"Cashflow periods and analysis will start on this day.": "Los períodos y análisis de flujo de caja empezarán este día.",
|
||||
"Cashflow visualization": "Visualización del flujo de caja",
|
||||
"Categories": "Categorías",
|
||||
"Categories below 5% of total": "Categorías por debajo del 5% del total",
|
||||
"Categories settings": "Configuración de categorías",
|
||||
"Categorize": "Categorizar",
|
||||
|
|
@ -330,6 +332,7 @@
|
|||
"Check that everything looks correct and click \"Import\". Your transactions will be encrypted and stored securely.": "Verifica que todo se vea correcto y haz clic en \"Importar\". Tus transacciones serán encriptadas y almacenadas de forma segura.",
|
||||
"Check your inbox — we've sent you an email.": "Revisa tu bandeja de entrada — te hemos enviado un correo.",
|
||||
"Checking": "Cuenta Corriente",
|
||||
"Child categories inherit their parent’s type and cashflow settings.": "Las categorías hijas heredan el tipo y la configuración de flujo de caja de su categoría padre.",
|
||||
"Chilean Peso": "Peso chileno",
|
||||
"Chinese Yuan": "Yuan chino",
|
||||
"Choose Sankey chart visibility": "Elige la visibilidad en el gráfico Sankey",
|
||||
|
|
@ -499,6 +502,7 @@
|
|||
"Delete balance": "Eliminar balance",
|
||||
"Delete budget": "Eliminar presupuesto",
|
||||
"Delete owed amount": "Eliminar monto adeudado",
|
||||
"Delete this category and all of its children": "Eliminar esta categoría y todas sus categorías hijas",
|
||||
"Delete your account and all of its resources": "Elimina tu cuenta y todos sus recursos",
|
||||
"Deleting...": "Eliminando...",
|
||||
"Demo encryption password:": "Contraseña de encriptación de demo:",
|
||||
|
|
@ -780,6 +784,7 @@
|
|||
"Indian Rupee": "Rupia india",
|
||||
"Information about how you use our service, including access times and features used": "Información sobre cómo usas nuestro servicio, incluyendo horarios de acceso y funciones utilizadas",
|
||||
"Information about how you use our service,\\n including access times and features used": "Información sobre cómo usas nuestro servicio, incluyendo tiempos de acceso y funciones utilizadas",
|
||||
"Inherited from parent": "Heredado de la categoría padre",
|
||||
"Install App": "Instalar App",
|
||||
"Install Whisper Money": "Instalar Whisper Money",
|
||||
"Instant Application": "Aplicación Instantánea",
|
||||
|
|
@ -818,7 +823,6 @@
|
|||
"Key Storage": "Almacenamiento de Clave",
|
||||
"Label (Optional)": "Etiqueta (Opcional)",
|
||||
"Label name": "Nombre de etiqueta",
|
||||
"Labels": "Etiquetas",
|
||||
"Labels settings": "Configuración de etiquetas",
|
||||
"Land": "Terreno",
|
||||
"Language": "Idioma",
|
||||
|
|
@ -876,6 +880,7 @@
|
|||
"Main Checking": "Cuenta Corriente Principal",
|
||||
"Maintain and promptly update your account information": "Mantén y actualiza prontamente tu información de cuenta",
|
||||
"Maintain and promptly update your account\\n information": "Mantén y actualiza puntualmente la información de tu cuenta",
|
||||
"Make child categories top level": "Convertir las categorías hijas en categorías principales",
|
||||
"Making your money talk...": "Haciendo que tu dinero hable...",
|
||||
"Manage Plan": "Gestionar Plan",
|
||||
"Manage Subscription": "Gestionar Suscripción",
|
||||
|
|
@ -931,6 +936,8 @@
|
|||
"Mortgages and loans": "Hipotecas y préstamos",
|
||||
"Most Popular": "Más Popular",
|
||||
"Move Up — Share Your Personal Link": "Sube puestos — Comparte Tu Enlace Personal",
|
||||
"Move child categories to the top level": "Mover las categorías hijas al nivel principal",
|
||||
"Move child categories up to the parent": "Mover las categorías hijas a la categoría padre",
|
||||
"Move up 10 spots per referral": "Avanza 10 puestos por referido",
|
||||
"Movie Night": "Noche de Cine",
|
||||
"Moving money between accounts. It does not count in expenses or income charts.": "Mover dinero entre cuentas. No cuenta en los gráficos de gastos o ingresos.",
|
||||
|
|
@ -998,6 +1005,7 @@
|
|||
"No worries! Your encryption key is automatically generated and stored securely on your device. You don't need to remember anything - I've made it as simple as possible.": "¡No te preocupes! Tu clave de encriptación se genera automáticamente y se almacena de forma segura en tu dispositivo. No necesitas recordar nada, lo he hecho lo más simple posible.",
|
||||
"None": "Ninguno",
|
||||
"None (Remove)": "Ninguno (Eliminar)",
|
||||
"None (top level)": "Ninguna (nivel principal)",
|
||||
"Not sure how to import transactions?": "¿No sabes cómo importar transacciones?",
|
||||
"Notes": "Notas",
|
||||
"Notifications": "Notificaciones",
|
||||
|
|
@ -1034,6 +1042,7 @@
|
|||
"Owed amount evolution": "Evolución del monto adeudado",
|
||||
"Page": "Página",
|
||||
"Paraguayan Guarani": "Guaraní paraguayo",
|
||||
"Parent category": "Categoría padre",
|
||||
"Password": "Contraseña",
|
||||
"Password changes are disabled on the demo account.": "Los cambios de contraseña están deshabilitados en la cuenta demo.",
|
||||
"Password changes are disabled on the demo\\n account.": "Los cambios de contraseña están desactivados en la cuenta de demostración.",
|
||||
|
|
@ -1410,6 +1419,7 @@
|
|||
"This account tracks balance changes over time instead of individual transactions.": "Esta cuenta rastrea cambios de balance a lo largo del tiempo en lugar de transacciones individuales.",
|
||||
"This account type is for balance tracking only and doesn't support transactions.": "Este tipo de cuenta es solo para rastreo de balance y no soporta transacciones.",
|
||||
"This account type is for balance tracking only and\\n doesn't support transactions.": "Este tipo de cuenta es solo para seguimiento de saldo y no admite transacciones.",
|
||||
"This action cannot be undone.": "Esta acción no se puede deshacer.",
|
||||
"This action cannot be undone. To confirm, type": "Esta acción no se puede deshacer. Para confirmar, escribe",
|
||||
"This action is irreversible. All transactions in this account will also be permanently deleted.": "Esta acción es irreversible. Todas las transacciones en esta cuenta también serán eliminadas permanentemente.",
|
||||
"This code won't last forever, but more importantly, your support means the world to me. As a solo founder, every subscriber helps me continue building something I'm passionate about.": "Este código no durará para siempre, pero lo más importante es que tu apoyo significa mucho para mí. Como fundador en solitario, cada suscriptor me ayuda a seguir construyendo algo que me apasiona.",
|
||||
|
|
@ -1489,6 +1499,7 @@
|
|||
"Transaction\\n details, budgets, categories, and other\\n financial information you manually enter\\n into the application": "Detalles de transacciones, presupuestos, categorías y otra información financiera que introduces manualmente en la aplicación",
|
||||
"Transactions": "Transacciones",
|
||||
"Transactions + Balance": "Transacciones + Balance",
|
||||
"Transactions in the deleted categories will become uncategorized.": "Las transacciones de las categorías eliminadas quedarán sin categoría.",
|
||||
"Transactions in this category will not be counted in top expenses or income. Transfer categories are mainly used for transactions between accounts.": "Las transacciones en esta categoría no se contarán entre los principales gastos o ingresos. Las categorías de transferencia se usan principalmente para transacciones entre cuentas.",
|
||||
"Transactions in this category will\\n not be counted in top expenses or\\n income. Transfer categories are\\n mainly used for transactions between\\n accounts.": "Las transacciones en esta categoría no se contarán entre los principales gastos o ingresos. Las categorías de transferencia se usan principalmente para transacciones entre cuentas.",
|
||||
"Transfer": "Transferencia",
|
||||
|
|
@ -1669,6 +1680,7 @@
|
|||
"What happens after you confirm?": "¿Qué pasa después de confirmar?",
|
||||
"What is Whisper Money?": "¿Qué es Whisper Money?",
|
||||
"What people are saying": "Lo que la gente dice",
|
||||
"What should happen to its child categories?": "¿Qué debería pasar con sus categorías hijas?",
|
||||
"What you get": "Lo que obtienes",
|
||||
"What's more, even if I wanted to use it, I couldn't, since you're the only one who has the key to read the data.": "Es más, aunque quisiera usarlos, no podría, ya que tú eres el único que tiene la clave para leer los datos.",
|
||||
"When I started building Whisper Money, I was frustrated with finance apps that wanted to mine my data or use AI to analyze my spending. I just wanted a simple, private way to track my finances - and I figured others might too.": "Cuando empecé a construir Whisper Money, estaba frustrado con las apps de finanzas que querían minar mis datos o usar IA para analizar mis gastos. Solo quería una forma simple y privada de rastrear mis finanzas - y pensé que otros también.",
|
||||
|
|
@ -1855,6 +1867,7 @@
|
|||
"category(ies) total.": "categoría(s) en total.",
|
||||
"contains": "contiene",
|
||||
"cyan": "cian",
|
||||
"direct": "directo",
|
||||
"e.g. 360 for a 30-year mortgage": "ej. 360 para una hipoteca a 30 años",
|
||||
"e.g., Monthly Budget": "ej., Presupuesto Mensual",
|
||||
"e.g., Padel Budget": "ej., Presupuesto de Pádel",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { buildCategoryTree, flattenCategoryTree } from '@/lib/category-tree';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SharedData } from '@/types';
|
||||
import {
|
||||
|
|
@ -231,7 +232,9 @@ export function CreateBudgetDialog({
|
|||
</UILabel>
|
||||
<MultiSelect
|
||||
id="categories"
|
||||
options={allCategories.map((category) => {
|
||||
options={flattenCategoryTree(
|
||||
buildCategoryTree(allCategories),
|
||||
).map((category) => {
|
||||
const colorClasses =
|
||||
getCategoryColorClasses(
|
||||
category.color,
|
||||
|
|
@ -243,6 +246,8 @@ export function CreateBudgetDialog({
|
|||
return {
|
||||
value: category.id,
|
||||
label: category.name,
|
||||
depth: category.depth,
|
||||
parentValue: category.parent_id,
|
||||
icon: IconComponent ? (
|
||||
<IconComponent className="h-3 w-3 opacity-80" />
|
||||
) : undefined,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { store } from '@/actions/App/Http/Controllers/Settings/CategoryController';
|
||||
import { CategoryCashflowDirectionFields } from '@/components/categories/category-cashflow-direction-fields';
|
||||
import { ParentCategoryField } from '@/components/categories/parent-category-field';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -27,6 +28,7 @@ import {
|
|||
CATEGORY_TYPES,
|
||||
getCategoryColorClasses,
|
||||
getCategoryTypeLabel,
|
||||
type Category,
|
||||
type CategoryType,
|
||||
} from '@/types/category';
|
||||
import { __ } from '@/utils/i18n';
|
||||
|
|
@ -35,12 +37,15 @@ import * as Icons from 'lucide-react';
|
|||
import { useState } from 'react';
|
||||
|
||||
export function CreateCategoryDialog({
|
||||
categories,
|
||||
onSuccess,
|
||||
}: {
|
||||
categories: Category[];
|
||||
onSuccess?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState<CategoryType | ''>('');
|
||||
const [parent, setParent] = useState<Category | null>(null);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
|
|
@ -78,6 +83,18 @@ export function CreateCategoryDialog({
|
|||
<InputError message={errors.name} />
|
||||
</div>
|
||||
|
||||
<ParentCategoryField
|
||||
categories={categories}
|
||||
value={parent?.id ?? null}
|
||||
onChange={(next) => {
|
||||
setParent(next);
|
||||
if (next) {
|
||||
setSelectedType(next.type);
|
||||
}
|
||||
}}
|
||||
error={errors.parent_id}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="icon">{__('Icon')}</Label>
|
||||
<Select name="icon" required>
|
||||
|
|
@ -140,24 +157,51 @@ export function CreateCategoryDialog({
|
|||
<InputError message={errors.color} />
|
||||
</div>
|
||||
|
||||
{parent ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">{__('Type')}</Label>
|
||||
<Label>{__('Type')}</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="type"
|
||||
value={parent.type}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getCategoryTypeLabel(parent.type)}
|
||||
{' · '}
|
||||
{__('Inherited from parent')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">
|
||||
{__('Type')}
|
||||
</Label>
|
||||
<Select
|
||||
name="type"
|
||||
required
|
||||
onValueChange={(value) =>
|
||||
setSelectedType(value as CategoryType)
|
||||
setSelectedType(
|
||||
value as CategoryType,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={__('Select a type')}
|
||||
placeholder={__(
|
||||
'Select a type',
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{getCategoryTypeLabel(type)}
|
||||
<SelectItem
|
||||
key={type}
|
||||
value={type}
|
||||
>
|
||||
{getCategoryTypeLabel(
|
||||
type,
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -169,6 +213,8 @@ export function CreateCategoryDialog({
|
|||
selectedType={selectedType}
|
||||
defaultValue="hidden"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -8,12 +8,18 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { type Category } from '@/types/category';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Form } from '@inertiajs/react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
type DeletionStrategy = 'reparent' | 'promote' | 'cascade';
|
||||
|
||||
interface DeleteCategoryDialogProps {
|
||||
category: Category;
|
||||
categories: Category[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
|
|
@ -21,21 +27,40 @@ interface DeleteCategoryDialogProps {
|
|||
|
||||
export function DeleteCategoryDialog({
|
||||
category,
|
||||
categories,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
}: DeleteCategoryDialogProps) {
|
||||
const hasChildren = useMemo(
|
||||
() => categories.some((c) => c.parent_id === category.id),
|
||||
[categories, category.id],
|
||||
);
|
||||
const isRoot = category.parent_id === null;
|
||||
const [strategy, setStrategy] = useState<DeletionStrategy>('reparent');
|
||||
|
||||
const options: { value: DeletionStrategy; label: string }[] = [
|
||||
{
|
||||
value: 'reparent',
|
||||
label: isRoot
|
||||
? __('Move child categories to the top level')
|
||||
: __('Move child categories up to the parent'),
|
||||
},
|
||||
{ value: 'promote', label: __('Make child categories top level') },
|
||||
{
|
||||
value: 'cascade',
|
||||
label: __('Delete this category and all of its children'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{__('Delete Category')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__('Are you sure you want to delete "')}
|
||||
{category.name}
|
||||
{__(
|
||||
'"? This\n action cannot be undone.',
|
||||
)}
|
||||
{__('Are you sure you want to delete')} “{category.name}
|
||||
”? {__('This action cannot be undone.')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form
|
||||
|
|
@ -45,7 +70,54 @@ export function DeleteCategoryDialog({
|
|||
onSuccess?.();
|
||||
}}
|
||||
>
|
||||
{({ processing }) => (
|
||||
{({ processing, errors }) => (
|
||||
<div className="space-y-4">
|
||||
{hasChildren && (
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="hidden"
|
||||
name="strategy"
|
||||
value={strategy}
|
||||
/>
|
||||
<Label>
|
||||
{__(
|
||||
'What should happen to its child categories?',
|
||||
)}
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={strategy}
|
||||
onValueChange={(value) =>
|
||||
setStrategy(
|
||||
value as DeletionStrategy,
|
||||
)
|
||||
}
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Label
|
||||
key={option.value}
|
||||
className="flex items-center gap-2 text-sm font-normal"
|
||||
>
|
||||
<RadioGroupItem
|
||||
value={option.value}
|
||||
/>
|
||||
{option.label}
|
||||
</Label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
{strategy === 'cascade' && (
|
||||
<p className="text-sm text-red-500">
|
||||
{__(
|
||||
'Transactions in the deleted categories will become uncategorized.',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{errors.strategy && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.strategy}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -60,9 +132,12 @@ export function DeleteCategoryDialog({
|
|||
variant="destructive"
|
||||
disabled={processing}
|
||||
>
|
||||
{processing ? 'Deleting...' : 'Delete'}
|
||||
{processing
|
||||
? __('Deleting...')
|
||||
: __('Delete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { update } from '@/actions/App/Http/Controllers/Settings/CategoryController';
|
||||
import { CategoryCashflowDirectionFields } from '@/components/categories/category-cashflow-direction-fields';
|
||||
import { ParentCategoryField } from '@/components/categories/parent-category-field';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
|
@ -35,6 +36,7 @@ import { useState } from 'react';
|
|||
|
||||
interface EditCategoryDialogProps {
|
||||
category: Category;
|
||||
categories: Category[];
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess?: () => void;
|
||||
|
|
@ -42,6 +44,7 @@ interface EditCategoryDialogProps {
|
|||
|
||||
export function EditCategoryDialog({
|
||||
category,
|
||||
categories,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
|
|
@ -49,6 +52,11 @@ export function EditCategoryDialog({
|
|||
const [selectedType, setSelectedType] = useState<CategoryType>(
|
||||
category.type,
|
||||
);
|
||||
const [parent, setParent] = useState<Category | null>(
|
||||
category.parent_id
|
||||
? (categories.find((c) => c.id === category.parent_id) ?? null)
|
||||
: null,
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
|
|
@ -82,6 +90,19 @@ export function EditCategoryDialog({
|
|||
<InputError message={errors.name} />
|
||||
</div>
|
||||
|
||||
<ParentCategoryField
|
||||
categories={categories}
|
||||
value={parent?.id ?? null}
|
||||
excludeId={category.id}
|
||||
onChange={(next) => {
|
||||
setParent(next);
|
||||
if (next) {
|
||||
setSelectedType(next.type);
|
||||
}
|
||||
}}
|
||||
error={errors.parent_id}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="icon">{__('Icon')}</Label>
|
||||
<Select
|
||||
|
|
@ -152,25 +173,52 @@ export function EditCategoryDialog({
|
|||
<InputError message={errors.color} />
|
||||
</div>
|
||||
|
||||
{parent ? (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">{__('Type')}</Label>
|
||||
<Label>{__('Type')}</Label>
|
||||
<input
|
||||
type="hidden"
|
||||
name="type"
|
||||
value={parent.type}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{getCategoryTypeLabel(parent.type)}
|
||||
{' · '}
|
||||
{__('Inherited from parent')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">
|
||||
{__('Type')}
|
||||
</Label>
|
||||
<Select
|
||||
name="type"
|
||||
defaultValue={category.type}
|
||||
required
|
||||
onValueChange={(value) =>
|
||||
setSelectedType(value as CategoryType)
|
||||
setSelectedType(
|
||||
value as CategoryType,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={__('Select a type')}
|
||||
placeholder={__(
|
||||
'Select a type',
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{getCategoryTypeLabel(type)}
|
||||
<SelectItem
|
||||
key={type}
|
||||
value={type}
|
||||
>
|
||||
{getCategoryTypeLabel(
|
||||
type,
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
|
|
@ -180,8 +228,12 @@ export function EditCategoryDialog({
|
|||
|
||||
<CategoryCashflowDirectionFields
|
||||
selectedType={selectedType}
|
||||
defaultValue={category.cashflow_direction}
|
||||
defaultValue={
|
||||
category.cashflow_direction
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
import InputError from '@/components/input-error';
|
||||
import { CategoryCombobox } from '@/components/shared/category-combobox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { getDescendantIds } from '@/lib/category-tree';
|
||||
import { type Category } from '@/types/category';
|
||||
import { UUID } from '@/types/uuid';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { usePage } from '@inertiajs/react';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
interface ParentCategoryFieldProps {
|
||||
categories: Category[];
|
||||
value: UUID | null;
|
||||
onChange: (parent: Category | null) => void;
|
||||
/** Category being edited — itself and its descendants can't be a parent. */
|
||||
excludeId?: UUID;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function ParentCategoryField({
|
||||
categories,
|
||||
value,
|
||||
onChange,
|
||||
excludeId,
|
||||
error,
|
||||
}: ParentCategoryFieldProps) {
|
||||
const enabled =
|
||||
usePage<{ categoryTreeEnabled?: boolean }>().props
|
||||
.categoryTreeEnabled ?? false;
|
||||
|
||||
const byId = useMemo(
|
||||
() => new Map(categories.map((c) => [c.id, c])),
|
||||
[categories],
|
||||
);
|
||||
|
||||
// Eligible parents: anything that isn't this category or one of its
|
||||
// descendants (no cycles) and that still leaves room for a level below it.
|
||||
const eligibleParents = useMemo(() => {
|
||||
const excluded = new Set<UUID>();
|
||||
if (excludeId) {
|
||||
excluded.add(excludeId);
|
||||
for (const id of getDescendantIds(excludeId, categories)) {
|
||||
excluded.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const depthOf = (category: Category): number => {
|
||||
let depth = 0;
|
||||
let current: Category | undefined = category;
|
||||
while (current?.parent_id != null && depth < 5) {
|
||||
current = byId.get(current.parent_id);
|
||||
depth += 1;
|
||||
}
|
||||
return depth;
|
||||
};
|
||||
|
||||
return categories.filter(
|
||||
(category) => !excluded.has(category.id) && depthOf(category) < 2,
|
||||
);
|
||||
}, [categories, excludeId, byId]);
|
||||
|
||||
if (!enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="parent_id">{__('Parent category')}</Label>
|
||||
<input type="hidden" name="parent_id" value={value ?? ''} />
|
||||
<CategoryCombobox
|
||||
value={value ?? 'null'}
|
||||
onValueChange={(next) =>
|
||||
onChange(next === 'null' ? null : (byId.get(next) ?? null))
|
||||
}
|
||||
categories={eligibleParents}
|
||||
emptyOptionLabel={__('None (top level)')}
|
||||
placeholder={__('None (top level)')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{__(
|
||||
'Child categories inherit their parent’s type and cashflow settings.',
|
||||
)}
|
||||
</p>
|
||||
<InputError message={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -28,6 +28,8 @@ interface SankeyChartProps {
|
|||
currency?: string;
|
||||
groupingThreshold?: number;
|
||||
period?: { from: Date; to: Date };
|
||||
/** Drill into a parent category node instead of navigating to it. */
|
||||
onDrill?: (categoryId: string, label: string) => void;
|
||||
}
|
||||
|
||||
interface NodeData {
|
||||
|
|
@ -39,6 +41,7 @@ interface NodeData {
|
|||
height: number;
|
||||
column: 0 | 1 | 2;
|
||||
category?: Category;
|
||||
hasChildren?: boolean;
|
||||
}
|
||||
|
||||
interface LinkData {
|
||||
|
|
@ -149,6 +152,7 @@ export function SankeyChart({
|
|||
currency = 'USD',
|
||||
groupingThreshold = 0.03,
|
||||
period,
|
||||
onDrill,
|
||||
}: SankeyChartProps) {
|
||||
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
|
||||
const [hoveredLink, setHoveredLink] = useState<string | null>(null);
|
||||
|
|
@ -242,6 +246,7 @@ export function SankeyChart({
|
|||
height: nodeHeight,
|
||||
column: 0,
|
||||
category: item.category,
|
||||
hasChildren: item.has_children,
|
||||
};
|
||||
incomeY += nodeHeight + NODE_PADDING;
|
||||
return node;
|
||||
|
|
@ -308,6 +313,7 @@ export function SankeyChart({
|
|||
height: nodeHeight,
|
||||
column: 2,
|
||||
category: item.category,
|
||||
hasChildren: item.has_children,
|
||||
};
|
||||
expenseY += nodeHeight + NODE_PADDING;
|
||||
return node;
|
||||
|
|
@ -488,8 +494,25 @@ export function SankeyChart({
|
|||
},
|
||||
}).url
|
||||
: null;
|
||||
const canDrill =
|
||||
!isOtherNode &&
|
||||
!!node.hasChildren &&
|
||||
!!node.category &&
|
||||
!!onDrill;
|
||||
const isNavigable =
|
||||
!isOtherNode && categoryUrl !== null;
|
||||
!isOtherNode && !canDrill && categoryUrl !== null;
|
||||
const isInteractive = canDrill || isNavigable;
|
||||
|
||||
const activate = () => {
|
||||
if (canDrill && node.category) {
|
||||
onDrill?.(node.category.id, node.label);
|
||||
return;
|
||||
}
|
||||
|
||||
if (categoryUrl) {
|
||||
router.visit(categoryUrl);
|
||||
}
|
||||
};
|
||||
|
||||
const nodeContent = (
|
||||
<g
|
||||
|
|
@ -497,12 +520,12 @@ export function SankeyChart({
|
|||
onMouseEnter={() => setHoveredNode(node.id)}
|
||||
onMouseLeave={() => setHoveredNode(null)}
|
||||
onClick={() => {
|
||||
if (categoryUrl) {
|
||||
router.visit(categoryUrl);
|
||||
if (isInteractive) {
|
||||
activate();
|
||||
}
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!categoryUrl) {
|
||||
if (!isInteractive) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -511,22 +534,30 @@ export function SankeyChart({
|
|||
event.key === ' '
|
||||
) {
|
||||
event.preventDefault();
|
||||
router.visit(categoryUrl);
|
||||
activate();
|
||||
}
|
||||
}}
|
||||
role={isNavigable ? 'link' : undefined}
|
||||
tabIndex={isNavigable ? 0 : undefined}
|
||||
role={
|
||||
canDrill
|
||||
? 'button'
|
||||
: isNavigable
|
||||
? 'link'
|
||||
: undefined
|
||||
}
|
||||
tabIndex={isInteractive ? 0 : undefined}
|
||||
aria-label={
|
||||
isNavigable
|
||||
canDrill
|
||||
? `Drill into ${node.label}`
|
||||
: isNavigable
|
||||
? `View ${node.label} transactions`
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
'transition-all duration-200',
|
||||
isOtherNode && 'cursor-pointer',
|
||||
isNavigable && 'cursor-pointer',
|
||||
isInteractive && 'cursor-pointer',
|
||||
!isOtherNode &&
|
||||
!isNavigable &&
|
||||
!isInteractive &&
|
||||
'cursor-default',
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { Badge } from '@/components/ui/badge';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
|
|
@ -12,6 +11,11 @@ import {
|
|||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
buildCategoryTree,
|
||||
flattenCategoryTree,
|
||||
getCategoryPath,
|
||||
} from '@/lib/category-tree';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
import { __ } from '@/utils/i18n';
|
||||
|
|
@ -22,7 +26,7 @@ import {
|
|||
HelpCircle,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { memo, useEffect, useRef, useState } from 'react';
|
||||
import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
const iconCache = new Map<string, LucideIcon>();
|
||||
|
||||
|
|
@ -49,6 +53,8 @@ interface CategoryComboboxProps {
|
|||
triggerClassName?: string;
|
||||
showUncategorized?: boolean;
|
||||
withoutChevronIcon?: boolean;
|
||||
/** Label for the empty / "no category" option (defaults to Uncategorized). */
|
||||
emptyOptionLabel?: string;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
|
|
@ -61,8 +67,10 @@ export function CategoryCombobox({
|
|||
triggerClassName,
|
||||
showUncategorized = true,
|
||||
withoutChevronIcon = false,
|
||||
emptyOptionLabel,
|
||||
'data-testid': dataTestId,
|
||||
}: CategoryComboboxProps) {
|
||||
const noneLabel = emptyOptionLabel ?? __('Uncategorized');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filterValue, setFilterValue] = useState('');
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -72,10 +80,44 @@ export function CategoryCombobox({
|
|||
? categories.find((c) => c.id === value)
|
||||
: null;
|
||||
|
||||
const sortedCategories = [...categories].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
const orderedNodes = useMemo(
|
||||
() => flattenCategoryTree(buildCategoryTree(categories)),
|
||||
[categories],
|
||||
);
|
||||
|
||||
const query = filterValue.trim().toLowerCase();
|
||||
|
||||
// Tree-aware search: keep every match plus its ancestors, so a matching
|
||||
// child is always shown under its parent for context.
|
||||
const visibleNodes = useMemo(() => {
|
||||
if (!query) {
|
||||
return orderedNodes;
|
||||
}
|
||||
|
||||
const parentOf = new Map(categories.map((c) => [c.id, c.parent_id]));
|
||||
const include = new Set<string>();
|
||||
for (const category of categories) {
|
||||
if (!category.name.toLowerCase().includes(query)) {
|
||||
continue;
|
||||
}
|
||||
let id: string | null | undefined = category.id;
|
||||
let guard = 0;
|
||||
while (id != null && guard++ < 10) {
|
||||
include.add(id);
|
||||
id = parentOf.get(id);
|
||||
}
|
||||
}
|
||||
|
||||
return orderedNodes.filter((node) => include.has(node.id));
|
||||
}, [orderedNodes, categories, query]);
|
||||
|
||||
const showNone =
|
||||
showUncategorized &&
|
||||
(query === '' ||
|
||||
noneLabel.toLowerCase().includes(query) ||
|
||||
'uncategorized'.includes(query));
|
||||
const hasResults = visibleNodes.length > 0 || showNone;
|
||||
|
||||
useEffect(() => {
|
||||
if (filterValue && listRef.current) {
|
||||
listRef.current.scrollTop = 0;
|
||||
|
|
@ -119,7 +161,7 @@ export function CategoryCombobox({
|
|||
<HelpCircle className="h-3 w-3 text-zinc-500" />
|
||||
</div>
|
||||
<span className="truncate text-zinc-500">
|
||||
{__('Uncategorized')}
|
||||
{noneLabel}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -133,7 +175,7 @@ export function CategoryCombobox({
|
|||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0" align="start">
|
||||
<Command>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={__('Search categories...')}
|
||||
value={filterValue}
|
||||
|
|
@ -141,8 +183,12 @@ export function CategoryCombobox({
|
|||
/>
|
||||
|
||||
<CommandList ref={listRef}>
|
||||
<CommandEmpty>{__('No category found.')}</CommandEmpty>
|
||||
{showUncategorized && (
|
||||
{!hasResults && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
{__('No category found.')}
|
||||
</div>
|
||||
)}
|
||||
{showNone && (
|
||||
<CommandItem
|
||||
value="uncategorized"
|
||||
onSelect={() => {
|
||||
|
|
@ -154,7 +200,7 @@ export function CategoryCombobox({
|
|||
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<HelpCircle className="h-3 w-3 text-zinc-500" />
|
||||
</div>
|
||||
<span>{__('Uncategorized')}</span>
|
||||
<span>{noneLabel}</span>
|
||||
</div>
|
||||
<Check
|
||||
className={cn(
|
||||
|
|
@ -166,16 +212,21 @@ export function CategoryCombobox({
|
|||
/>
|
||||
</CommandItem>
|
||||
)}
|
||||
{sortedCategories.map((category) => (
|
||||
{visibleNodes.map((category) => (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
value={category.name}
|
||||
value={getCategoryPath(category.id, categories)}
|
||||
onSelect={() => {
|
||||
onValueChange(String(category.id));
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
style={{
|
||||
paddingLeft: `${category.depth * 1.25}rem`,
|
||||
}}
|
||||
>
|
||||
<CategoryIcon category={category} />
|
||||
<span className="truncate">
|
||||
{category.name}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { CategoryIcon } from '@/components/shared/category-combobox';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
|
|
@ -9,12 +8,17 @@ import {
|
|||
} from '@/components/ui/command';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
import { type AnimationState } from '@/hooks/use-categorize-transactions';
|
||||
import {
|
||||
buildCategoryTree,
|
||||
flattenCategoryTree,
|
||||
getCategoryPath,
|
||||
} from '@/lib/category-tree';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
import { type DecryptedTransaction } from '@/types/transaction';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { ArrowDown, ArrowUp } from 'lucide-react';
|
||||
import { type RefObject } from 'react';
|
||||
import { type RefObject, useMemo } from 'react';
|
||||
|
||||
interface CategorizerCommandProps {
|
||||
sortedCategories: Category[];
|
||||
|
|
@ -37,6 +41,39 @@ export function CategorizerCommand({
|
|||
commandInputRef,
|
||||
disabled = false,
|
||||
}: CategorizerCommandProps) {
|
||||
const treeCategories = useMemo(
|
||||
() => flattenCategoryTree(buildCategoryTree(sortedCategories)),
|
||||
[sortedCategories],
|
||||
);
|
||||
|
||||
const query = searchValue.trim().toLowerCase();
|
||||
|
||||
// Tree-aware search: keep each match together with its ancestors so a
|
||||
// matching child still shows its parent chain for context.
|
||||
const visibleCategories = useMemo(() => {
|
||||
if (!query) {
|
||||
return treeCategories;
|
||||
}
|
||||
|
||||
const parentOf = new Map(
|
||||
sortedCategories.map((c) => [c.id, c.parent_id]),
|
||||
);
|
||||
const include = new Set<string>();
|
||||
for (const category of sortedCategories) {
|
||||
if (!category.name.toLowerCase().includes(query)) {
|
||||
continue;
|
||||
}
|
||||
let id: string | null | undefined = category.id;
|
||||
let guard = 0;
|
||||
while (id != null && guard++ < 10) {
|
||||
include.add(id);
|
||||
id = parentOf.get(id);
|
||||
}
|
||||
}
|
||||
|
||||
return treeCategories.filter((node) => include.has(node.id));
|
||||
}, [treeCategories, sortedCategories, query]);
|
||||
|
||||
if (animationState === 'success' || !currentTransaction) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -68,7 +105,7 @@ export function CategorizerCommand({
|
|||
</div>
|
||||
<Command
|
||||
className="rounded-xl border border-zinc-200 bg-white shadow-lg dark:border-zinc-800 dark:bg-zinc-900"
|
||||
shouldFilter={true}
|
||||
shouldFilter={false}
|
||||
>
|
||||
<CommandInput
|
||||
ref={commandInputRef}
|
||||
|
|
@ -79,21 +116,31 @@ export function CategorizerCommand({
|
|||
/>
|
||||
|
||||
<CommandList className="max-h-64">
|
||||
<CommandEmpty>{__('No categories found.')}</CommandEmpty>
|
||||
{visibleCategories.length === 0 && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
{__('No categories found.')}
|
||||
</div>
|
||||
)}
|
||||
<CommandGroup>
|
||||
{sortedCategories.map((category) => {
|
||||
{visibleCategories.map((category) => {
|
||||
const colorClasses = getCategoryColorClasses(
|
||||
category.color,
|
||||
);
|
||||
return (
|
||||
<CommandItem
|
||||
key={category.id}
|
||||
value={category.name}
|
||||
value={getCategoryPath(
|
||||
category.id,
|
||||
sortedCategories,
|
||||
)}
|
||||
onSelect={() => onCategorySelect(category)}
|
||||
disabled={
|
||||
animationState !== 'idle' || disabled
|
||||
}
|
||||
className="group cursor-pointer gap-3 p-2"
|
||||
style={{
|
||||
paddingLeft: `${0.5 + category.depth * 1.25}rem`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import { __ } from '@/utils/i18n';
|
||||
import { format } from 'date-fns';
|
||||
import * as Icons from 'lucide-react';
|
||||
import { Check, ChevronsUpDown, Tag, X } from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { ChevronsUpDown, Tag, X } from 'lucide-react';
|
||||
import { type ReactNode, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { AccountName } from '@/components/accounts/account-name';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
|
|
@ -21,6 +22,12 @@ import {
|
|||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import {
|
||||
buildCategoryTree,
|
||||
categorySelectionState,
|
||||
flattenCategoryTree,
|
||||
toggleCategorySelection,
|
||||
} from '@/lib/category-tree';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type Account } from '@/types/account';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
|
|
@ -50,6 +57,7 @@ export function TransactionFilters({
|
|||
}: TransactionFiltersProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
|
||||
const [categorySearch, setCategorySearch] = useState('');
|
||||
const [labelDropdownOpen, setLabelDropdownOpen] = useState(false);
|
||||
const [searchText, setSearchText] = useState(filters.searchText);
|
||||
const [creditorName, setCreditorName] = useState(filters.creditorName);
|
||||
|
|
@ -93,12 +101,87 @@ export function TransactionFilters({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters.searchText, filters.creditorName, filters.debtorName]);
|
||||
|
||||
function handleCategoryToggle(categoryId: string) {
|
||||
const newCategoryIds = filters.categoryIds.includes(categoryId)
|
||||
? filters.categoryIds.filter((id) => id !== categoryId)
|
||||
: [...filters.categoryIds, categoryId];
|
||||
// Snapshot of which categories were selected when the dropdown opened.
|
||||
// Ordering uses this snapshot, so toggling while open never reshuffles the
|
||||
// list under the cursor; it only re-sorts the next time it is opened.
|
||||
const [categoryOrderSnapshot, setCategoryOrderSnapshot] = useState<
|
||||
Set<string>
|
||||
>(new Set());
|
||||
|
||||
onFiltersChange({ ...filters, categoryIds: newCategoryIds });
|
||||
const categoryTree = useMemo(() => {
|
||||
// A branch sorts to the top when it (or a descendant) was selected.
|
||||
const branchSelected = new Set<string>();
|
||||
if (categoryOrderSnapshot.size > 0) {
|
||||
const parentOf = new Map(
|
||||
categories.map((c) => [c.id, c.parent_id]),
|
||||
);
|
||||
for (const id of categoryOrderSnapshot) {
|
||||
let current: string | null | undefined = id;
|
||||
let guard = 0;
|
||||
while (current != null && guard++ < 10) {
|
||||
branchSelected.add(current);
|
||||
current = parentOf.get(current);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const compare = (a: Category, b: Category) => {
|
||||
const aSelected = branchSelected.has(a.id);
|
||||
const bSelected = branchSelected.has(b.id);
|
||||
if (aSelected !== bSelected) {
|
||||
return aSelected ? -1 : 1;
|
||||
}
|
||||
return a.name.localeCompare(b.name);
|
||||
};
|
||||
|
||||
return flattenCategoryTree(buildCategoryTree(categories, compare));
|
||||
}, [categories, categoryOrderSnapshot]);
|
||||
|
||||
const selectedCategorySet = useMemo(
|
||||
() => new Set(filters.categoryIds),
|
||||
[filters.categoryIds],
|
||||
);
|
||||
|
||||
// Tree-aware search: keep each match together with its ancestors so a
|
||||
// matching child still shows its parent chain for context.
|
||||
const visibleCategoryTree = useMemo(() => {
|
||||
const query = categorySearch.trim().toLowerCase();
|
||||
if (!query) {
|
||||
return categoryTree;
|
||||
}
|
||||
|
||||
const parentOf = new Map(categories.map((c) => [c.id, c.parent_id]));
|
||||
const include = new Set<string>();
|
||||
for (const category of categories) {
|
||||
if (!category.name.toLowerCase().includes(query)) {
|
||||
continue;
|
||||
}
|
||||
let id: string | null | undefined = category.id;
|
||||
let guard = 0;
|
||||
while (id != null && guard++ < 10) {
|
||||
include.add(id);
|
||||
id = parentOf.get(id);
|
||||
}
|
||||
}
|
||||
|
||||
return categoryTree.filter((node) => include.has(node.id));
|
||||
}, [categoryTree, categories, categorySearch]);
|
||||
|
||||
const showUncategorizedRow =
|
||||
categorySearch.trim() === '' ||
|
||||
'uncategorized'.includes(categorySearch.trim().toLowerCase());
|
||||
const hasCategoryResults =
|
||||
visibleCategoryTree.length > 0 || showUncategorizedRow;
|
||||
|
||||
function handleCategoryToggle(categoryId: string) {
|
||||
onFiltersChange({
|
||||
...filters,
|
||||
categoryIds: toggleCategorySelection(
|
||||
filters.categoryIds,
|
||||
categoryId,
|
||||
categories,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function handleAccountToggle(accountId: number) {
|
||||
|
|
@ -300,9 +383,18 @@ export function TransactionFilters({
|
|||
<div className="pt-2">
|
||||
<Popover
|
||||
open={categoryDropdownOpen}
|
||||
onOpenChange={
|
||||
setCategoryDropdownOpen
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
setCategoryOrderSnapshot(
|
||||
new Set(
|
||||
filters.categoryIds,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setCategorySearch('');
|
||||
}
|
||||
setCategoryDropdownOpen(open);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
|
@ -334,18 +426,25 @@ export function TransactionFilters({
|
|||
className="w-full p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={__(
|
||||
'Search categories...',
|
||||
)}
|
||||
value={categorySearch}
|
||||
onValueChange={
|
||||
setCategorySearch
|
||||
}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
{!hasCategoryResults && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
{__(
|
||||
'No category found.',
|
||||
)}
|
||||
</CommandEmpty>
|
||||
</div>
|
||||
)}
|
||||
{showUncategorizedRow && (
|
||||
<CommandItem
|
||||
onSelect={() =>
|
||||
handleCategoryToggle(
|
||||
|
|
@ -353,18 +452,14 @@ export function TransactionFilters({
|
|||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
|
||||
<Checkbox
|
||||
checked={
|
||||
isUncategorizedSelected
|
||||
? 'bg-primary/10 text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible',
|
||||
)}
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</div>
|
||||
}
|
||||
className="pointer-events-none mr-1"
|
||||
/>
|
||||
<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">
|
||||
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
|
||||
<Icons.HelpCircle className="h-3 w-3 text-zinc-500" />
|
||||
</div>
|
||||
{__(
|
||||
|
|
@ -372,11 +467,14 @@ export function TransactionFilters({
|
|||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
{categories.map(
|
||||
)}
|
||||
{visibleCategoryTree.map(
|
||||
(category) => {
|
||||
const isSelected =
|
||||
filters.categoryIds.includes(
|
||||
const state =
|
||||
categorySelectionState(
|
||||
category.id,
|
||||
selectedCategorySet,
|
||||
categories,
|
||||
);
|
||||
const colorClasses =
|
||||
getCategoryColorClasses(
|
||||
|
|
@ -387,26 +485,32 @@ export function TransactionFilters({
|
|||
key={
|
||||
category.id
|
||||
}
|
||||
value={
|
||||
category.name
|
||||
}
|
||||
onSelect={() =>
|
||||
handleCategoryToggle(
|
||||
category.id,
|
||||
)
|
||||
}
|
||||
style={{
|
||||
paddingLeft: `${0.5 + category.depth * 1.25}rem`,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={
|
||||
state ===
|
||||
'indeterminate'
|
||||
? 'indeterminate'
|
||||
: state ===
|
||||
'checked'
|
||||
}
|
||||
className="pointer-events-none mr-1"
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<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',
|
||||
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full',
|
||||
colorClasses.bg,
|
||||
)}
|
||||
>
|
||||
|
|
@ -419,7 +523,7 @@ export function TransactionFilters({
|
|||
}
|
||||
/>
|
||||
</div>
|
||||
<span>
|
||||
<span className="truncate">
|
||||
{
|
||||
category.name
|
||||
}
|
||||
|
|
@ -504,16 +608,12 @@ export function TransactionFilters({
|
|||
)
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
|
||||
<Checkbox
|
||||
checked={
|
||||
isSelected
|
||||
? 'bg-primary/10 text-primary-foreground'
|
||||
: 'opacity-50 [&_svg]:invisible',
|
||||
)}
|
||||
>
|
||||
<Check className="size-3" />
|
||||
</div>
|
||||
}
|
||||
className="pointer-events-none mr-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { CheckIcon, MinusIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
|
|
@ -12,7 +12,7 @@ function Checkbox({
|
|||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"peer group border-input data-[state=checked]:bg-primary/5 dark:data-[state=checked]:bg-primary/20 data-[state=checked]:text-primary data-[state=checked]:border-primary/25 data-[state=indeterminate]:bg-primary/5 dark:data-[state=indeterminate]:bg-primary/20 data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary/25 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
@ -21,7 +21,8 @@ function Checkbox({
|
|||
data-slot="checkbox-indicator"
|
||||
className="flex items-center justify-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
<CheckIcon className="size-3.5 stroke-primary group-data-[state=indeterminate]:hidden" />
|
||||
<MinusIcon className="hidden stroke-primary size-3.5 group-data-[state=indeterminate]:block" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -16,13 +16,16 @@ import {
|
|||
import { cn } from '@/lib/utils';
|
||||
import { __ } from '@/utils/i18n';
|
||||
import { Check, ChevronsUpDown, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
export interface MultiSelectOption {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
badgeClassName?: string;
|
||||
/** Optional hierarchy: indentation level and parent for tree-aware search. */
|
||||
depth?: number;
|
||||
parentValue?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
|
|
@ -47,6 +50,38 @@ export function MultiSelect({
|
|||
className,
|
||||
}: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const isTree = options.some(
|
||||
(option) => option.depth != null || option.parentValue != null,
|
||||
);
|
||||
|
||||
// Tree-aware search: keep each match together with its ancestors so a
|
||||
// matching child still shows its parent chain for context.
|
||||
const visibleOptions = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!isTree || !query) {
|
||||
return options;
|
||||
}
|
||||
|
||||
const parentOf = new Map(
|
||||
options.map((option) => [option.value, option.parentValue ?? null]),
|
||||
);
|
||||
const include = new Set<string>();
|
||||
for (const option of options) {
|
||||
if (!option.label.toLowerCase().includes(query)) {
|
||||
continue;
|
||||
}
|
||||
let value: string | null | undefined = option.value;
|
||||
let guard = 0;
|
||||
while (value != null && guard++ < 10) {
|
||||
include.add(value);
|
||||
value = parentOf.get(value);
|
||||
}
|
||||
}
|
||||
|
||||
return options.filter((option) => include.has(option.value));
|
||||
}, [options, isTree, search]);
|
||||
|
||||
const toggle = (value: string) => {
|
||||
if (selected.includes(value)) {
|
||||
|
|
@ -90,12 +125,24 @@ export function MultiSelect({
|
|||
className="w-[--radix-popover-trigger-width] p-0"
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<Command shouldFilter={!isTree}>
|
||||
<CommandInput
|
||||
placeholder={searchPlaceholder}
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
/>
|
||||
<CommandList>
|
||||
{isTree ? (
|
||||
visibleOptions.length === 0 && (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||
)}
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
{visibleOptions.map((option) => {
|
||||
const isSelected = selected.includes(
|
||||
option.value,
|
||||
);
|
||||
|
|
@ -104,10 +151,17 @@ export function MultiSelect({
|
|||
key={option.value}
|
||||
value={option.label}
|
||||
onSelect={() => toggle(option.value)}
|
||||
style={
|
||||
option.depth
|
||||
? {
|
||||
paddingLeft: `${option.depth * 1.25}rem`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
'mr-2 h-4 w-4',
|
||||
'mr-2 h-4 w-4 shrink-0',
|
||||
isSelected
|
||||
? 'opacity-100'
|
||||
: 'opacity-0',
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ export interface SankeyCategory {
|
|||
category: Category;
|
||||
category_id: string;
|
||||
amount: number;
|
||||
has_children?: boolean;
|
||||
is_direct?: boolean;
|
||||
}
|
||||
|
||||
export interface SankeyData {
|
||||
|
|
@ -39,6 +41,8 @@ export interface BreakdownItem {
|
|||
amount: number;
|
||||
percentage: number;
|
||||
previous_amount: number;
|
||||
has_children?: boolean;
|
||||
is_direct?: boolean;
|
||||
}
|
||||
|
||||
export interface BreakdownData {
|
||||
|
|
@ -63,6 +67,8 @@ interface UseCashflowDataOptions {
|
|||
from: Date;
|
||||
to: Date;
|
||||
periodType: CashflowPeriodType;
|
||||
/** When set, the Sankey diagram drills into this parent category. */
|
||||
sankeyParent?: string | null;
|
||||
}
|
||||
|
||||
const emptyBreakdown: BreakdownData = {
|
||||
|
|
@ -84,6 +90,7 @@ export function useCashflowData({
|
|||
from,
|
||||
to,
|
||||
periodType,
|
||||
sankeyParent = null,
|
||||
}: UseCashflowDataOptions): CashflowData & { refetch: () => void } {
|
||||
const [data, setData] = useState<Omit<CashflowData, 'isLoading'>>({
|
||||
summary: { current: emptySummary, previous: emptySummary },
|
||||
|
|
@ -110,6 +117,9 @@ export function useCashflowData({
|
|||
to: toStr,
|
||||
});
|
||||
const periodQuery = `?${periodParams.toString()}`;
|
||||
const sankeyQuery = sankeyParent
|
||||
? `${periodQuery}&parent=${sankeyParent}`
|
||||
: periodQuery;
|
||||
const trendQuery =
|
||||
periodType === 'month' ? `?months=12&to=${toStr}` : periodQuery;
|
||||
|
||||
|
|
@ -118,7 +128,7 @@ export function useCashflowData({
|
|||
fetch(`/api/cashflow/summary${periodQuery}`).then((r) =>
|
||||
r.json(),
|
||||
),
|
||||
fetch(`/api/cashflow/sankey${periodQuery}`).then((r) =>
|
||||
fetch(`/api/cashflow/sankey${sankeyQuery}`).then((r) =>
|
||||
r.json(),
|
||||
),
|
||||
fetch(`/api/cashflow/trend${trendQuery}`).then((r) =>
|
||||
|
|
@ -144,7 +154,7 @@ export function useCashflowData({
|
|||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [from, periodType, to]);
|
||||
}, [from, periodType, to, sankeyParent]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,101 @@
|
|||
import { Category } from '@/types/category';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
categorySelectionState,
|
||||
toggleCategorySelection,
|
||||
} from './category-tree';
|
||||
|
||||
function category(id: string, parent_id: string | null): Category {
|
||||
return {
|
||||
id,
|
||||
name: id,
|
||||
icon: 'Tag',
|
||||
color: 'gray',
|
||||
type: 'expense',
|
||||
cashflow_direction: 'hidden',
|
||||
parent_id,
|
||||
} as Category;
|
||||
}
|
||||
|
||||
// food ──┬─ groceries ── coffee
|
||||
// └─ restaurants
|
||||
// drinks (root)
|
||||
const categories: Category[] = [
|
||||
category('food', null),
|
||||
category('groceries', 'food'),
|
||||
category('restaurants', 'food'),
|
||||
category('coffee', 'groceries'),
|
||||
category('drinks', null),
|
||||
];
|
||||
|
||||
const stateOf = (id: string, selected: string[]) =>
|
||||
categorySelectionState(id, new Set(selected), categories);
|
||||
|
||||
describe('categorySelectionState', () => {
|
||||
it('marks a category and its descendants checked when the parent is selected', () => {
|
||||
expect(stateOf('food', ['food'])).toBe('checked');
|
||||
expect(stateOf('groceries', ['food'])).toBe('checked');
|
||||
expect(stateOf('coffee', ['food'])).toBe('checked');
|
||||
expect(stateOf('drinks', ['food'])).toBe('unchecked');
|
||||
});
|
||||
|
||||
it('marks ancestors indeterminate when only a descendant is selected', () => {
|
||||
expect(stateOf('food', ['coffee'])).toBe('indeterminate');
|
||||
expect(stateOf('groceries', ['coffee'])).toBe('indeterminate');
|
||||
expect(stateOf('coffee', ['coffee'])).toBe('checked');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleCategorySelection', () => {
|
||||
it('selecting a parent collapses to a single id covering the subtree', () => {
|
||||
expect(toggleCategorySelection([], 'food', categories)).toEqual([
|
||||
'food',
|
||||
]);
|
||||
});
|
||||
|
||||
it('unselecting one child keeps its siblings selected', () => {
|
||||
const result = toggleCategorySelection(
|
||||
['food'],
|
||||
'restaurants',
|
||||
categories,
|
||||
);
|
||||
|
||||
expect(result).not.toContain('food');
|
||||
expect(result).not.toContain('restaurants');
|
||||
expect(stateOf('groceries', result)).toBe('checked');
|
||||
expect(stateOf('restaurants', result)).toBe('unchecked');
|
||||
expect(stateOf('food', result)).toBe('indeterminate');
|
||||
});
|
||||
|
||||
it('re-selecting a sibling does not auto-collapse to the parent, but clicking the parent does', () => {
|
||||
const partial = toggleCategorySelection(
|
||||
['food'],
|
||||
'restaurants',
|
||||
categories,
|
||||
);
|
||||
const reselected = toggleCategorySelection(
|
||||
partial,
|
||||
'restaurants',
|
||||
categories,
|
||||
);
|
||||
|
||||
// Both children selected individually; the parent is not auto-added.
|
||||
expect(reselected).not.toContain('food');
|
||||
expect(stateOf('groceries', reselected)).toBe('checked');
|
||||
expect(stateOf('restaurants', reselected)).toBe('checked');
|
||||
|
||||
// Clicking the parent collapses the whole subtree to a single id.
|
||||
expect(toggleCategorySelection(reselected, 'food', categories)).toEqual(
|
||||
['food'],
|
||||
);
|
||||
});
|
||||
|
||||
it('selecting a single child never pulls in its parent', () => {
|
||||
const result = toggleCategorySelection([], 'coffee', categories);
|
||||
|
||||
expect(result).toEqual(['coffee']);
|
||||
expect(stateOf('groceries', result)).toBe('indeterminate');
|
||||
expect(stateOf('food', result)).toBe('indeterminate');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,235 @@
|
|||
import { Category } from '@/types/category';
|
||||
import { UUID } from '@/types/uuid';
|
||||
|
||||
export interface CategoryNode extends Category {
|
||||
depth: number;
|
||||
children: CategoryNode[];
|
||||
}
|
||||
|
||||
const compareByName = (a: Category, b: Category): number =>
|
||||
a.name.localeCompare(b.name);
|
||||
|
||||
/**
|
||||
* Build a nested tree from a flat list of categories. Siblings are sorted with
|
||||
* `compare` at every level (defaults to name), so children always stay grouped
|
||||
* under their parent regardless of the chosen order. Orphans (a parent that is
|
||||
* missing from the list) are treated as roots so nothing silently disappears.
|
||||
*/
|
||||
export function buildCategoryTree(
|
||||
categories: Category[],
|
||||
compare: (a: Category, b: Category) => number = compareByName,
|
||||
): CategoryNode[] {
|
||||
const byId = new Map<UUID, CategoryNode>();
|
||||
for (const category of categories) {
|
||||
byId.set(category.id, { ...category, depth: 0, children: [] });
|
||||
}
|
||||
|
||||
const roots: CategoryNode[] = [];
|
||||
for (const node of byId.values()) {
|
||||
const parent =
|
||||
node.parent_id != null ? byId.get(node.parent_id) : undefined;
|
||||
if (parent) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
|
||||
const sortAndDepth = (nodes: CategoryNode[], depth: number) => {
|
||||
nodes.sort(compare);
|
||||
for (const node of nodes) {
|
||||
node.depth = depth;
|
||||
sortAndDepth(node.children, depth + 1);
|
||||
}
|
||||
};
|
||||
sortAndDepth(roots, 0);
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flatten a tree back into a list in depth-first display order, carrying the
|
||||
* computed depth so callers can indent rows.
|
||||
*/
|
||||
export function flattenCategoryTree(nodes: CategoryNode[]): CategoryNode[] {
|
||||
const flat: CategoryNode[] = [];
|
||||
const walk = (list: CategoryNode[]) => {
|
||||
for (const node of list) {
|
||||
flat.push(node);
|
||||
walk(node.children);
|
||||
}
|
||||
};
|
||||
walk(nodes);
|
||||
|
||||
return flat;
|
||||
}
|
||||
|
||||
/**
|
||||
* All descendant ids of a category (excluding itself), resolved from a flat
|
||||
* list via a parent index.
|
||||
*/
|
||||
export function getDescendantIds(
|
||||
categoryId: UUID,
|
||||
categories: Category[],
|
||||
): UUID[] {
|
||||
const childrenByParent = new Map<UUID, UUID[]>();
|
||||
for (const category of categories) {
|
||||
if (category.parent_id == null) {
|
||||
continue;
|
||||
}
|
||||
const siblings = childrenByParent.get(category.parent_id) ?? [];
|
||||
siblings.push(category.id);
|
||||
childrenByParent.set(category.parent_id, siblings);
|
||||
}
|
||||
|
||||
const result: UUID[] = [];
|
||||
const stack = [...(childrenByParent.get(categoryId) ?? [])];
|
||||
while (stack.length > 0) {
|
||||
const id = stack.pop()!;
|
||||
result.push(id);
|
||||
stack.push(...(childrenByParent.get(id) ?? []));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export type CategorySelectionState = 'checked' | 'indeterminate' | 'unchecked';
|
||||
|
||||
interface TreeIndex {
|
||||
parentOf: Map<UUID, UUID | null>;
|
||||
childrenOf: Map<UUID, UUID[]>;
|
||||
}
|
||||
|
||||
function indexTree(categories: Category[]): TreeIndex {
|
||||
const parentOf = new Map<UUID, UUID | null>();
|
||||
const childrenOf = new Map<UUID, UUID[]>();
|
||||
for (const category of categories) {
|
||||
parentOf.set(category.id, category.parent_id);
|
||||
if (category.parent_id != null) {
|
||||
const siblings = childrenOf.get(category.parent_id) ?? [];
|
||||
siblings.push(category.id);
|
||||
childrenOf.set(category.parent_id, siblings);
|
||||
}
|
||||
}
|
||||
|
||||
return { parentOf, childrenOf };
|
||||
}
|
||||
|
||||
function collectDescendants(id: UUID, childrenOf: Map<UUID, UUID[]>): UUID[] {
|
||||
const result: UUID[] = [];
|
||||
const stack = [...(childrenOf.get(id) ?? [])];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop()!;
|
||||
result.push(current);
|
||||
stack.push(...(childrenOf.get(current) ?? []));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tri-state for a category in a tree multi-select: a category is checked when
|
||||
* it (or an ancestor) is in the selected set, indeterminate when only some
|
||||
* descendants are, otherwise unchecked.
|
||||
*/
|
||||
export function categorySelectionState(
|
||||
id: UUID,
|
||||
selected: Set<UUID>,
|
||||
categories: Category[],
|
||||
): CategorySelectionState {
|
||||
const { parentOf, childrenOf } = indexTree(categories);
|
||||
|
||||
let current: UUID | null | undefined = id;
|
||||
let guard = 0;
|
||||
while (current != null && guard++ < 10) {
|
||||
if (selected.has(current)) {
|
||||
return 'checked';
|
||||
}
|
||||
current = parentOf.get(current);
|
||||
}
|
||||
|
||||
if (collectDescendants(id, childrenOf).some((d) => selected.has(d))) {
|
||||
return 'indeterminate';
|
||||
}
|
||||
|
||||
return 'unchecked';
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle a category in a tree multi-select selection.
|
||||
*
|
||||
* Selecting a category selects its whole subtree (one id covers it; the backend
|
||||
* expands it when filtering). Unselecting a covered child first pushes the
|
||||
* covering ancestor down into its children, so siblings stay selected while the
|
||||
* one child is removed. Selecting a child never auto-selects its parent, so
|
||||
* picking a child won't pull in the parent's directly-assigned transactions —
|
||||
* click the parent row to select the whole subtree at once.
|
||||
*/
|
||||
export function toggleCategorySelection(
|
||||
selected: UUID[],
|
||||
id: UUID,
|
||||
categories: Category[],
|
||||
): UUID[] {
|
||||
const set = new Set(selected);
|
||||
const { parentOf, childrenOf } = indexTree(categories);
|
||||
|
||||
const isChecked = categorySelectionState(id, set, categories) === 'checked';
|
||||
|
||||
if (isChecked) {
|
||||
// Push every covering ancestor down to its direct children so removing
|
||||
// a single node leaves its siblings selected.
|
||||
const chain: UUID[] = [];
|
||||
let ancestor = parentOf.get(id);
|
||||
let guard = 0;
|
||||
while (ancestor != null && guard++ < 10) {
|
||||
chain.unshift(ancestor);
|
||||
ancestor = parentOf.get(ancestor);
|
||||
}
|
||||
for (const node of chain) {
|
||||
if (set.has(node)) {
|
||||
set.delete(node);
|
||||
for (const child of childrenOf.get(node) ?? []) {
|
||||
set.add(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
set.delete(id);
|
||||
for (const descendant of collectDescendants(id, childrenOf)) {
|
||||
set.delete(descendant);
|
||||
}
|
||||
|
||||
return [...set];
|
||||
}
|
||||
|
||||
// Select the whole subtree: one id covers it, so drop any now-redundant
|
||||
// descendants. Ancestors are left untouched so selecting a child never
|
||||
// pulls in a parent (and its directly-assigned transactions).
|
||||
for (const descendant of collectDescendants(id, childrenOf)) {
|
||||
set.delete(descendant);
|
||||
}
|
||||
set.add(id);
|
||||
|
||||
return [...set];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the "Parent > Child" display path for a category.
|
||||
*/
|
||||
export function getCategoryPath(
|
||||
categoryId: UUID,
|
||||
categories: Category[],
|
||||
separator = ' › ',
|
||||
): string {
|
||||
const byId = new Map<UUID, Category>(categories.map((c) => [c.id, c]));
|
||||
const names: string[] = [];
|
||||
let current: Category | undefined = byId.get(categoryId);
|
||||
let guard = 0;
|
||||
while (current && guard++ < 10) {
|
||||
names.unshift(current.name);
|
||||
current =
|
||||
current.parent_id != null ? byId.get(current.parent_id) : undefined;
|
||||
}
|
||||
|
||||
return names.join(separator);
|
||||
}
|
||||
|
|
@ -60,11 +60,6 @@ export const FIELD_CONFIG: Record<
|
|||
type: 'string',
|
||||
operators: ['contains', 'equals'],
|
||||
},
|
||||
category: {
|
||||
label: 'Category',
|
||||
type: 'string',
|
||||
operators: ['equals', 'is_empty', 'is_not_empty'],
|
||||
},
|
||||
};
|
||||
|
||||
export const OPERATOR_LABELS: Record<Operator, string> = {
|
||||
|
|
@ -85,7 +80,7 @@ function buildConditionJsonLogic(condition: Condition): JsonLogicRule {
|
|||
case 'contains':
|
||||
return { in: [value, { var: field }] };
|
||||
case 'equals':
|
||||
if (FIELD_CONFIG[field].type === 'number') {
|
||||
if (FIELD_CONFIG[field]?.type === 'number') {
|
||||
return { '==': [{ var: field }, parseFloat(value)] };
|
||||
}
|
||||
return { '==': [{ var: field }, value] };
|
||||
|
|
|
|||
|
|
@ -125,6 +125,11 @@ export default function CashflowPage() {
|
|||
parsePeriodParam(initialPeriod, initialPeriodType),
|
||||
);
|
||||
|
||||
const [sankeyDrill, setSankeyDrill] = useState<{
|
||||
id: string;
|
||||
label: string;
|
||||
} | null>(null);
|
||||
|
||||
const period = getPeriodRange(currentDate, periodType);
|
||||
|
||||
const {
|
||||
|
|
@ -134,7 +139,16 @@ export default function CashflowPage() {
|
|||
incomeBreakdown,
|
||||
expenseBreakdown,
|
||||
isLoading,
|
||||
} = useCashflowData({ ...period, periodType });
|
||||
} = useCashflowData({
|
||||
...period,
|
||||
periodType,
|
||||
sankeyParent: sankeyDrill?.id ?? null,
|
||||
});
|
||||
|
||||
// Leaving the period resets any Sankey drill-down.
|
||||
useEffect(() => {
|
||||
setSankeyDrill(null);
|
||||
}, [currentDate, periodType]);
|
||||
|
||||
useEffect(() => {
|
||||
const periodParam = formatPeriodParam(currentDate, periodType);
|
||||
|
|
@ -202,9 +216,28 @@ export default function CashflowPage() {
|
|||
{/* Sankey Diagram */}
|
||||
<Card>
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<CardTitle className="text-base">
|
||||
{__('Money Flow')}
|
||||
</CardTitle>
|
||||
{sankeyDrill && (
|
||||
<nav className="flex items-center gap-1 text-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSankeyDrill(null)}
|
||||
className="text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{__('All categories')}
|
||||
</button>
|
||||
<span className="text-muted-foreground">
|
||||
›
|
||||
</span>
|
||||
<span className="font-medium">
|
||||
{sankeyDrill.label}
|
||||
</span>
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
|
|
@ -215,6 +248,9 @@ export default function CashflowPage() {
|
|||
height={400}
|
||||
currency={auth.user.currency_code}
|
||||
period={period}
|
||||
onDrill={(id, label) =>
|
||||
setSankeyDrill({ id, label })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import {
|
|||
VisibilityState,
|
||||
} from '@tanstack/react-table';
|
||||
import * as Icons from 'lucide-react';
|
||||
import { ArrowUpDown, MoreHorizontal } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { ArrowDown, ArrowUp, ArrowUpDown, MoreHorizontal } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController';
|
||||
import { CreateCategoryDialog } from '@/components/categories/create-category-dialog';
|
||||
|
|
@ -54,9 +54,16 @@ import {
|
|||
} from '@/components/ui/tooltip';
|
||||
import AppLayout from '@/layouts/app-layout';
|
||||
import SettingsLayout from '@/layouts/settings/layout';
|
||||
import {
|
||||
buildCategoryTree,
|
||||
type CategoryNode,
|
||||
flattenCategoryTree,
|
||||
} from '@/lib/category-tree';
|
||||
import { type BreadcrumbItem } from '@/types';
|
||||
import { type Category, getCategoryColorClasses } from '@/types/category';
|
||||
|
||||
type SortField = 'name' | 'color' | 'type';
|
||||
|
||||
const breadcrumbs: BreadcrumbItem[] = [
|
||||
{
|
||||
title: 'Categories settings',
|
||||
|
|
@ -64,7 +71,13 @@ const breadcrumbs: BreadcrumbItem[] = [
|
|||
},
|
||||
];
|
||||
|
||||
function CategoryActions({ category }: { category: Category }) {
|
||||
function CategoryActions({
|
||||
category,
|
||||
categories,
|
||||
}: {
|
||||
category: Category;
|
||||
categories: Category[];
|
||||
}) {
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
||||
|
|
@ -93,6 +106,7 @@ function CategoryActions({ category }: { category: Category }) {
|
|||
|
||||
<EditCategoryDialog
|
||||
category={category}
|
||||
categories={categories}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
onSuccess={() => {}}
|
||||
|
|
@ -100,6 +114,7 @@ function CategoryActions({ category }: { category: Category }) {
|
|||
|
||||
<DeleteCategoryDialog
|
||||
category={category}
|
||||
categories={categories}
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
onSuccess={() => {}}
|
||||
|
|
@ -108,7 +123,13 @@ function CategoryActions({ category }: { category: Category }) {
|
|||
);
|
||||
}
|
||||
|
||||
function CategoryRow({ row }: { row: Row<Category> }) {
|
||||
function CategoryRow({
|
||||
row,
|
||||
categories,
|
||||
}: {
|
||||
row: Row<Category>;
|
||||
categories: Category[];
|
||||
}) {
|
||||
const category = row.original;
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
|
|
@ -155,6 +176,7 @@ function CategoryRow({ row }: { row: Row<Category> }) {
|
|||
|
||||
<EditCategoryDialog
|
||||
category={category}
|
||||
categories={categories}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
onSuccess={() => {}}
|
||||
|
|
@ -162,6 +184,7 @@ function CategoryRow({ row }: { row: Row<Category> }) {
|
|||
|
||||
<DeleteCategoryDialog
|
||||
category={category}
|
||||
categories={categories}
|
||||
open={deleteOpen}
|
||||
onOpenChange={setDeleteOpen}
|
||||
onSuccess={() => {}}
|
||||
|
|
@ -172,9 +195,54 @@ function CategoryRow({ row }: { row: Row<Category> }) {
|
|||
|
||||
export default function Categories() {
|
||||
const { categories } = usePage<{ categories: Category[] }>().props;
|
||||
const [sorting, setSorting] = useState<SortingState>([
|
||||
{ id: 'name', desc: false },
|
||||
]);
|
||||
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
const toggleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc'));
|
||||
return;
|
||||
}
|
||||
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
};
|
||||
|
||||
// Sort siblings by the chosen column at every level so subcategories stay
|
||||
// grouped under their parent, then flatten to depth-first display order.
|
||||
const orderedCategories = useMemo<CategoryNode[]>(() => {
|
||||
const compare = (a: Category, b: Category) => {
|
||||
const result = String(a[sortField] ?? '').localeCompare(
|
||||
String(b[sortField] ?? ''),
|
||||
);
|
||||
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
};
|
||||
|
||||
return flattenCategoryTree(buildCategoryTree(categories, compare));
|
||||
}, [categories, sortField, sortDirection]);
|
||||
|
||||
const sortHeader = (field: SortField, label: string) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="px-3"
|
||||
onClick={() => toggleSort(field)}
|
||||
>
|
||||
{label}
|
||||
{sortField === field ? (
|
||||
sortDirection === 'asc' ? (
|
||||
<ArrowUp className="ml-2 h-4 w-4" />
|
||||
) : (
|
||||
<ArrowDown className="ml-2 h-4 w-4" />
|
||||
)
|
||||
) : (
|
||||
<ArrowUpDown className="ml-2 h-4 w-4 opacity-40" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
|
||||
{},
|
||||
|
|
@ -183,20 +251,7 @@ export default function Categories() {
|
|||
const columns: ColumnDef<Category>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === 'asc')
|
||||
}
|
||||
>
|
||||
{__('Name')}
|
||||
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
header: () => sortHeader('name', __('Name')),
|
||||
cell: ({ row }) => {
|
||||
const iconName = row.original.icon;
|
||||
const IconComponent = Icons[iconName as keyof typeof Icons] as
|
||||
|
|
@ -204,19 +259,32 @@ export default function Categories() {
|
|||
| undefined;
|
||||
const Icon = IconComponent || Icons.Tag;
|
||||
|
||||
const depth = (row.original as CategoryNode).depth ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 pl-3">
|
||||
<div
|
||||
className="flex items-center gap-3 pl-3"
|
||||
style={{ paddingLeft: `${0.75 + depth * 1.5}rem` }}
|
||||
>
|
||||
{depth > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="text-muted-foreground select-none"
|
||||
>
|
||||
└
|
||||
</span>
|
||||
)}
|
||||
<Icon className="h-4 w-4 opacity-80" />
|
||||
<div className="font-medium">
|
||||
<span className="font-medium">
|
||||
{row.getValue('name')}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'color',
|
||||
header: __('Color'),
|
||||
header: () => sortHeader('color', __('Color')),
|
||||
cell: ({ row }) => {
|
||||
const color = row.getValue('color') as Category['color'];
|
||||
if (!color) {
|
||||
|
|
@ -237,7 +305,7 @@ export default function Categories() {
|
|||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: __('Type'),
|
||||
header: () => sortHeader('type', __('Type')),
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue('type') as Category['type'];
|
||||
const cashflowDirection = row.original.cashflow_direction;
|
||||
|
|
@ -335,12 +403,17 @@ export default function Categories() {
|
|||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
cell: ({ row }) => <CategoryActions category={row.original} />,
|
||||
cell: ({ row }) => (
|
||||
<CategoryActions
|
||||
category={row.original}
|
||||
categories={categories}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: categories,
|
||||
data: orderedCategories,
|
||||
columns,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
|
|
@ -383,7 +456,10 @@ export default function Categories() {
|
|||
className="max-w-sm"
|
||||
/>
|
||||
|
||||
<CreateCategoryDialog onSuccess={() => {}} />
|
||||
<CreateCategoryDialog
|
||||
categories={categories}
|
||||
onSuccess={() => {}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
|
|
@ -423,6 +499,7 @@ export default function Categories() {
|
|||
<CategoryRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
categories={categories}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -136,6 +136,8 @@ export const CATEGORY_CASHFLOW_DIRECTIONS = [
|
|||
export type CategoryCashflowDirection =
|
||||
(typeof CATEGORY_CASHFLOW_DIRECTIONS)[number];
|
||||
|
||||
export const CATEGORY_MAX_DEPTH = 3;
|
||||
|
||||
export interface Category {
|
||||
id: UUID;
|
||||
name: string;
|
||||
|
|
@ -143,6 +145,7 @@ export interface Category {
|
|||
color: CategoryColor;
|
||||
type: CategoryType;
|
||||
cashflow_direction: CategoryCashflowDirection;
|
||||
parent_id: UUID | null;
|
||||
}
|
||||
|
||||
export function getCategoryColorClasses(color: CategoryColor): {
|
||||
|
|
|
|||
|
|
@ -285,13 +285,13 @@ it('can use is empty operator for nullable fields', function () {
|
|||
->wait(1)
|
||||
->click('button:has-text("Create Rule")')
|
||||
->wait(0.5)
|
||||
->fill('title', 'Empty Category Rule')
|
||||
->fill('title', 'Empty Creditor Rule')
|
||||
->click('button:has-text("Description")')
|
||||
->wait(0.5)
|
||||
->click('[role="option"]:has-text("Category")')
|
||||
->click('[role="option"]:has-text("Creditor Name")')
|
||||
->wait(1)
|
||||
// Click the operator dropdown - it shows "equals" by default for Category field
|
||||
->click('button[role="combobox"]:has-text("equals")')
|
||||
// Click the operator dropdown - it shows "contains" by default for Creditor Name
|
||||
->click('button[role="combobox"]:has-text("contains")')
|
||||
->wait(0.5)
|
||||
->click('[role="option"]:has-text("is empty")')
|
||||
->wait(0.5)
|
||||
|
|
@ -303,11 +303,11 @@ it('can use is empty operator for nullable fields', function () {
|
|||
|
||||
$page->navigate('/settings/automation-rules')->wait(1);
|
||||
|
||||
$page->assertSee('Empty Category Rule')
|
||||
$page->assertSee('Empty Creditor Rule')
|
||||
->assertNoJavascriptErrors();
|
||||
|
||||
$this->assertDatabaseHas('automation_rules', [
|
||||
'user_id' => $user->id,
|
||||
'title' => 'Empty Category Rule',
|
||||
'title' => 'Empty Creditor Rule',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -703,3 +703,58 @@ test('assignTransaction matches a budget tracking multiple categories', function
|
|||
->where('budget_period_id', $period->id)
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('a budget tracking a parent category includes child category transactions historically', function () {
|
||||
$parent = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
$child = Category::factory()->childOf($parent)->create(['user_id' => $this->user->id]);
|
||||
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'category_id' => $parent->id,
|
||||
'transaction_date' => now()->subDay(),
|
||||
'amount' => -1000,
|
||||
]);
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'category_id' => $child->id,
|
||||
'transaction_date' => now()->subDay(),
|
||||
'amount' => -2000,
|
||||
]);
|
||||
|
||||
$budget = Budget::factory()->forCategories($parent)->create(['user_id' => $this->user->id]);
|
||||
$period = BudgetPeriod::factory()->create([
|
||||
'budget_id' => $budget->id,
|
||||
'start_date' => now()->subDays(30),
|
||||
'end_date' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
$count = $this->service->assignHistoricalTransactionsToPeriod($period);
|
||||
|
||||
expect($count)->toBe(2);
|
||||
expect((int) $period->budgetTransactions()->sum('amount'))->toBe(3000);
|
||||
});
|
||||
|
||||
test('assigning a child category transaction matches a budget tracking the parent', function () {
|
||||
$parent = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
$child = Category::factory()->childOf($parent)->create(['user_id' => $this->user->id]);
|
||||
|
||||
$budget = Budget::factory()->forCategories($parent)->create(['user_id' => $this->user->id]);
|
||||
$period = BudgetPeriod::factory()->create([
|
||||
'budget_id' => $budget->id,
|
||||
'start_date' => now()->subDays(30),
|
||||
'end_date' => now()->addDays(30),
|
||||
]);
|
||||
|
||||
$transaction = Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'category_id' => $child->id,
|
||||
'transaction_date' => now(),
|
||||
'amount' => -1500,
|
||||
]);
|
||||
|
||||
$this->service->assignTransaction($transaction);
|
||||
|
||||
expect(BudgetTransaction::where('transaction_id', $transaction->id)
|
||||
->where('budget_period_id', $period->id)
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1380,3 +1380,93 @@ test('breakdown includes unknown income category', function () {
|
|||
expect($unknownCategory['amount'])->toBe(50000);
|
||||
expect($unknownCategory['percentage'])->toEqual(20.0); // 50k / 250k = 20%
|
||||
});
|
||||
|
||||
test('sankey rolls child category amounts up to their top-level parent by default', function () {
|
||||
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||
$parent = Category::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'name' => 'Food',
|
||||
'type' => CategoryType::Expense,
|
||||
]);
|
||||
$child = Category::factory()->childOf($parent)->create([
|
||||
'user_id' => $this->user->id,
|
||||
'name' => 'Groceries',
|
||||
]);
|
||||
|
||||
// $100 directly on the parent, $50 on the child.
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $parent->id,
|
||||
'amount' => -10000,
|
||||
'transaction_date' => now(),
|
||||
]);
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $child->id,
|
||||
'amount' => -5000,
|
||||
'transaction_date' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
||||
'from' => now()->startOfMonth()->toDateString(),
|
||||
'to' => now()->endOfMonth()->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$expense = collect($response->json('expense_categories'));
|
||||
|
||||
expect($expense)->toHaveCount(1);
|
||||
$node = $expense->first();
|
||||
expect($node['category_id'])->toBe($parent->id)
|
||||
->and($node['amount'])->toBe(15000)
|
||||
->and($node['has_children'])->toBeTrue();
|
||||
});
|
||||
|
||||
test('drilling into a parent splits it into children plus a direct node', function () {
|
||||
$account = Account::factory()->create(['user_id' => $this->user->id]);
|
||||
$parent = Category::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'name' => 'Food',
|
||||
'type' => CategoryType::Expense,
|
||||
]);
|
||||
$child = Category::factory()->childOf($parent)->create([
|
||||
'user_id' => $this->user->id,
|
||||
'name' => 'Groceries',
|
||||
]);
|
||||
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $parent->id,
|
||||
'amount' => -10000,
|
||||
'transaction_date' => now(),
|
||||
]);
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $child->id,
|
||||
'amount' => -5000,
|
||||
'transaction_date' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
|
||||
'from' => now()->startOfMonth()->toDateString(),
|
||||
'to' => now()->endOfMonth()->toDateString(),
|
||||
'parent' => $parent->id,
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$expense = collect($response->json('expense_categories'));
|
||||
|
||||
expect($expense)->toHaveCount(2);
|
||||
|
||||
$childNode = $expense->firstWhere('is_direct', false);
|
||||
expect($childNode['category_id'])->toBe($child->id)
|
||||
->and($childNode['amount'])->toBe(5000);
|
||||
|
||||
$directNode = $expense->firstWhere('is_direct', true);
|
||||
expect($directNode['category_id'])->toBe($parent->id)
|
||||
->and($directNode['amount'])->toBe(10000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -333,6 +333,44 @@ test('top categories excludes soft deleted categories', function () {
|
|||
->and($response->json('0.category.id'))->toBe($activeCategory->id);
|
||||
});
|
||||
|
||||
test('top categories rolls child spending up into the top-level parent', function () {
|
||||
$food = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']);
|
||||
$groceries = Category::factory()->childOf($food)->create(['user_id' => $this->user->id, 'name' => 'Groceries']);
|
||||
$restaurants = Category::factory()->childOf($food)->create(['user_id' => $this->user->id, 'name' => 'Restaurants']);
|
||||
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'category_id' => $food->id,
|
||||
'amount' => -1000,
|
||||
'transaction_date' => now(),
|
||||
]);
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'category_id' => $groceries->id,
|
||||
'amount' => -2000,
|
||||
'transaction_date' => now(),
|
||||
]);
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'category_id' => $restaurants->id,
|
||||
'amount' => -3000,
|
||||
'transaction_date' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->getJson('/api/dashboard/top-categories?'.http_build_query([
|
||||
'from' => now()->startOfMonth()->toDateString(),
|
||||
'to' => now()->endOfMonth()->toDateString(),
|
||||
]));
|
||||
|
||||
$response->assertOk();
|
||||
$data = $response->json();
|
||||
|
||||
expect($data)->toHaveCount(1);
|
||||
expect($data[0]['category']['id'])->toBe($food->id);
|
||||
expect($data[0]['amount'])->toBe(6000);
|
||||
expect($data[0]['total_amount'])->toBe(6000);
|
||||
});
|
||||
|
||||
test('net worth evolution returns monthly data points with per-account balances', function () {
|
||||
$account1 = Account::factory()->create([
|
||||
'user_id' => $this->user->id,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
|
|
@ -49,3 +52,33 @@ test('authenticated users can visit the dashboard', function () {
|
|||
|
||||
$this->get(route('dashboard'))->assertOk();
|
||||
});
|
||||
|
||||
test('dashboard top categories roll child spending up into the parent', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
$food = Category::factory()->create(['user_id' => $user->id, 'type' => CategoryType::Expense, 'name' => 'Food']);
|
||||
$groceries = Category::factory()->childOf($food)->create(['user_id' => $user->id, 'name' => 'Groceries']);
|
||||
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => $food->id,
|
||||
'amount' => -1000,
|
||||
'transaction_date' => now(),
|
||||
]);
|
||||
Transaction::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'category_id' => $groceries->id,
|
||||
'amount' => -2000,
|
||||
'transaction_date' => now(),
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($user)->withoutVite()->get(route('dashboard'), [
|
||||
'X-Inertia' => 'true',
|
||||
'X-Inertia-Partial-Component' => 'dashboard',
|
||||
'X-Inertia-Partial-Data' => 'topCategories',
|
||||
]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertJsonCount(1, 'props.topCategories')
|
||||
->assertJsonPath('props.topCategories.0.category.id', $food->id)
|
||||
->assertJsonPath('props.topCategories.0.amount', 3000);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -550,6 +550,34 @@ test('default categories are created when user registers', function () {
|
|||
->cashflow_direction->toBe(CategoryCashflowDirection::Inflow);
|
||||
});
|
||||
|
||||
test('default categories nest children under their configured parent', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
(new CreateDefaultCategories)->handle($user);
|
||||
|
||||
$categories = $user->categories()->get()->keyBy('name');
|
||||
|
||||
expect($categories->get('Food')->parent_id)->toBeNull();
|
||||
expect($categories->get('Groceries')->parent_id)->toBe($categories->get('Food')->id);
|
||||
expect($categories->get('Electricity')->parent_id)->toBe($categories->get('Utility services')->id);
|
||||
expect($categories->get('Fuel')->parent_id)->toBe($categories->get('Transportation')->id);
|
||||
expect($categories->get('Other investments')->parent_id)->toBe($categories->get('Investments')->id);
|
||||
expect($categories->get('Salary')->parent_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('default child categories attach to an already existing parent', function () {
|
||||
$user = User::factory()->create();
|
||||
$food = Category::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Food',
|
||||
'type' => CategoryType::Expense,
|
||||
]);
|
||||
|
||||
(new CreateDefaultCategories)->handle($user);
|
||||
|
||||
expect($user->categories()->firstWhere('name', 'Groceries')->parent_id)->toBe($food->id);
|
||||
});
|
||||
|
||||
test('default categories are not created twice for the same user', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
|
|
@ -569,7 +597,7 @@ test('default categories are created without repeated category lookups', functio
|
|||
$categorySelects = 0;
|
||||
|
||||
DB::listen(function ($query) use (&$categorySelects) {
|
||||
if (str_starts_with($query->sql, 'select `name` from `categories`')) {
|
||||
if (str_starts_with($query->sql, 'select `id`, `name` from `categories`')) {
|
||||
$categorySelects++;
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,217 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\Account;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Services\CategoryTree;
|
||||
|
||||
test('a child category inherits its parent type and cashflow direction', function () {
|
||||
$user = User::factory()->create();
|
||||
$parent = Category::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Transfers',
|
||||
'type' => CategoryType::Transfer,
|
||||
'cashflow_direction' => CategoryCashflowDirection::Inflow,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->post(route('categories.store'), [
|
||||
'name' => 'Family transfers',
|
||||
'parent_id' => $parent->id,
|
||||
'icon' => 'Users',
|
||||
'color' => 'blue',
|
||||
'type' => CategoryType::Expense->value,
|
||||
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
|
||||
])->assertRedirect(route('categories.index'));
|
||||
|
||||
$this->assertDatabaseHas('categories', [
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Family transfers',
|
||||
'parent_id' => $parent->id,
|
||||
'type' => CategoryType::Transfer->value,
|
||||
'cashflow_direction' => CategoryCashflowDirection::Inflow->value,
|
||||
]);
|
||||
});
|
||||
|
||||
test('categories cannot be nested deeper than the max depth', function () {
|
||||
$user = User::factory()->create();
|
||||
$root = Category::factory()->create(['user_id' => $user->id, 'type' => CategoryType::Expense]);
|
||||
$level2 = Category::factory()->childOf($root)->create(['user_id' => $user->id]);
|
||||
|
||||
// Third level is allowed.
|
||||
$this->actingAs($user)->post(route('categories.store'), [
|
||||
'name' => 'Level three',
|
||||
'parent_id' => $level2->id,
|
||||
'icon' => 'Tag',
|
||||
'color' => 'blue',
|
||||
'type' => CategoryType::Expense->value,
|
||||
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
|
||||
])->assertRedirect(route('categories.index'));
|
||||
|
||||
$level3 = $user->categories()->where('name', 'Level three')->firstOrFail();
|
||||
|
||||
// Fourth level is rejected.
|
||||
$this->actingAs($user)->post(route('categories.store'), [
|
||||
'name' => 'Level four',
|
||||
'parent_id' => $level3->id,
|
||||
'icon' => 'Tag',
|
||||
'color' => 'blue',
|
||||
'type' => CategoryType::Expense->value,
|
||||
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
|
||||
])->assertSessionHasErrors(['parent_id']);
|
||||
});
|
||||
|
||||
test('a category cannot be moved under one of its own descendants', function () {
|
||||
$user = User::factory()->create();
|
||||
$root = Category::factory()->create(['user_id' => $user->id, 'type' => CategoryType::Expense]);
|
||||
$child = Category::factory()->childOf($root)->create(['user_id' => $user->id]);
|
||||
|
||||
$this->actingAs($user)->patch(route('categories.update', $root), [
|
||||
'name' => $root->name,
|
||||
'parent_id' => $child->id,
|
||||
'icon' => 'Tag',
|
||||
'color' => 'blue',
|
||||
'type' => CategoryType::Expense->value,
|
||||
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
|
||||
])->assertSessionHasErrors(['parent_id']);
|
||||
});
|
||||
|
||||
test('the same name is allowed under different parents but blocked under the same parent', function () {
|
||||
$user = User::factory()->create();
|
||||
$food = Category::factory()->create(['user_id' => $user->id, 'name' => 'Food', 'type' => CategoryType::Expense]);
|
||||
$drinks = Category::factory()->create(['user_id' => $user->id, 'name' => 'Drinks', 'type' => CategoryType::Expense]);
|
||||
|
||||
Category::factory()->childOf($food)->create(['user_id' => $user->id, 'name' => 'Coffee']);
|
||||
|
||||
// Same name under a different parent: allowed.
|
||||
$this->actingAs($user)->post(route('categories.store'), [
|
||||
'name' => 'Coffee',
|
||||
'parent_id' => $drinks->id,
|
||||
'icon' => 'Tag',
|
||||
'color' => 'blue',
|
||||
'type' => CategoryType::Expense->value,
|
||||
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
|
||||
])->assertRedirect(route('categories.index'));
|
||||
|
||||
// Same name under the same parent: blocked.
|
||||
$this->actingAs($user)->post(route('categories.store'), [
|
||||
'name' => 'Coffee',
|
||||
'parent_id' => $food->id,
|
||||
'icon' => 'Tag',
|
||||
'color' => 'blue',
|
||||
'type' => CategoryType::Expense->value,
|
||||
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
|
||||
])->assertSessionHasErrors(['name']);
|
||||
|
||||
expect($user->categories()->where('name', 'Coffee')->count())->toBe(2);
|
||||
});
|
||||
|
||||
test('deleting a parent lifts its children up to the grandparent', function () {
|
||||
$user = User::factory()->create();
|
||||
$root = Category::factory()->create(['user_id' => $user->id, 'type' => CategoryType::Expense]);
|
||||
$parent = Category::factory()->childOf($root)->create(['user_id' => $user->id]);
|
||||
$child = Category::factory()->childOf($parent)->create(['user_id' => $user->id]);
|
||||
|
||||
$this->actingAs($user)->delete(route('categories.destroy', $parent))
|
||||
->assertRedirect(route('categories.index'));
|
||||
|
||||
$this->assertSoftDeleted('categories', ['id' => $parent->id]);
|
||||
expect($child->refresh()->parent_id)->toBe($root->id);
|
||||
});
|
||||
|
||||
test('deleting a parent with the promote strategy turns its children into roots', function () {
|
||||
$user = User::factory()->create();
|
||||
$root = Category::factory()->create(['user_id' => $user->id, 'type' => CategoryType::Expense]);
|
||||
$child = Category::factory()->childOf($root)->create(['user_id' => $user->id]);
|
||||
|
||||
$this->actingAs($user)->delete(route('categories.destroy', $root), ['strategy' => 'promote'])
|
||||
->assertRedirect(route('categories.index'));
|
||||
|
||||
$this->assertSoftDeleted('categories', ['id' => $root->id]);
|
||||
expect($child->refresh()->parent_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('deleting a parent with the cascade strategy removes the subtree and uncategorizes transactions', function () {
|
||||
$user = User::factory()->create();
|
||||
$account = Account::factory()->create(['user_id' => $user->id]);
|
||||
$root = Category::factory()->create(['user_id' => $user->id, 'type' => CategoryType::Expense]);
|
||||
$child = Category::factory()->childOf($root)->create(['user_id' => $user->id]);
|
||||
|
||||
$transaction = Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $user->id,
|
||||
'account_id' => $account->id,
|
||||
'category_id' => $child->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user)->delete(route('categories.destroy', $root), ['strategy' => 'cascade'])
|
||||
->assertRedirect(route('categories.index'));
|
||||
|
||||
$this->assertSoftDeleted('categories', ['id' => $root->id]);
|
||||
$this->assertSoftDeleted('categories', ['id' => $child->id]);
|
||||
expect($transaction->refresh()->category_id)->toBeNull();
|
||||
});
|
||||
|
||||
test('deleting a parent with the promote strategy rejects child name collisions at the top level', function () {
|
||||
$user = User::factory()->create();
|
||||
Category::factory()->create(['user_id' => $user->id, 'name' => 'Other', 'type' => CategoryType::Expense]);
|
||||
$root = Category::factory()->create(['user_id' => $user->id, 'name' => 'Food', 'type' => CategoryType::Expense]);
|
||||
$child = Category::factory()->childOf($root)->create(['user_id' => $user->id, 'name' => 'Other']);
|
||||
|
||||
$this->actingAs($user)->delete(route('categories.destroy', $root), ['strategy' => 'promote'])
|
||||
->assertSessionHasErrors(['strategy']);
|
||||
|
||||
$this->assertNotSoftDeleted('categories', ['id' => $root->id]);
|
||||
expect($child->refresh()->parent_id)->toBe($root->id);
|
||||
});
|
||||
|
||||
test('deleting a parent with the reparent strategy rejects child name collisions at the destination level', function () {
|
||||
$user = User::factory()->create();
|
||||
$root = Category::factory()->create(['user_id' => $user->id, 'name' => 'Food', 'type' => CategoryType::Expense]);
|
||||
Category::factory()->childOf($root)->create(['user_id' => $user->id, 'name' => 'Other']);
|
||||
$parent = Category::factory()->childOf($root)->create(['user_id' => $user->id, 'name' => 'Eating out']);
|
||||
$child = Category::factory()->childOf($parent)->create(['user_id' => $user->id, 'name' => 'Other']);
|
||||
|
||||
$this->actingAs($user)->delete(route('categories.destroy', $parent))
|
||||
->assertSessionHasErrors(['strategy']);
|
||||
|
||||
$this->assertNotSoftDeleted('categories', ['id' => $parent->id]);
|
||||
expect($child->refresh()->parent_id)->toBe($parent->id);
|
||||
});
|
||||
|
||||
test('updating a parent type cascades to all descendants', function () {
|
||||
$user = User::factory()->create();
|
||||
$root = Category::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'type' => CategoryType::Expense,
|
||||
'cashflow_direction' => CategoryCashflowDirection::Hidden,
|
||||
]);
|
||||
$child = Category::factory()->childOf($root)->create(['user_id' => $user->id]);
|
||||
$grandchild = Category::factory()->childOf($child)->create(['user_id' => $user->id]);
|
||||
|
||||
$this->actingAs($user)->patch(route('categories.update', $root), [
|
||||
'name' => $root->name,
|
||||
'parent_id' => null,
|
||||
'icon' => 'Tag',
|
||||
'color' => 'blue',
|
||||
'type' => CategoryType::Income->value,
|
||||
'cashflow_direction' => CategoryCashflowDirection::Hidden->value,
|
||||
])->assertRedirect(route('categories.index'));
|
||||
|
||||
expect($child->refresh()->type)->toBe(CategoryType::Income)
|
||||
->and($grandchild->refresh()->type)->toBe(CategoryType::Income);
|
||||
});
|
||||
|
||||
test('the tree service expands a parent id to include all descendants', function () {
|
||||
$user = User::factory()->create();
|
||||
$root = Category::factory()->create(['user_id' => $user->id, 'type' => CategoryType::Expense]);
|
||||
$child = Category::factory()->childOf($root)->create(['user_id' => $user->id]);
|
||||
$grandchild = Category::factory()->childOf($child)->create(['user_id' => $user->id]);
|
||||
$unrelated = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
$expanded = (new CategoryTree)->expand($user->id, [$root->id]);
|
||||
|
||||
expect($expanded)->toContain($root->id, $child->id, $grandchild->id)
|
||||
->not->toContain($unrelated->id);
|
||||
});
|
||||
|
|
@ -121,6 +121,30 @@ test('filter by category', function () {
|
|||
);
|
||||
});
|
||||
|
||||
test('filtering by a parent category includes transactions from its descendants', function () {
|
||||
$parent = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
$child = Category::factory()->childOf($parent)->create(['user_id' => $this->user->id]);
|
||||
$grandchild = Category::factory()->childOf($child)->create(['user_id' => $this->user->id]);
|
||||
$unrelated = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
foreach ([$parent, $child, $grandchild, $unrelated] as $category) {
|
||||
Transaction::factory()->plaintext()->create([
|
||||
'user_id' => $this->user->id,
|
||||
'account_id' => $this->account->id,
|
||||
'category_id' => $category->id,
|
||||
]);
|
||||
}
|
||||
|
||||
$response = actingAs($this->user)->get(route('transactions.index', [
|
||||
'category_ids' => $parent->id,
|
||||
]));
|
||||
|
||||
// Parent + child + grandchild, but not the unrelated category.
|
||||
$response->assertInertia(fn ($page) => $page
|
||||
->has('transactions.data', 3)
|
||||
);
|
||||
});
|
||||
|
||||
test('filter by uncategorized', function () {
|
||||
$category = Category::factory()->create(['user_id' => $this->user->id]);
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ test('transactions index page does not exceed query threshold', function () {
|
|||
});
|
||||
|
||||
test('budgets index page does not exceed query threshold', function () {
|
||||
assertMaxQueries(18, function () {
|
||||
assertMaxQueries(21, function () {
|
||||
$this->get(route('budgets.index'))->assertOk();
|
||||
}, 'Budgets Index');
|
||||
});
|
||||
|
|
@ -66,7 +66,7 @@ test('budgets index page does not exceed query threshold', function () {
|
|||
test('budget show page does not exceed query threshold', function () {
|
||||
$budget = $this->user->budgets()->first();
|
||||
|
||||
assertMaxQueries(20, function () use ($budget) {
|
||||
assertMaxQueries(22, function () use ($budget) {
|
||||
$this->get(route('budgets.show', $budget))->assertOk();
|
||||
}, 'Budget Show');
|
||||
});
|
||||
|
|
@ -82,7 +82,7 @@ test('cashflow page does not exceed query threshold', function () {
|
|||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('settings accounts page does not exceed query threshold', function () {
|
||||
assertMaxQueries(15, function () {
|
||||
assertMaxQueries(17, function () {
|
||||
$this->get(route('accounts.index'))->assertOk();
|
||||
}, 'Settings Accounts');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue