Categories

This commit is contained in:
Víctor Falcón 2025-11-07 17:18:07 +00:00
parent 2c0a5bd9a7
commit 689666e0f5
19 changed files with 2487 additions and 2 deletions

View File

@ -42,7 +42,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
- Do not change the application's dependencies without approval.
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `bun run build`, `bun run dev`, or `composer run dev`. Ask them.
## Replies
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
@ -203,7 +203,7 @@ Route::get('/users', function () {
- When creating tests, make use of `php artisan make:test [options] <name>` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
### Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `bun run build` or ask the user to run `bun run dev` or `composer run dev`.
=== laravel/v12 rules ===

View File

@ -0,0 +1,29 @@
<?php
namespace App\Actions;
use App\Models\User;
class CreateDefaultCategories
{
/**
* Create default categories for a newly registered user.
*/
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'],
];
foreach ($defaultCategories as $category) {
$user->categories()->create($category);
}
}
}

View File

@ -0,0 +1,66 @@
<?php
namespace App\Http\Controllers\Settings;
use App\Http\Controllers\Controller;
use App\Http\Requests\Settings\StoreCategoryRequest;
use App\Http\Requests\Settings\UpdateCategoryRequest;
use App\Models\Category;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class CategoryController extends Controller
{
use AuthorizesRequests;
/**
* Show the user's categories settings page.
*/
public function index(): Response
{
$categories = auth()->user()
->categories()
->orderBy('name')
->get(['id', 'name', 'icon', 'color']);
return Inertia::render('settings/categories', [
'categories' => $categories,
]);
}
/**
* Store a newly created category.
*/
public function store(StoreCategoryRequest $request): RedirectResponse
{
auth()->user()->categories()->create($request->validated());
return to_route('categories.index');
}
/**
* Update the specified category.
*/
public function update(UpdateCategoryRequest $request, Category $category): RedirectResponse
{
$this->authorize('update', $category);
$category->update($request->validated());
return to_route('categories.index');
}
/**
* Soft delete the specified category.
*/
public function destroy(Category $category): RedirectResponse
{
$this->authorize('delete', $category);
$category->delete();
return to_route('categories.index');
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreCategoryRequest 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 [
'name' => ['required', 'string', 'max:255'],
'icon' => ['required', 'string'],
'color' => [
'required',
'string',
Rule::in([
'red',
'orange',
'amber',
'yellow',
'lime',
'green',
'emerald',
'teal',
'cyan',
'sky',
'blue',
'indigo',
'violet',
'purple',
'fuchsia',
'pink',
'rose',
]),
],
];
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Http\Requests\Settings;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateCategoryRequest 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 [
'name' => ['required', 'string', 'max:255'],
'icon' => ['required', 'string'],
'color' => [
'required',
'string',
Rule::in([
'red',
'orange',
'amber',
'yellow',
'lime',
'green',
'emerald',
'teal',
'cyan',
'sky',
'blue',
'indigo',
'violet',
'purple',
'fuchsia',
'pink',
'rose',
]),
],
];
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Policies;
use App\Models\Category;
use App\Models\User;
class CategoryPolicy
{
/**
* Determine whether the user can view any models.
*/
public function viewAny(User $user): bool
{
return false;
}
/**
* Determine whether the user can view the model.
*/
public function view(User $user, Category $category): bool
{
return false;
}
/**
* Determine whether the user can create models.
*/
public function create(User $user): bool
{
return false;
}
/**
* Determine whether the user can update the model.
*/
public function update(User $user, Category $category): bool
{
return $user->id === $category->user_id;
}
/**
* Determine whether the user can delete the model.
*/
public function delete(User $user, Category $category): bool
{
return $user->id === $category->user_id;
}
/**
* Determine whether the user can restore the model.
*/
public function restore(User $user, Category $category): bool
{
return false;
}
/**
* Determine whether the user can permanently delete the model.
*/
public function forceDelete(User $user, Category $category): bool
{
return false;
}
}

View File

@ -2,10 +2,13 @@
namespace App\Providers;
use App\Actions\CreateDefaultCategories;
use App\Actions\Fortify\CreateNewUser;
use App\Actions\Fortify\ResetUserPassword;
use Illuminate\Auth\Events\Registered;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider;
use Illuminate\Support\Str;
@ -31,6 +34,7 @@ class FortifyServiceProvider extends ServiceProvider
$this->configureActions();
$this->configureViews();
$this->configureRateLimiting();
$this->configureEventListeners();
}
/**
@ -88,4 +92,14 @@ class FortifyServiceProvider extends ServiceProvider
return Limit::perMinute(5)->by($throttleKey);
});
}
/**
* Configure event listeners.
*/
private function configureEventListeners(): void
{
Event::listen(function (Registered $event) {
app(CreateDefaultCategories::class)->handle($event->user);
});
}
}

1085
bun.lock Normal file

File diff suppressed because it is too large Load Diff

34
package-lock.json generated
View File

@ -22,6 +22,7 @@
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-table": "^8.21.3",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
@ -2652,6 +2653,26 @@
"vite": "^5.2.0 || ^6 || ^7"
}
},
"node_modules/@tanstack/react-table": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
"integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
"license": "MIT",
"dependencies": {
"@tanstack/table-core": "8.21.3"
},
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz",
@ -2668,6 +2689,19 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/table-core": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
"integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.13.12",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz",

View File

@ -43,6 +43,7 @@
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tailwindcss/vite": "^4.1.11",
"@tanstack/react-table": "^8.21.3",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",

View File

@ -0,0 +1,156 @@
import { useState } from 'react';
import { Form } from '@inertiajs/react';
import * as Icons from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import {
CATEGORY_ICONS,
CATEGORY_COLORS,
getCategoryColorClasses,
type CategoryIcon,
type CategoryColor,
} from '@/types/category';
import { store } from '@/actions/App/Http/Controllers/Settings/CategoryController';
export function CreateCategoryDialog() {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Create Category</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Create Category</DialogTitle>
<DialogDescription>
Add a new category to organize your transactions.
</DialogDescription>
</DialogHeader>
<Form
{...store.form()}
onSuccess={() => setOpen(false)}
className="space-y-4"
>
{({ errors, processing }) => (
<>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
name="name"
placeholder="Category name"
required
/>
{errors.name && (
<p className="text-sm text-red-500">
{errors.name}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="icon">Icon</Label>
<Select name="icon" required>
<SelectTrigger>
<SelectValue placeholder="Select an icon" />
</SelectTrigger>
<SelectContent>
{CATEGORY_ICONS.map((iconName) => {
const IconComponent =
Icons[
iconName as keyof typeof Icons
] as Icons.LucideIcon;
return (
<SelectItem
key={iconName}
value={iconName}
>
<div className="flex items-center gap-2">
<IconComponent className="h-4 w-4" />
<span>{iconName}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
{errors.icon && (
<p className="text-sm text-red-500">
{errors.icon}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Select name="color" required>
<SelectTrigger>
<SelectValue placeholder="Select a color" />
</SelectTrigger>
<SelectContent>
{CATEGORY_COLORS.map((color) => {
const colorClasses =
getCategoryColorClasses(color);
return (
<SelectItem
key={color}
value={color}
>
<div className="flex items-center gap-2">
<Badge
className={`${colorClasses.bg} ${colorClasses.text}`}
>
{color}
</Badge>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
{errors.color && (
<p className="text-sm text-red-500">
{errors.color}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={processing}
>
Cancel
</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Creating...' : 'Create'}
</Button>
</div>
</>
)}
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,63 @@
import { Form } from '@inertiajs/react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { type Category } from '@/types/category';
import { destroy } from '@/actions/App/Http/Controllers/Settings/CategoryController';
interface DeleteCategoryDialogProps {
category: Category;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function DeleteCategoryDialog({
category,
open,
onOpenChange,
}: DeleteCategoryDialogProps) {
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
action cannot be undone.
</DialogDescription>
</DialogHeader>
<Form
{...destroy.form.delete(category.id)}
onSuccess={() => onOpenChange(false)}
>
{({ processing }) => (
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={processing}
>
Cancel
</Button>
<Button
type="submit"
variant="destructive"
disabled={processing}
>
{processing ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
)}
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,167 @@
import { Form } from '@inertiajs/react';
import * as Icons from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import {
CATEGORY_ICONS,
CATEGORY_COLORS,
getCategoryColorClasses,
type Category,
} from '@/types/category';
import { update } from '@/actions/App/Http/Controllers/Settings/CategoryController';
interface EditCategoryDialogProps {
category: Category;
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function EditCategoryDialog({
category,
open,
onOpenChange,
}: EditCategoryDialogProps) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Edit Category</DialogTitle>
<DialogDescription>
Update the category information.
</DialogDescription>
</DialogHeader>
<Form
{...update.form.patch(category.id)}
onSuccess={() => onOpenChange(false)}
className="space-y-4"
>
{({ errors, processing }) => (
<>
<div className="space-y-2">
<Label htmlFor="name">Name</Label>
<Input
id="name"
name="name"
defaultValue={category.name}
placeholder="Category name"
required
/>
{errors.name && (
<p className="text-sm text-red-500">
{errors.name}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="icon">Icon</Label>
<Select
name="icon"
defaultValue={category.icon}
required
>
<SelectTrigger>
<SelectValue placeholder="Select an icon" />
</SelectTrigger>
<SelectContent>
{CATEGORY_ICONS.map((iconName) => {
const IconComponent =
Icons[
iconName as keyof typeof Icons
] as Icons.LucideIcon;
return (
<SelectItem
key={iconName}
value={iconName}
>
<div className="flex items-center gap-2">
<IconComponent className="h-4 w-4" />
<span>{iconName}</span>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
{errors.icon && (
<p className="text-sm text-red-500">
{errors.icon}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="color">Color</Label>
<Select
name="color"
defaultValue={category.color}
required
>
<SelectTrigger>
<SelectValue placeholder="Select a color" />
</SelectTrigger>
<SelectContent>
{CATEGORY_COLORS.map((color) => {
const colorClasses =
getCategoryColorClasses(color);
return (
<SelectItem
key={color}
value={color}
>
<div className="flex items-center gap-2">
<Badge
className={`${colorClasses.bg} ${colorClasses.text}`}
>
{color}
</Badge>
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
{errors.color && (
<p className="text-sm text-red-500">
{errors.color}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={processing}
>
Cancel
</Button>
<Button type="submit" disabled={processing}>
{processing ? 'Updating...' : 'Update'}
</Button>
</div>
</>
)}
</Form>
</DialogContent>
</Dialog>
);
}

View File

@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import { cn, isSameUrl, resolveUrl } from '@/lib/utils';
import { edit as editAppearance } from '@/routes/appearance';
import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController';
import { edit } from '@/routes/profile';
import { show } from '@/routes/two-factor';
import { edit as editPassword } from '@/routes/user-password';
@ -26,6 +27,11 @@ const sidebarNavItems: NavItem[] = [
href: show(),
icon: null,
},
{
title: 'Categories',
href: categoriesIndex(),
icon: null,
},
{
title: 'Appearance',
href: editAppearance(),

View File

@ -0,0 +1,302 @@
import { useState } from 'react';
import { Head } from '@inertiajs/react';
import {
ColumnDef,
ColumnFiltersState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
SortingState,
useReactTable,
VisibilityState,
} from '@tanstack/react-table';
import * as Icons from 'lucide-react';
import { ArrowUpDown, MoreHorizontal } from 'lucide-react';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Badge } from '@/components/ui/badge';
import HeadingSmall from '@/components/heading-small';
import { CreateCategoryDialog } from '@/components/categories/create-category-dialog';
import { EditCategoryDialog } from '@/components/categories/edit-category-dialog';
import { DeleteCategoryDialog } from '@/components/categories/delete-category-dialog';
import {
type Category,
getCategoryColorClasses,
} from '@/types/category';
import { type BreadcrumbItem } from '@/types';
import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController';
interface CategoriesProps {
categories: Category[];
}
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Categories settings',
href: categoriesIndex().url,
},
];
function CategoryActions({ category }: { category: Category }) {
const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setEditOpen(true)}>
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => setDeleteOpen(true)}
variant='destructive'
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<EditCategoryDialog
category={category}
open={editOpen}
onOpenChange={setEditOpen}
/>
<DeleteCategoryDialog
category={category}
open={deleteOpen}
onOpenChange={setDeleteOpen}
/>
</>
);
}
export default function Categories({ categories }: CategoriesProps) {
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
{},
);
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>
);
},
cell: ({ row }) => {
const iconName = row.original.icon;
const IconComponent =
Icons[iconName as keyof typeof Icons] as Icons.LucideIcon;
return (
<div className="flex items-center gap-3 pl-3">
<IconComponent className="opacity-80 h-4 w-4" />
<div className="font-medium">{row.getValue('name')}</div>
</div>
)
},
},
{
accessorKey: 'color',
header: 'Color',
cell: ({ row }) => {
const color = row.getValue('color') as Category['color'];
const colorClasses = getCategoryColorClasses(color);
return (
<Badge className={`${colorClasses.bg} ${colorClasses.text} tracking-widest text-[10px]`}>
{color.toLocaleUpperCase()}
</Badge>
);
},
},
{
id: 'actions',
enableHiding: false,
cell: ({ row }) => <CategoryActions category={row.original} />,
},
];
const table = useReactTable({
data: categories,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
state: {
sorting,
columnFilters,
columnVisibility,
},
});
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title="Categories settings" />
<SettingsLayout>
<div className="space-y-6">
<HeadingSmall
title="Categories settings"
description="Manage your transaction categories"
/>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Input
placeholder="Filter categories..."
value={
(table
.getColumn('name')
?.getFilterValue() as string) ?? ''
}
onChange={(event) =>
table
.getColumn('name')
?.setFilterValue(event.target.value)
}
className="max-w-sm"
/>
<CreateCategoryDialog />
</div>
<div className="overflow-hidden rounded-md border">
<Table>
<TableHeader>
{table
.getHeaderGroups()
.map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(
(header) => {
return (
<TableHead
key={header.id}
>
{header.isPlaceholder
? null
: flexRender(
header
.column
.columnDef
.header,
header.getContext(),
)}
</TableHead>
);
},
)}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={
row.getIsSelected() &&
'selected'
}
>
{row
.getVisibleCells()
.map((cell) => (
<TableCell
key={cell.id}
>
{flexRender(
cell.column
.columnDef
.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No categories found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className="flex items-center justify-end space-x-2">
<div className="text-muted-foreground flex-1 text-sm">
{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>
</SettingsLayout>
</AppLayout>
);
}

View File

@ -0,0 +1,79 @@
export const CATEGORY_ICONS = [
'ShoppingCart',
'Home',
'Car',
'Utensils',
'Film',
'Heart',
'Zap',
'ShoppingBag',
'Plane',
'Coffee',
'Smartphone',
'Book',
'DollarSign',
'Gift',
'Briefcase',
'GamepadIcon',
'Music',
'Shirt',
] as const;
export type CategoryIcon = (typeof CATEGORY_ICONS)[number];
export const CATEGORY_COLORS = [
'red',
'orange',
'amber',
'yellow',
'lime',
'green',
'emerald',
'teal',
'cyan',
'sky',
'blue',
'indigo',
'violet',
'purple',
'fuchsia',
'pink',
'rose',
] as const;
export type CategoryColor = (typeof CATEGORY_COLORS)[number];
export interface Category {
id: number;
name: string;
icon: CategoryIcon;
color: CategoryColor;
}
export function getCategoryColorClasses(color: CategoryColor): {
bg: string;
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' },
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' },
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' },
};
return colorMap[color];
}

View File

@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\Settings\CategoryController;
use App\Http\Controllers\Settings\PasswordController;
use App\Http\Controllers\Settings\ProfileController;
use App\Http\Controllers\Settings\TwoFactorAuthenticationController;
@ -19,6 +20,11 @@ Route::middleware('auth')->group(function () {
->middleware('throttle:6,1')
->name('user-password.update');
Route::get('settings/categories', [CategoryController::class, 'index'])->name('categories.index');
Route::post('settings/categories', [CategoryController::class, 'store'])->name('categories.store');
Route::patch('settings/categories/{category}', [CategoryController::class, 'update'])->name('categories.update');
Route::delete('settings/categories/{category}', [CategoryController::class, 'destroy'])->name('categories.destroy');
Route::get('settings/appearance', function () {
return Inertia::render('settings/appearance');
})->name('appearance.edit');

View File

@ -0,0 +1,190 @@
<?php
use App\Models\Category;
use App\Models\User;
test('authenticated users can view their categories', function () {
$user = User::factory()->create();
$categories = Category::factory()->count(3)->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->get(route('categories.index'));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->component('settings/categories')
->has('categories', 3)
);
});
test('authenticated users can only view their own categories', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
Category::factory()->create(['user_id' => $user->id, 'name' => 'My Category']);
Category::factory()->create(['user_id' => $otherUser->id, 'name' => 'Other Category']);
$response = $this->actingAs($user)->get(route('categories.index'));
$response->assertSuccessful();
$response->assertInertia(fn ($page) => $page
->component('settings/categories')
->has('categories', 1)
->where('categories.0.name', 'My Category')
);
});
test('authenticated users can create a category', function () {
$user = User::factory()->create();
$categoryData = [
'name' => 'Shopping',
'icon' => 'ShoppingCart',
'color' => 'blue',
];
$response = $this->actingAs($user)->post(route('categories.store'), $categoryData);
$response->assertRedirect(route('categories.index'));
$this->assertDatabaseHas('categories', [
'user_id' => $user->id,
'name' => 'Shopping',
'icon' => 'ShoppingCart',
'color' => 'blue',
]);
});
test('category name is required', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('categories.store'), [
'icon' => 'ShoppingCart',
'color' => 'blue',
]);
$response->assertSessionHasErrors(['name']);
});
test('category icon is required', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('categories.store'), [
'name' => 'Shopping',
'color' => 'blue',
]);
$response->assertSessionHasErrors(['icon']);
});
test('category color is required', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('categories.store'), [
'name' => 'Shopping',
'icon' => 'ShoppingCart',
]);
$response->assertSessionHasErrors(['color']);
});
test('category color must be valid', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->post(route('categories.store'), [
'name' => 'Shopping',
'icon' => 'ShoppingCart',
'color' => 'invalid-color',
]);
$response->assertSessionHasErrors(['color']);
});
test('authenticated users can update their own category', function () {
$user = User::factory()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$updateData = [
'name' => 'Updated Name',
'icon' => 'Home',
'color' => 'green',
];
$response = $this->actingAs($user)->patch(
route('categories.update', $category),
$updateData
);
$response->assertRedirect(route('categories.index'));
$this->assertDatabaseHas('categories', [
'id' => $category->id,
'name' => 'Updated Name',
'icon' => 'Home',
'color' => 'green',
]);
});
test('users cannot update categories they do not own', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$category = Category::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->patch(
route('categories.update', $category),
[
'name' => 'Updated Name',
'icon' => 'Home',
'color' => 'green',
]
);
$response->assertForbidden();
});
test('authenticated users can delete their own category', function () {
$user = User::factory()->create();
$category = Category::factory()->create(['user_id' => $user->id]);
$response = $this->actingAs($user)->delete(route('categories.destroy', $category));
$response->assertRedirect(route('categories.index'));
$this->assertSoftDeleted('categories', [
'id' => $category->id,
]);
});
test('users cannot delete categories they do not own', function () {
$user = User::factory()->create();
$otherUser = User::factory()->create();
$category = Category::factory()->create(['user_id' => $otherUser->id]);
$response = $this->actingAs($user)->delete(route('categories.destroy', $category));
$response->assertForbidden();
$this->assertDatabaseHas('categories', [
'id' => $category->id,
'deleted_at' => null,
]);
});
test('guests cannot access category management', function () {
$response = $this->get(route('categories.index'));
$response->assertRedirect(route('login'));
$response = $this->post(route('categories.store'), []);
$response->assertRedirect(route('login'));
});
test('default categories are created when user registers', function () {
$user = User::factory()->create();
$service = new \App\Actions\CreateDefaultCategories;
$service->handle($user);
expect($user->categories()->count())->toBe(8);
$categoryNames = $user->categories->pluck('name')->toArray();
expect($categoryNames)->toContain('Groceries', 'Transportation', 'Entertainment');
});