Add category type field support (#2)
* Add category type field support - Remove default value from type column in migration - Add type validation to StoreCategoryRequest and UpdateCategoryRequest - Include type field in CategoryController index response - Assign proper types to all default categories (income/expense/transfer) - Update CategoryFactory to include type field - Add type field to TypeScript Category interface - Add type select field to create and edit category dialogs - Update all category tests to include type field - Add new tests for type field validation All tests passing (27 category-related tests) * Add CategoryColor enum and update validation rules * Remove unused AccountType enum class * Add category type column to settings page * Remove obsolete import screenshots, add new category edit screenshot * feat(ui): add transfer type description alerts * feat(tests): Add .gitignore and update CategoriesTest.php for Browser tests * fix: update transfer category UI and test * chore: update .gitignore to include Screenshots directory * Change category type from income to expense
|
|
@ -1,12 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum AccountType: string
|
||||
{
|
||||
case Checking = 'checking';
|
||||
case CreditCard = 'credit_card';
|
||||
case Loan = 'loan';
|
||||
case Savings = 'savings';
|
||||
case Others = 'others';
|
||||
}
|
||||
|
|
@ -24,7 +24,7 @@ class CreateDefaultCategories
|
|||
/**
|
||||
* Get the default categories configuration.
|
||||
*
|
||||
* @return array<int, array{name: string, icon: string, color: string}>
|
||||
* @return array<int, array{name: string, icon: string, color: string, type: string}>
|
||||
*/
|
||||
public static function getDefaultCategories(): array
|
||||
{
|
||||
|
|
@ -33,316 +33,379 @@ class CreateDefaultCategories
|
|||
'name' => 'Food',
|
||||
'icon' => 'Utensils',
|
||||
'color' => 'red',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Cafes, restaurants, bars',
|
||||
'icon' => 'Wine',
|
||||
'color' => 'red',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Groceries',
|
||||
'icon' => 'ShoppingBasket',
|
||||
'color' => 'red',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Tobacco and alcohol',
|
||||
'icon' => 'Cigarette',
|
||||
'color' => 'red',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Other groceries',
|
||||
'icon' => 'ShoppingBasket',
|
||||
'color' => 'red',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Food delivery',
|
||||
'icon' => 'Pizza',
|
||||
'color' => 'red',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Utility services',
|
||||
'icon' => 'Home',
|
||||
'color' => 'orange',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Electricity',
|
||||
'icon' => 'Bolt',
|
||||
'color' => 'orange',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Natural gas',
|
||||
'icon' => 'Flame',
|
||||
'color' => 'orange',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Rent and maintanence',
|
||||
'icon' => 'Wrench',
|
||||
'color' => 'orange',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Telephone, internet, TV, computer',
|
||||
'icon' => 'Wifi',
|
||||
'color' => 'orange',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Water',
|
||||
'icon' => 'Droplets',
|
||||
'color' => 'orange',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Other utility expenses',
|
||||
'icon' => 'Receipt',
|
||||
'color' => 'orange',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Household goods',
|
||||
'icon' => 'Home',
|
||||
'color' => 'orange',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Transportation',
|
||||
'icon' => 'Bus',
|
||||
'color' => 'amber',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Parking',
|
||||
'icon' => 'ParkingMeter',
|
||||
'color' => 'amber',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Fuel',
|
||||
'icon' => 'Fuel',
|
||||
'color' => 'amber',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Transportation expenses',
|
||||
'icon' => 'Ticket',
|
||||
'color' => 'amber',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Vehicle purchase, maintenance',
|
||||
'icon' => 'Car',
|
||||
'color' => 'amber',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Clothing and shoes',
|
||||
'icon' => 'Shirt',
|
||||
'color' => 'pink',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Leisure activities, traveling',
|
||||
'icon' => 'Plane',
|
||||
'color' => 'violet',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Gifts',
|
||||
'icon' => 'Gift',
|
||||
'color' => 'violet',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Books, newspapers, magazines',
|
||||
'icon' => 'BookOpen',
|
||||
'color' => 'violet',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Accommodation, travel expenses',
|
||||
'icon' => 'Hotel',
|
||||
'color' => 'violet',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Sport and sports goods',
|
||||
'icon' => 'Dumbbell',
|
||||
'color' => 'violet',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Theatre, music, cinema',
|
||||
'icon' => 'Clapperboard',
|
||||
'color' => 'violet',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Hobbies and other leisure time activites',
|
||||
'icon' => 'Puzzle',
|
||||
'color' => 'violet',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Education, health and beauty',
|
||||
'icon' => 'GraduationCap',
|
||||
'color' => 'rose',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Education and courses',
|
||||
'icon' => 'GraduationCap',
|
||||
'color' => 'rose',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Beauty, cosmetics',
|
||||
'icon' => 'Sparkles',
|
||||
'color' => 'rose',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Health and pharmaceuticals',
|
||||
'icon' => 'HeartPulse',
|
||||
'color' => 'rose',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Online transactions',
|
||||
'icon' => 'Globe',
|
||||
'color' => 'fuchsia',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Online services',
|
||||
'icon' => 'Server',
|
||||
'color' => 'fuchsia',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Insurance',
|
||||
'icon' => 'ShieldCheck',
|
||||
'color' => 'yellow',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Investments',
|
||||
'icon' => 'LineChart',
|
||||
'color' => 'lime',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Savings',
|
||||
'icon' => 'PiggyBank',
|
||||
'color' => 'lime',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Other investments',
|
||||
'icon' => 'TrendingUp',
|
||||
'color' => 'lime',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Financial services and commission',
|
||||
'icon' => 'Landmark',
|
||||
'color' => 'slate',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Fines',
|
||||
'icon' => 'Gavel',
|
||||
'color' => 'slate',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Mortgage',
|
||||
'icon' => 'Building2',
|
||||
'color' => 'slate',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Credit card repayment',
|
||||
'icon' => 'CreditCard',
|
||||
'color' => 'slate',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Cash withdrawal',
|
||||
'icon' => 'Banknote',
|
||||
'color' => 'neutral',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Gambling',
|
||||
'icon' => 'Dice5',
|
||||
'color' => 'purple',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Lottery',
|
||||
'icon' => 'TicketPercent',
|
||||
'color' => 'purple',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Taxes and government fees',
|
||||
'icon' => 'FileText',
|
||||
'color' => 'stone',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Invoices',
|
||||
'icon' => 'FileInvoice',
|
||||
'color' => 'stone',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Personal transfers',
|
||||
'icon' => 'ArrowLeftRight',
|
||||
'color' => 'cyan',
|
||||
'type' => 'transfer',
|
||||
],
|
||||
[
|
||||
'name' => 'Other personal transfers',
|
||||
'icon' => 'ArrowLeftRight',
|
||||
'color' => 'cyan',
|
||||
'type' => 'transfer',
|
||||
],
|
||||
[
|
||||
'name' => 'Administrative violations',
|
||||
'icon' => 'BadgeAlert',
|
||||
'color' => 'stone',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Other transfers',
|
||||
'icon' => 'Split',
|
||||
'color' => 'stone',
|
||||
'type' => 'transfer',
|
||||
],
|
||||
[
|
||||
'name' => 'Other payments',
|
||||
'icon' => 'Wallet',
|
||||
'color' => 'stone',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Salary',
|
||||
'icon' => 'Coins',
|
||||
'color' => 'green',
|
||||
'type' => 'income',
|
||||
],
|
||||
[
|
||||
'name' => 'Regular income',
|
||||
'icon' => 'Coins',
|
||||
'color' => 'green',
|
||||
'type' => 'income',
|
||||
],
|
||||
[
|
||||
'name' => 'Work on demand',
|
||||
'icon' => 'Briefcase',
|
||||
'color' => 'green',
|
||||
'type' => 'income',
|
||||
],
|
||||
[
|
||||
'name' => 'Income from rent',
|
||||
'icon' => 'Building',
|
||||
'color' => 'green',
|
||||
'type' => 'income',
|
||||
],
|
||||
[
|
||||
'name' => 'Unemployment benefit',
|
||||
'icon' => 'HandCoins',
|
||||
'color' => 'green',
|
||||
'type' => 'income',
|
||||
],
|
||||
[
|
||||
'name' => 'Tax return',
|
||||
'icon' => 'RotateCcw',
|
||||
'color' => 'green',
|
||||
'type' => 'income',
|
||||
],
|
||||
[
|
||||
'name' => 'Return debit',
|
||||
'icon' => 'Undo2',
|
||||
'color' => 'green',
|
||||
'type' => 'income',
|
||||
],
|
||||
[
|
||||
'name' => 'Own account',
|
||||
'icon' => 'ArrowRightLeft',
|
||||
'color' => 'blue',
|
||||
'type' => 'transfer',
|
||||
],
|
||||
[
|
||||
'name' => 'From account of relatives',
|
||||
'icon' => 'Users',
|
||||
'color' => 'blue',
|
||||
'type' => 'transfer',
|
||||
],
|
||||
[
|
||||
'name' => 'Returned payments',
|
||||
'icon' => 'RotateCw',
|
||||
'color' => 'green',
|
||||
'type' => 'income',
|
||||
],
|
||||
[
|
||||
'name' => 'Credit cards',
|
||||
'icon' => 'CreditCard',
|
||||
'color' => 'green',
|
||||
'type' => 'expense',
|
||||
],
|
||||
[
|
||||
'name' => 'Other incoming payments',
|
||||
'icon' => 'DollarSign',
|
||||
'color' => 'green',
|
||||
'type' => 'income',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ class SetupMainUser extends Command
|
|||
'name' => $categoryData['name'],
|
||||
'icon' => $categoryData['icon'],
|
||||
'color' => $categoryData['color'],
|
||||
'type' => $categoryData['type'] ?? 'expense',
|
||||
'created_at' => $categoryData['created_at'] ?? now(),
|
||||
'updated_at' => $categoryData['updated_at'] ?? now(),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum CategoryColor: string
|
||||
{
|
||||
case Amber = 'amber';
|
||||
case Blue = 'blue';
|
||||
case Cyan = 'cyan';
|
||||
case Emerald = 'emerald';
|
||||
case Fuchsia = 'fuchsia';
|
||||
case Gray = 'gray';
|
||||
case Green = 'green';
|
||||
case Indigo = 'indigo';
|
||||
case Lime = 'lime';
|
||||
case Neutral = 'neutral';
|
||||
case Orange = 'orange';
|
||||
case Pink = 'pink';
|
||||
case Purple = 'purple';
|
||||
case Red = 'red';
|
||||
case Rose = 'rose';
|
||||
case Slate = 'slate';
|
||||
case Stone = 'stone';
|
||||
case Teal = 'teal';
|
||||
case Violet = 'violet';
|
||||
case Yellow = 'yellow';
|
||||
}
|
||||
|
|
@ -23,7 +23,7 @@ class CategoryController extends Controller
|
|||
$categories = auth()->user()
|
||||
->categories()
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'icon', 'color']);
|
||||
->get(['id', 'name', 'icon', 'color', 'type']);
|
||||
|
||||
return Inertia::render('settings/categories', [
|
||||
'categories' => $categories,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Enums\CategoryColor;
|
||||
use App\Enums\CategoryType;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
|
@ -29,22 +31,12 @@ class StoreCategoryRequest extends FormRequest
|
|||
'color' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in([
|
||||
'amber',
|
||||
'blue',
|
||||
'cyan',
|
||||
'emerald',
|
||||
'gray',
|
||||
'green',
|
||||
'indigo',
|
||||
'orange',
|
||||
'pink',
|
||||
'purple',
|
||||
'red',
|
||||
'slate',
|
||||
'teal',
|
||||
'yellow',
|
||||
]),
|
||||
Rule::enum(CategoryColor::class),
|
||||
],
|
||||
'type' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::enum(CategoryType::class),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@
|
|||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Enums\CategoryColor;
|
||||
use App\Enums\CategoryType;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
|
@ -29,22 +31,12 @@ class UpdateCategoryRequest extends FormRequest
|
|||
'color' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::in([
|
||||
'amber',
|
||||
'blue',
|
||||
'cyan',
|
||||
'emerald',
|
||||
'gray',
|
||||
'green',
|
||||
'indigo',
|
||||
'orange',
|
||||
'pink',
|
||||
'purple',
|
||||
'red',
|
||||
'slate',
|
||||
'teal',
|
||||
'yellow',
|
||||
]),
|
||||
Rule::enum(CategoryColor::class),
|
||||
],
|
||||
'type' => [
|
||||
'required',
|
||||
'string',
|
||||
Rule::enum(CategoryType::class),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
|
|
@ -21,6 +22,7 @@ class CategoryFactory extends Factory
|
|||
'name' => fake()->unique()->randomElement(['Food', 'Transport', 'Entertainment', 'Shopping', 'Healthcare', 'Utilities', 'Travel', 'Education', 'Bills', 'Investments', 'Savings', 'Insurance', 'Gifts', 'Personal Care', 'Sports', 'Hobbies', 'Subscriptions', 'Dining', 'Groceries', 'Clothing']),
|
||||
'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']),
|
||||
'type' => fake()->randomElement(CategoryType::cases()),
|
||||
'user_id' => User::factory(),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ return new class extends Migration
|
|||
public function up(): void
|
||||
{
|
||||
Schema::table('categories', function (Blueprint $table) {
|
||||
$table->string('type')->default('expense')->after('color');
|
||||
$table->string('type')->after('color');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { store } from '@/actions/App/Http/Controllers/Settings/CategoryController';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
|
|
@ -22,10 +23,12 @@ import { categorySyncService } from '@/services/category-sync';
|
|||
import {
|
||||
CATEGORY_COLORS,
|
||||
CATEGORY_ICONS,
|
||||
CATEGORY_TYPES,
|
||||
getCategoryColorClasses,
|
||||
} from '@/types/category';
|
||||
import { Form } from '@inertiajs/react';
|
||||
import * as Icons from 'lucide-react';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function CreateCategoryDialog({
|
||||
|
|
@ -34,6 +37,7 @@ export function CreateCategoryDialog({
|
|||
onSuccess?: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [selectedType, setSelectedType] = useState<string>('');
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
|
|
@ -139,6 +143,44 @@ export function CreateCategoryDialog({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<Select
|
||||
name="type"
|
||||
required
|
||||
onValueChange={setSelectedType}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.charAt(0).toUpperCase() +
|
||||
type.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.type && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.type}
|
||||
</p>
|
||||
)}
|
||||
{selectedType === 'transfer' && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4 opacity-50" />
|
||||
<AlertDescription className="text-sm">
|
||||
Transactions in this category will
|
||||
not be counted in top expenses or
|
||||
income. Transfer categories are
|
||||
mainly used for transactions between
|
||||
accounts.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { update } from '@/actions/App/Http/Controllers/Settings/CategoryController';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
|
|
@ -21,11 +22,14 @@ import { categorySyncService } from '@/services/category-sync';
|
|||
import {
|
||||
CATEGORY_COLORS,
|
||||
CATEGORY_ICONS,
|
||||
CATEGORY_TYPES,
|
||||
getCategoryColorClasses,
|
||||
type Category,
|
||||
} from '@/types/category';
|
||||
import { Form } from '@inertiajs/react';
|
||||
import * as Icons from 'lucide-react';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface EditCategoryDialogProps {
|
||||
category: Category;
|
||||
|
|
@ -40,6 +44,8 @@ export function EditCategoryDialog({
|
|||
onOpenChange,
|
||||
onSuccess,
|
||||
}: EditCategoryDialogProps) {
|
||||
const [selectedType, setSelectedType] = useState<string>(category.type);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
|
|
@ -150,6 +156,45 @@ export function EditCategoryDialog({
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="type">Type</Label>
|
||||
<Select
|
||||
name="type"
|
||||
defaultValue={category.type}
|
||||
required
|
||||
onValueChange={setSelectedType}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_TYPES.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.charAt(0).toUpperCase() +
|
||||
type.slice(1)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.type && (
|
||||
<p className="text-sm text-red-500">
|
||||
{errors.type}
|
||||
</p>
|
||||
)}
|
||||
{selectedType === 'transfer' && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4 opacity-50" />
|
||||
<AlertDescription className="text-sm">
|
||||
Transactions in this category will
|
||||
not be counted in top expenses or
|
||||
income. Transfer categories are
|
||||
mainly used for transactions between
|
||||
accounts.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -225,6 +225,38 @@ export default function Categories() {
|
|||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'type',
|
||||
header: 'Type',
|
||||
cell: ({ row }) => {
|
||||
const type = row.getValue('type') as Category['type'];
|
||||
const typeConfig = {
|
||||
income: {
|
||||
label: 'Income',
|
||||
className:
|
||||
'bg-green-50 text-green-700 dark:bg-green-700 dark:text-green-100',
|
||||
},
|
||||
expense: {
|
||||
label: 'Expense',
|
||||
className:
|
||||
'bg-red-50 text-red-700 dark:bg-red-700 dark:text-red-100',
|
||||
},
|
||||
transfer: {
|
||||
label: 'Transfer',
|
||||
className:
|
||||
'bg-zinc-50 text-zinc-700 dark:bg-zinc-700 dark:text-zinc-100',
|
||||
},
|
||||
};
|
||||
const config = typeConfig[type];
|
||||
return (
|
||||
<Badge
|
||||
className={`${config.className} text-[10px] tracking-widest`}
|
||||
>
|
||||
{config.label.toLocaleUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
enableHiding: false,
|
||||
|
|
|
|||
|
|
@ -103,11 +103,16 @@ export const CATEGORY_COLORS = [
|
|||
|
||||
export type CategoryColor = (typeof CATEGORY_COLORS)[number];
|
||||
|
||||
export const CATEGORY_TYPES = ['income', 'expense', 'transfer'] as const;
|
||||
|
||||
export type CategoryType = (typeof CATEGORY_TYPES)[number];
|
||||
|
||||
export interface Category {
|
||||
id: UUID;
|
||||
name: string;
|
||||
icon: CategoryIcon;
|
||||
color: CategoryColor;
|
||||
type: CategoryType;
|
||||
}
|
||||
|
||||
export function getCategoryColorClasses(color: CategoryColor): {
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
Screenshots/
|
||||
|
|
@ -44,7 +44,7 @@ it('can open create category dialog', function () {
|
|||
|
||||
$page->assertSee('Categories settings')
|
||||
->click('Create Category')
|
||||
->waitFor('dialog')
|
||||
->wait(0.5)
|
||||
->assertSee('Add a new category to organize your transactions')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
|
@ -58,14 +58,20 @@ it('can create a new category', function () {
|
|||
|
||||
$page->assertSee('Categories settings')
|
||||
->click('Create Category')
|
||||
->waitFor('dialog')
|
||||
->wait(0.5)
|
||||
->fill('name', 'Entertainment')
|
||||
->click('Select an icon')
|
||||
->wait(0.5)
|
||||
->click('//div[@role="option"][1]')
|
||||
->wait(0.3)
|
||||
->click('Film')
|
||||
->click('Select a color')
|
||||
->wait(0.5)
|
||||
->click('//div[@role="option"][contains(., "purple")]')
|
||||
->wait(0.3)
|
||||
->click('Select a type')
|
||||
->wait(0.5)
|
||||
->click('//div[@role="option"][contains(., "Expense")]')
|
||||
->wait(0.3)
|
||||
->click('purple')
|
||||
->click('button[type="submit"]')
|
||||
->wait(2)
|
||||
->assertSee('Entertainment')
|
||||
|
|
@ -74,8 +80,8 @@ it('can create a new category', function () {
|
|||
$this->assertDatabaseHas('categories', [
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Entertainment',
|
||||
'icon' => 'Film',
|
||||
'color' => 'purple',
|
||||
'type' => 'expense',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -127,13 +133,13 @@ it('can edit an existing category via context menu', function () {
|
|||
$page = visit('/settings/categories');
|
||||
|
||||
$page->assertSee('Old Category')
|
||||
->click('button[aria-label="Open menu"]')
|
||||
->wait(0.3)
|
||||
->rightClick('Old Category')
|
||||
->wait(0.5)
|
||||
->click('Edit')
|
||||
->waitFor('dialog')
|
||||
->wait(0.5)
|
||||
->assertSee('Edit Category')
|
||||
->fill('name', 'Updated Category')
|
||||
->click('Save')
|
||||
->click('//button[contains(., "Update")]')
|
||||
->wait(2)
|
||||
->assertSee('Updated Category')
|
||||
->assertNoJavascriptErrors();
|
||||
|
|
@ -143,3 +149,53 @@ it('can edit an existing category via context menu', function () {
|
|||
'name' => 'Updated Category',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows transfer type description when transfer type is selected in create dialog', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/categories');
|
||||
|
||||
$page->assertSee('Categories settings')
|
||||
->click('Create Category')
|
||||
->wait(1)
|
||||
->assertSee('Add a new category to organize your transactions')
|
||||
->assertSee('Select a type')
|
||||
->click('//button[contains(., "Select a type")]')
|
||||
->wait(0.5)
|
||||
->click('//div[@role="option"][contains(., "Transfer")]')
|
||||
->wait(0.5)
|
||||
->assertSee('Transactions in this category will not be counted in top expenses or income')
|
||||
->assertSee('Transfer categories are mainly used for transactions between accounts')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
it('shows transfer type description when transfer type is selected in edit dialog', function () {
|
||||
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
|
||||
$category = Category::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'name' => 'Test Category',
|
||||
'icon' => 'Tag',
|
||||
'color' => 'blue',
|
||||
'type' => 'expense',
|
||||
]);
|
||||
|
||||
actingAs($user);
|
||||
|
||||
$page = visit('/settings/categories');
|
||||
|
||||
$page->assertSee('Test Category')
|
||||
->rightClick('Test Category')
|
||||
->wait(0.5)
|
||||
->click('Edit')
|
||||
->wait(1)
|
||||
->assertSee('Edit Category')
|
||||
->click('//button[contains(., "Expense")]')
|
||||
->wait(0.5)
|
||||
->click('//div[@role="option"][contains(., "Transfer")]')
|
||||
->wait(0.5)
|
||||
->assertSee('Transactions in this category will not be counted in top expenses or income')
|
||||
->assertSee('Transfer categories are mainly used for transactions between accounts')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 51 KiB |
|
|
@ -40,6 +40,7 @@ test('authenticated users can create a category', function () {
|
|||
'name' => 'Shopping',
|
||||
'icon' => 'ShoppingBag',
|
||||
'color' => 'blue',
|
||||
'type' => 'expense',
|
||||
];
|
||||
|
||||
$response = $this->actingAs($user)->post(route('categories.store'), $categoryData);
|
||||
|
|
@ -51,6 +52,7 @@ test('authenticated users can create a category', function () {
|
|||
'name' => 'Shopping',
|
||||
'icon' => 'ShoppingBag',
|
||||
'color' => 'blue',
|
||||
'type' => 'expense',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -60,6 +62,7 @@ test('category name is required', function () {
|
|||
$response = $this->actingAs($user)->post(route('categories.store'), [
|
||||
'icon' => 'ShoppingBag',
|
||||
'color' => 'blue',
|
||||
'type' => 'expense',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['name']);
|
||||
|
|
@ -71,6 +74,7 @@ test('category icon is required', function () {
|
|||
$response = $this->actingAs($user)->post(route('categories.store'), [
|
||||
'name' => 'Shopping',
|
||||
'color' => 'blue',
|
||||
'type' => 'expense',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['icon']);
|
||||
|
|
@ -82,6 +86,7 @@ test('category color is required', function () {
|
|||
$response = $this->actingAs($user)->post(route('categories.store'), [
|
||||
'name' => 'Shopping',
|
||||
'icon' => 'ShoppingBag',
|
||||
'type' => 'expense',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['color']);
|
||||
|
|
@ -94,11 +99,37 @@ test('category color must be valid', function () {
|
|||
'name' => 'Shopping',
|
||||
'icon' => 'ShoppingBag',
|
||||
'color' => 'invalid-color',
|
||||
'type' => 'expense',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['color']);
|
||||
});
|
||||
|
||||
test('category type is required', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post(route('categories.store'), [
|
||||
'name' => 'Shopping',
|
||||
'icon' => 'ShoppingBag',
|
||||
'color' => 'blue',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['type']);
|
||||
});
|
||||
|
||||
test('category type must be valid', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user)->post(route('categories.store'), [
|
||||
'name' => 'Shopping',
|
||||
'icon' => 'ShoppingBag',
|
||||
'color' => 'blue',
|
||||
'type' => 'invalid-type',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors(['type']);
|
||||
});
|
||||
|
||||
test('authenticated users can update their own category', function () {
|
||||
$user = User::factory()->create();
|
||||
$category = Category::factory()->create(['user_id' => $user->id]);
|
||||
|
|
@ -107,6 +138,7 @@ test('authenticated users can update their own category', function () {
|
|||
'name' => 'Updated Name',
|
||||
'icon' => 'Home',
|
||||
'color' => 'green',
|
||||
'type' => 'income',
|
||||
];
|
||||
|
||||
$response = $this->actingAs($user)->patch(
|
||||
|
|
@ -121,6 +153,7 @@ test('authenticated users can update their own category', function () {
|
|||
'name' => 'Updated Name',
|
||||
'icon' => 'Home',
|
||||
'color' => 'green',
|
||||
'type' => 'income',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
@ -135,6 +168,7 @@ test('users cannot update categories they do not own', function () {
|
|||
'name' => 'Updated Name',
|
||||
'icon' => 'Home',
|
||||
'color' => 'green',
|
||||
'type' => 'expense',
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -209,6 +243,7 @@ test('category names are unique per user', function () {
|
|||
'name' => 'Test Category',
|
||||
'icon' => 'Tag',
|
||||
'color' => 'red',
|
||||
'type' => 'expense',
|
||||
]);
|
||||
|
||||
expect($category)->toBeInstanceOf(\App\Models\Category::class);
|
||||
|
|
@ -219,5 +254,6 @@ test('category names are unique per user', function () {
|
|||
'name' => 'Test Category',
|
||||
'icon' => 'Tag',
|
||||
'color' => 'blue',
|
||||
'type' => 'expense',
|
||||
]);
|
||||
});
|
||||
|
|
|
|||