Update default categories

This commit is contained in:
Víctor Falcón 2025-11-11 18:10:32 +00:00
parent b866e6152e
commit 2c696aa88a
14 changed files with 333 additions and 161 deletions

View File

@ -11,19 +11,77 @@ class CreateDefaultCategories
*/
public function handle(User $user): void
{
$defaultCategories = [
['name' => 'Groceries', 'icon' => 'ShoppingCart', 'color' => 'green'],
['name' => 'Transportation', 'icon' => 'Car', 'color' => 'blue'],
['name' => 'Entertainment', 'icon' => 'Film', 'color' => 'purple'],
['name' => 'Dining Out', 'icon' => 'Utensils', 'color' => 'orange'],
['name' => 'Healthcare', 'icon' => 'Heart', 'color' => 'red'],
['name' => 'Utilities', 'icon' => 'Zap', 'color' => 'yellow'],
['name' => 'Shopping', 'icon' => 'ShoppingBag', 'color' => 'pink'],
['name' => 'Travel', 'icon' => 'Plane', 'color' => 'sky'],
];
$defaultCategories = self::getDefaultCategories();
foreach ($defaultCategories as $category) {
$user->categories()->create($category);
}
}
/**
* Get the default categories configuration.
*
* @return array<int, array{name: string, icon: string, color: string}>
*/
public static function getDefaultCategories(): array
{
return
['name' => 'Administrative violations', 'icon' => 'AlertTriangle', 'color' => 'red'],
['name' => 'Alimony and child support', 'icon' => 'Users', 'color' => 'teal'],
['name' => 'Association membership fees', 'icon' => 'Users2', 'color' => 'cyan'],
['name' => 'Bailiffs', 'icon' => 'Scale', 'color' => 'amber'],
['name' => 'Bills', 'icon' => 'FileText', 'color' => 'red'],
['name' => 'Cash deposit', 'icon' => 'Banknote', 'color' => 'green'],
['name' => 'Cash withdrawal', 'icon' => 'Banknote', 'color' => 'emerald'],
['name' => 'Cashback rewards', 'icon' => 'Gift', 'color' => 'pink'],
['name' => 'Cheque', 'icon' => 'Receipt', 'color' => 'gray'],
['name' => 'Cheque deposit', 'icon' => 'Receipt', 'color' => 'green'],
['name' => 'Children', 'icon' => 'Baby', 'color' => 'yellow'],
['name' => 'Clothing and shoes', 'icon' => 'ShoppingBag', 'color' => 'pink'],
['name' => 'Currency exchange', 'icon' => 'Repeat', 'color' => 'green'],
['name' => 'Debt collection', 'icon' => 'FileText', 'color' => 'orange'],
['name' => 'Dishonors', 'icon' => 'AlertCircle', 'color' => 'red'],
['name' => 'Dividends Income', 'icon' => 'TrendingUp', 'color' => 'emerald'],
['name' => 'Education, health and beauty', 'icon' => 'Heart', 'color' => 'red'],
['name' => 'Enquiries', 'icon' => 'HelpCircle', 'color' => 'blue'],
['name' => 'Entertainment', 'icon' => 'Heart', 'color' => 'purple'],
['name' => 'Financial services and commission', 'icon' => 'Building2', 'color' => 'slate'],
['name' => 'Food', 'icon' => 'Utensils', 'color' => 'orange'],
['name' => 'Gambling', 'icon' => 'Dices', 'color' => 'red'],
['name' => 'Groceries', 'icon' => 'ShoppingBag', 'color' => 'green'],
['name' => 'Healthcare', 'icon' => 'Heart', 'color' => 'red'],
['name' => 'Household goods', 'icon' => 'Home', 'color' => 'green'],
['name' => 'Income from rent', 'icon' => 'Building', 'color' => 'indigo'],
['name' => 'Insurance', 'icon' => 'Shield', 'color' => 'cyan'],
['name' => 'Insurance indemnity', 'icon' => 'ShieldCheck', 'color' => 'cyan'],
['name' => 'Interest received', 'icon' => 'TrendingUp', 'color' => 'emerald'],
['name' => 'Invoices', 'icon' => 'FileText', 'color' => 'indigo'],
['name' => 'Leisure activities, traveling', 'icon' => 'Plane', 'color' => 'purple'],
['name' => 'Loans', 'icon' => 'Landmark', 'color' => 'gray'],
['name' => 'Online transactions', 'icon' => 'CreditCard', 'color' => 'indigo'],
['name' => 'Other incoming payments', 'icon' => 'ArrowUpCircle', 'color' => 'green'],
['name' => 'Other incoming payments from employer', 'icon' => 'Briefcase', 'color' => 'blue'],
['name' => 'Other outgoing payments', 'icon' => 'ArrowDownCircle', 'color' => 'gray'],
['name' => 'Pension', 'icon' => 'Wallet', 'color' => 'teal'],
['name' => 'Personal transfers', 'icon' => 'ArrowLeftRight', 'color' => 'purple'],
['name' => 'Postal services', 'icon' => 'Mail', 'color' => 'blue'],
['name' => 'Regular income', 'icon' => 'TrendingUp', 'color' => 'emerald'],
['name' => 'Restaurants', 'icon' => 'Utensils', 'color' => 'orange'],
['name' => 'Return debit', 'icon' => 'Undo2', 'color' => 'amber'],
['name' => 'Returned payments', 'icon' => 'RotateCcw', 'color' => 'orange'],
['name' => 'Salary', 'icon' => 'Wallet', 'color' => 'green'],
['name' => 'Savings and investments', 'icon' => 'PiggyBank', 'color' => 'green'],
['name' => 'Scholarships', 'icon' => 'GraduationCap', 'color' => 'purple'],
['name' => 'Shopping', 'icon' => 'ShoppingBag', 'color' => 'pink'],
['name' => 'Social security benefits', 'icon' => 'HandHeart', 'color' => 'cyan'],
['name' => 'State aid payments', 'icon' => 'Landmark', 'color' => 'blue'],
['name' => 'Tax return', 'icon' => 'ReceiptText', 'color' => 'green'],
['name' => 'Taxes and government fees', 'icon' => 'Receipt', 'color' => 'slate'],
['name' => 'Transportation', 'icon' => 'Car', 'color' => 'blue'],
['name' => 'Travel', 'icon' => 'Plane', 'color' => 'blue'],
['name' => 'Utilities', 'icon' => 'Home', 'color' => 'yellow'],
['name' => 'Utility services', 'icon' => 'Home', 'color' => 'blue'],
['name' => 'Work on demand', 'icon' => 'Clock', 'color' => 'orange'],
];
}
}

View File

@ -0,0 +1,64 @@
<?php
namespace App\Console\Commands;
use App\Actions\CreateDefaultCategories;
use App\Models\User;
use Illuminate\Console\Command;
class ResetUserCategories extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'categories:reset {user : The ID or email of the user}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete all categories for a user and create the default ones';
/**
* Execute the console command.
*/
public function handle(): int
{
$userIdentifier = $this->argument('user');
$user = is_numeric($userIdentifier)
? User::find($userIdentifier)
: User::where('email', $userIdentifier)->first();
if (! $user) {
$this->error("User not found: {$userIdentifier}");
return self::FAILURE;
}
$this->info("Resetting categories for user: {$user->name} ({$user->email})");
$categoriesCount = $user->categories()->count();
if ($categoriesCount > 0) {
$user->categories()->delete();
$this->info("Deleted {$categoriesCount} existing categories.");
} else {
$this->info('No existing categories found.');
}
$createDefaultCategories = new CreateDefaultCategories;
$createDefaultCategories->handle($user);
$newCategoriesCount = $user->categories()->count();
$this->info("Created {$newCategoriesCount} default categories.");
$this->newLine();
$this->info('✓ Categories reset successfully!');
return self::SUCCESS;
}
}

View File

@ -30,23 +30,20 @@ class StoreCategoryRequest extends FormRequest
'required',
'string',
Rule::in([
'red',
'orange',
'amber',
'yellow',
'lime',
'green',
'emerald',
'teal',
'cyan',
'sky',
'blue',
'cyan',
'emerald',
'gray',
'green',
'indigo',
'violet',
'purple',
'fuchsia',
'orange',
'pink',
'rose',
'purple',
'red',
'slate',
'teal',
'yellow',
]),
],
];

View File

@ -30,23 +30,20 @@ class UpdateCategoryRequest extends FormRequest
'required',
'string',
Rule::in([
'red',
'orange',
'amber',
'yellow',
'lime',
'green',
'emerald',
'teal',
'cyan',
'sky',
'blue',
'cyan',
'emerald',
'gray',
'green',
'indigo',
'violet',
'purple',
'fuchsia',
'orange',
'pink',
'rose',
'purple',
'red',
'slate',
'teal',
'yellow',
]),
],
];

View File

@ -19,8 +19,8 @@ class CategoryFactory extends Factory
{
return [
'name' => fake()->randomElement(['Food', 'Transport', 'Entertainment', 'Shopping', 'Healthcare', 'Utilities', 'Travel', 'Education']),
'icon' => fake()->randomElement(['🍔', '🚗', '🎮', '🛒', '💊', '💡', '✈️', '📚']),
'color' => fake()->hexColor(),
'icon' => fake()->randomElement(['AlertCircle', 'AlertTriangle', 'ArrowDownCircle', 'ArrowLeftRight', 'ArrowUpCircle', 'Baby', 'Banknote', 'Briefcase', 'Building', 'Building2', 'Car', 'Clock', 'CreditCard', 'Dices', 'FileText', 'Gift', 'GraduationCap', 'HandHeart', 'Heart', 'HelpCircle', 'Home', 'Landmark', 'Mail', 'PiggyBank', 'Plane', 'Receipt', 'ReceiptText', 'Repeat', 'RotateCcw', 'Scale', 'Shield', 'ShieldCheck', 'ShoppingBag', 'TrendingUp', 'Undo2', 'Users', 'Users2', 'Utensils', 'Wallet']),
'color' => fake()->randomElement(['amber', 'blue', 'cyan', 'emerald', 'gray', 'green', 'indigo', 'orange', 'pink', 'purple', 'red', 'slate', 'teal', 'yellow']),
'user_id' => User::factory(),
];
}

View File

@ -2,6 +2,7 @@
namespace Database\Seeders;
use App\Actions\CreateDefaultCategories;
use App\Models\Category;
use App\Models\User;
use Illuminate\Database\Seeder;
@ -19,16 +20,7 @@ class CategorySeeder extends Seeder
return;
}
$categories = [
['name' => 'Food & Dining', 'icon' => '🍔', 'color' => '#FF6B6B'],
['name' => 'Transportation', 'icon' => '🚗', 'color' => '#4ECDC4'],
['name' => 'Entertainment', 'icon' => '🎮', 'color' => '#95E1D3'],
['name' => 'Shopping', 'icon' => '🛒', 'color' => '#F38181'],
['name' => 'Healthcare', 'icon' => '💊', 'color' => '#AA96DA'],
['name' => 'Utilities', 'icon' => '💡', 'color' => '#FCBAD3'],
['name' => 'Travel', 'icon' => '✈️', 'color' => '#FFFFD2'],
['name' => 'Education', 'icon' => '📚', 'color' => '#A8D8EA'],
];
$categories = CreateDefaultCategories::getDefaultCategories();
foreach ($users as $user) {
foreach ($categories as $category) {

View File

@ -18,6 +18,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { categorySyncService } from '@/services/category-sync';
import {
CATEGORY_COLORS,
CATEGORY_ICONS,
@ -48,7 +49,8 @@ export function CreateCategoryDialog({
</DialogHeader>
<Form
{...store.form()}
onSuccess={() => {
onSuccess={async () => {
await categorySyncService.sync();
setOpen(false);
onSuccess?.();
}}

View File

@ -8,6 +8,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { categorySyncService } from '@/services/category-sync';
import { type Category } from '@/types/category';
import { Form } from '@inertiajs/react';
@ -36,7 +37,8 @@ export function DeleteCategoryDialog({
</DialogHeader>
<Form
{...destroy.form.delete(category.id)}
onSuccess={() => {
onSuccess={async () => {
await categorySyncService.delete(category.id);
onOpenChange(false);
onSuccess?.();
}}

View File

@ -17,6 +17,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { categorySyncService } from '@/services/category-sync';
import {
CATEGORY_COLORS,
CATEGORY_ICONS,
@ -50,7 +51,8 @@ export function EditCategoryDialog({
</DialogHeader>
<Form
{...update.form.patch(category.id)}
onSuccess={() => {
onSuccess={async () => {
await categorySyncService.sync();
onOpenChange(false);
onSuccess?.();
}}

View File

@ -5,7 +5,6 @@ import {
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
@ -140,7 +139,13 @@ export default function Categories() {
header: 'Color',
cell: ({ row }) => {
const color = row.getValue('color') as Category['color'];
if (!color) {
return null;
}
const colorClasses = getCategoryColorClasses(color);
if (!colorClasses) {
return null;
}
return (
<Badge
className={`${colorClasses.bg} ${colorClasses.text} text-[10px] tracking-widest`}
@ -163,7 +168,6 @@ export default function Categories() {
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
@ -273,29 +277,11 @@ export default function Categories() {
</Table>
</div>
<div className="flex items-center justify-end space-x-2">
<div className="flex-1 text-sm text-muted-foreground">
<div className="flex items-center justify-end">
<div className="text-sm text-muted-foreground">
{table.getFilteredRowModel().rows.length}{' '}
category(ies) total.
</div>
<div className="space-x-2">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Next
</Button>
</div>
</div>
</div>
</div>

View File

@ -1,44 +1,62 @@
export const CATEGORY_ICONS = [
'ShoppingCart',
'Home',
'Car',
'Utensils',
'Film',
'Heart',
'Zap',
'ShoppingBag',
'Plane',
'Coffee',
'Smartphone',
'Book',
'DollarSign',
'Gift',
'AlertCircle',
'AlertTriangle',
'ArrowDownCircle',
'ArrowLeftRight',
'ArrowUpCircle',
'Baby',
'Banknote',
'Briefcase',
'GamepadIcon',
'Music',
'Shirt',
'Building',
'Building2',
'Car',
'Clock',
'CreditCard',
'Dices',
'FileText',
'Gift',
'GraduationCap',
'HandHeart',
'Heart',
'HelpCircle',
'Home',
'Landmark',
'Mail',
'PiggyBank',
'Plane',
'Receipt',
'ReceiptText',
'Repeat',
'RotateCcw',
'Scale',
'Shield',
'ShieldCheck',
'ShoppingBag',
'TrendingUp',
'Undo2',
'Users',
'Users2',
'Utensils',
'Wallet',
] as const;
export type CategoryIcon = (typeof CATEGORY_ICONS)[number];
export const CATEGORY_COLORS = [
'red',
'orange',
'amber',
'yellow',
'lime',
'green',
'emerald',
'teal',
'cyan',
'sky',
'blue',
'cyan',
'emerald',
'gray',
'green',
'indigo',
'violet',
'purple',
'fuchsia',
'orange',
'pink',
'rose',
'purple',
'red',
'slate',
'teal',
'yellow',
] as const;
export type CategoryColor = (typeof CATEGORY_COLORS)[number];
@ -55,73 +73,61 @@ export function getCategoryColorClasses(color: CategoryColor): {
text: string;
} {
const colorMap: Record<CategoryColor, { bg: string; text: string }> = {
red: {
bg: 'bg-red-100 dark:bg-red-700',
text: 'text-red-700 dark:text-red-100',
},
orange: {
bg: 'bg-orange-100 dark:bg-orange-700',
text: 'text-orange-700 dark:text-orange-100',
},
amber: {
bg: 'bg-amber-100 dark:bg-amber-700',
text: 'text-amber-700 dark:text-amber-100',
},
yellow: {
bg: 'bg-yellow-100 dark:bg-yellow-700',
text: 'text-yellow-700 dark:text-yellow-100',
},
lime: {
bg: 'bg-lime-100 dark:bg-lime-700',
text: 'text-lime-700 dark:text-lime-100',
},
green: {
bg: 'bg-green-100 dark:bg-green-700',
text: 'text-green-700 dark:text-green-100',
},
emerald: {
bg: 'bg-emerald-100 dark:bg-emerald-700',
text: 'text-emerald-700 dark:text-emerald-100',
},
teal: {
bg: 'bg-teal-100 dark:bg-teal-700',
text: 'text-teal-700 dark:text-teal-100',
},
cyan: {
bg: 'bg-cyan-100 dark:bg-cyan-700',
text: 'text-cyan-700 dark:text-cyan-100',
},
sky: {
bg: 'bg-sky-100 dark:bg-sky-700',
text: 'text-sky-700 dark:text-sky-100',
},
blue: {
bg: 'bg-blue-100 dark:bg-blue-700',
text: 'text-blue-700 dark:text-blue-100',
},
cyan: {
bg: 'bg-cyan-100 dark:bg-cyan-700',
text: 'text-cyan-700 dark:text-cyan-100',
},
emerald: {
bg: 'bg-emerald-100 dark:bg-emerald-700',
text: 'text-emerald-700 dark:text-emerald-100',
},
gray: {
bg: 'bg-gray-100 dark:bg-gray-700',
text: 'text-gray-700 dark:text-gray-100',
},
green: {
bg: 'bg-green-100 dark:bg-green-700',
text: 'text-green-700 dark:text-green-100',
},
indigo: {
bg: 'bg-indigo-100 dark:bg-indigo-700',
text: 'text-indigo-700 dark:text-indigo-100',
},
violet: {
bg: 'bg-violet-100 dark:bg-violet-700',
text: 'text-violet-700 dark:text-violet-100',
},
purple: {
bg: 'bg-purple-100 dark:bg-purple-700',
text: 'text-purple-700 dark:text-purple-100',
},
fuchsia: {
bg: 'bg-fuchsia-100 dark:bg-fuchsia-700',
text: 'text-fuchsia-700 dark:text-fuchsia-100',
orange: {
bg: 'bg-orange-100 dark:bg-orange-700',
text: 'text-orange-700 dark:text-orange-100',
},
pink: {
bg: 'bg-pink-100 dark:bg-pink-700',
text: 'text-pink-700 dark:text-pink-100',
},
rose: {
bg: 'bg-rose-100 dark:bg-rose-700',
text: 'text-rose-700 dark:text-rose-100',
purple: {
bg: 'bg-purple-100 dark:bg-purple-700',
text: 'text-purple-700 dark:text-purple-100',
},
red: {
bg: 'bg-red-100 dark:bg-red-700',
text: 'text-red-700 dark:text-red-100',
},
slate: {
bg: 'bg-slate-100 dark:bg-slate-700',
text: 'text-slate-700 dark:text-slate-100',
},
teal: {
bg: 'bg-teal-100 dark:bg-teal-700',
text: 'text-teal-700 dark:text-teal-100',
},
yellow: {
bg: 'bg-yellow-100 dark:bg-yellow-700',
text: 'text-yellow-700 dark:text-yellow-100',
},
};

View File

@ -0,0 +1,66 @@
<?php
use App\Models\Category;
use App\Models\User;
test('command resets categories for a user by ID', function () {
$user = User::factory()->create();
Category::factory()->count(5)->create(['user_id' => $user->id]);
expect($user->categories()->count())->toBe(5);
$this->artisan('categories:reset', ['user' => $user->id])
->expectsOutput("Resetting categories for user: {$user->name} ({$user->email})")
->expectsOutput('Deleted 5 existing categories.')
->expectsOutput('Created 49 default categories.')
->expectsOutput('✓ Categories reset successfully!')
->assertSuccessful();
expect($user->fresh()->categories()->count())->toBe(49);
$categoryNames = $user->categories->pluck('name')->toArray();
expect($categoryNames)->toContain('Food', 'Transportation', 'Salary', 'Insurance');
});
test('command resets categories for a user by email', function () {
$user = User::factory()->create(['email' => 'test@example.com']);
Category::factory()->count(3)->create(['user_id' => $user->id]);
expect($user->categories()->count())->toBe(3);
$this->artisan('categories:reset', ['user' => 'test@example.com'])
->expectsOutput("Resetting categories for user: {$user->name} (test@example.com)")
->expectsOutput('Deleted 3 existing categories.')
->expectsOutput('Created 49 default categories.')
->expectsOutput('✓ Categories reset successfully!')
->assertSuccessful();
expect($user->fresh()->categories()->count())->toBe(49);
});
test('command works when user has no existing categories', function () {
$user = User::factory()->create();
expect($user->categories()->count())->toBe(0);
$this->artisan('categories:reset', ['user' => $user->id])
->expectsOutput("Resetting categories for user: {$user->name} ({$user->email})")
->expectsOutput('No existing categories found.')
->expectsOutput('Created 49 default categories.')
->expectsOutput('✓ Categories reset successfully!')
->assertSuccessful();
expect($user->fresh()->categories()->count())->toBe(49);
});
test('command fails when user is not found by ID', function () {
$this->artisan('categories:reset', ['user' => 99999])
->expectsOutput('User not found: 99999')
->assertFailed();
});
test('command fails when user is not found by email', function () {
$this->artisan('categories:reset', ['user' => 'nonexistent@example.com'])
->expectsOutput('User not found: nonexistent@example.com')
->assertFailed();
});

View File

@ -38,7 +38,7 @@ test('authenticated users can create a category', function () {
$categoryData = [
'name' => 'Shopping',
'icon' => 'ShoppingCart',
'icon' => 'ShoppingBag',
'color' => 'blue',
];
@ -49,7 +49,7 @@ test('authenticated users can create a category', function () {
$this->assertDatabaseHas('categories', [
'user_id' => $user->id,
'name' => 'Shopping',
'icon' => 'ShoppingCart',
'icon' => 'ShoppingBag',
'color' => 'blue',
]);
});
@ -58,7 +58,7 @@ test('category name is required', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('categories.store'), [
'icon' => 'ShoppingCart',
'icon' => 'ShoppingBag',
'color' => 'blue',
]);
@ -81,7 +81,7 @@ test('category color is required', function () {
$response = $this->actingAs($user)->post(route('categories.store'), [
'name' => 'Shopping',
'icon' => 'ShoppingCart',
'icon' => 'ShoppingBag',
]);
$response->assertSessionHasErrors(['color']);
@ -92,7 +92,7 @@ test('category color must be valid', function () {
$response = $this->actingAs($user)->post(route('categories.store'), [
'name' => 'Shopping',
'icon' => 'ShoppingCart',
'icon' => 'ShoppingBag',
'color' => 'invalid-color',
]);
@ -183,8 +183,8 @@ test('default categories are created when user registers', function () {
$service = new \App\Actions\CreateDefaultCategories;
$service->handle($user);
expect($user->categories()->count())->toBe(8);
expect($user->categories()->count())->toBe(49);
$categoryNames = $user->categories->pluck('name')->toArray();
expect($categoryNames)->toContain('Groceries', 'Transportation', 'Entertainment');
expect($categoryNames)->toContain('Food', 'Transportation', 'Salary', 'Insurance');
});

View File

@ -216,7 +216,7 @@ it('can delete a transaction', function () {
$response->assertSuccessful();
$this->assertDatabaseMissing('transactions', [
$this->assertSoftDeleted('transactions', [
'id' => $transaction->id,
]);
});