Skip to content

Commit a041bc8

Browse files
committed
feat: Phase 28 — Product Categories with colour badges and product filtering
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 1086366 commit a041bc8

23 files changed

Lines changed: 610 additions & 44 deletions
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Inventory\Models\ProductCategory;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class ProductCategoryController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', ProductCategory::class);
17+
$tenantId = $request->user()->tenant_id;
18+
$categories = ProductCategory::where('tenant_id', $tenantId)
19+
->withCount('products')
20+
->orderBy('name')
21+
->get()
22+
->map(fn ($c) => [
23+
'id' => $c->id,
24+
'name' => $c->name,
25+
'slug' => $c->slug,
26+
'description' => $c->description,
27+
'colour' => $c->colour,
28+
'products_count' => $c->products_count,
29+
]);
30+
return Inertia::render('Inventory/ProductCategories/Index', compact('categories'));
31+
}
32+
33+
public function store(Request $request): RedirectResponse
34+
{
35+
$this->authorize('create', ProductCategory::class);
36+
$data = $request->validate([
37+
'name' => 'required|string|max:100',
38+
'description' => 'nullable|string',
39+
'colour' => 'nullable|string|regex:/^#[0-9A-Fa-f]{6}$/',
40+
]);
41+
$data['tenant_id'] = $request->user()->tenant_id;
42+
$data['slug'] = $this->uniqueSlug($data['tenant_id'], \Str::slug($data['name']));
43+
$data['colour'] = $data['colour'] ?? '#6366f1';
44+
ProductCategory::create($data);
45+
return back()->with('success', 'Category created.');
46+
}
47+
48+
public function update(Request $request, ProductCategory $productCategory): RedirectResponse
49+
{
50+
$this->authorize('update', $productCategory);
51+
$data = $request->validate([
52+
'name' => 'required|string|max:100',
53+
'description' => 'nullable|string',
54+
'colour' => 'nullable|string|regex:/^#[0-9A-Fa-f]{6}$/',
55+
]);
56+
$data['slug'] = $this->uniqueSlug($productCategory->tenant_id, \Str::slug($data['name']), $productCategory->id);
57+
$productCategory->update($data);
58+
return back()->with('success', 'Category updated.');
59+
}
60+
61+
public function destroy(ProductCategory $productCategory): RedirectResponse
62+
{
63+
$this->authorize('delete', $productCategory);
64+
$productCategory->delete(); // products become uncategorised (nullOnDelete)
65+
return back()->with('success', 'Category deleted.');
66+
}
67+
68+
private function uniqueSlug(int $tenantId, string $base, ?int $excludeId = null): string
69+
{
70+
$slug = $base;
71+
$count = 2;
72+
while (
73+
ProductCategory::where('tenant_id', $tenantId)
74+
->where('slug', $slug)
75+
->when($excludeId, fn ($q) => $q->where('id', '!=', $excludeId))
76+
->exists()
77+
) {
78+
$slug = $base . '-' . $count++;
79+
}
80+
return $slug;
81+
}
82+
}

erp/app/Modules/Inventory/Http/Controllers/ProductController.php

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,9 @@
66
use App\Modules\Inventory\Http\Requests\StoreProductRequest;
77
use App\Modules\Inventory\Http\Requests\UpdateProductRequest;
88
use App\Modules\Inventory\Http\Resources\ProductResource;
9-
use App\Modules\Inventory\Models\Category;
109
use App\Modules\Inventory\Models\Product;
10+
use App\Modules\Inventory\Models\ProductCategory;
1111
use App\Modules\Inventory\Models\UnitOfMeasure;
12-
use App\Modules\Inventory\Models\Warehouse;
1312
use Inertia\Inertia;
1413
use Inertia\Response;
1514
use Illuminate\Http\RedirectResponse;
@@ -35,7 +34,7 @@ public function index(Request $request): Response
3534

3635
return Inertia::render('Inventory/Products/Index', [
3736
'products' => ProductResource::collection($products),
38-
'categories' => Category::orderBy('name')->get(['id', 'name']),
37+
'categories' => ProductCategory::orderBy('name')->get(['id', 'name', 'colour']),
3938
'filters' => $request->only(['search', 'category_id', 'status', 'sort', 'direction']),
4039
'breadcrumbs' => [
4140
['label' => 'Inventory'],
@@ -49,7 +48,7 @@ public function create(): Response
4948
$this->authorize('create', Product::class);
5049

5150
return Inertia::render('Inventory/Products/Create', [
52-
'categories' => Category::orderBy('name')->get(['id', 'name']),
51+
'categories' => ProductCategory::orderBy('name')->get(['id', 'name', 'colour']),
5352
'uoms' => UnitOfMeasure::orderBy('name')->get(['id', 'name', 'abbreviation']),
5453
'breadcrumbs' => [
5554
['label' => 'Inventory'],
@@ -97,7 +96,7 @@ public function edit(Product $product): Response
9796

9897
return Inertia::render('Inventory/Products/Edit', [
9998
'product' => new ProductResource($product),
100-
'categories' => Category::orderBy('name')->get(['id', 'name']),
99+
'categories' => ProductCategory::orderBy('name')->get(['id', 'name', 'colour']),
101100
'uoms' => UnitOfMeasure::orderBy('name')->get(['id', 'name', 'abbreviation']),
102101
'breadcrumbs' => [
103102
['label' => 'Inventory'],

erp/app/Modules/Inventory/Http/Requests/StoreProductRequest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ public function rules(): array
1919
],
2020
'name' => ['required', 'string', 'max:255'],
2121
'description' => ['nullable', 'string'],
22-
'category_id' => ['nullable', 'integer', 'exists:categories,id'],
22+
'category_id' => ['nullable', 'integer', 'exists:product_categories,id'],
2323
'uom_id' => ['nullable', 'integer', 'exists:units_of_measure,id'],
2424
'cost_price' => ['required', 'numeric', 'min:0'],
2525
'sale_price' => ['required', 'numeric', 'min:0'],

erp/app/Modules/Inventory/Http/Requests/UpdateProductRequest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public function rules(): array
2020
],
2121
'name' => ['required', 'string', 'max:255'],
2222
'description' => ['nullable', 'string'],
23-
'category_id' => ['nullable', 'integer', 'exists:categories,id'],
23+
'category_id' => ['nullable', 'integer', 'exists:product_categories,id'],
2424
'uom_id' => ['nullable', 'integer', 'exists:units_of_measure,id'],
2525
'cost_price' => ['required', 'numeric', 'min:0'],
2626
'sale_price' => ['required', 'numeric', 'min:0'],

erp/app/Modules/Inventory/Models/Product.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ class Product extends Model
3030

3131
public function category(): BelongsTo
3232
{
33-
return $this->belongsTo(Category::class);
33+
return $this->belongsTo(ProductCategory::class, 'category_id');
3434
}
3535

3636
public function uom(): BelongsTo
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\HasMany;
8+
use Illuminate\Database\Eloquent\SoftDeletes;
9+
10+
class ProductCategory extends Model
11+
{
12+
use BelongsToTenant;
13+
use SoftDeletes;
14+
15+
protected $table = 'product_categories';
16+
17+
protected $fillable = [
18+
'tenant_id', 'name', 'slug', 'description', 'colour',
19+
];
20+
21+
protected static function boot(): void
22+
{
23+
parent::boot();
24+
25+
static::deleting(function (self $category) {
26+
// Nullify category_id on products when a category is soft-deleted
27+
Product::where('category_id', $category->id)
28+
->update(['category_id' => null]);
29+
});
30+
}
31+
32+
public function products(): HasMany
33+
{
34+
return $this->hasMany(Product::class, 'category_id');
35+
}
36+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Inventory\Models\ProductCategory;
7+
8+
class ProductCategoryPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('inventory.view');
13+
}
14+
15+
public function view(User $user, ProductCategory $productCategory): bool
16+
{
17+
return $user->can('inventory.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->can('inventory.create');
23+
}
24+
25+
public function update(User $user, ProductCategory $productCategory): bool
26+
{
27+
return $user->can('inventory.create');
28+
}
29+
30+
public function delete(User $user, ProductCategory $productCategory): bool
31+
{
32+
return $user->can('inventory.delete');
33+
}
34+
}

erp/app/Modules/Inventory/Providers/InventoryServiceProvider.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
namespace App\Modules\Inventory\Providers;
44

55
use App\Modules\Inventory\Models\Product;
6+
use App\Modules\Inventory\Models\ProductCategory;
67
use App\Modules\Inventory\Models\WarehouseTransfer;
8+
use App\Modules\Inventory\Policies\ProductCategoryPolicy;
79
use App\Modules\Inventory\Policies\ProductPolicy;
810
use App\Modules\Inventory\Policies\WarehouseTransferPolicy;
911
use Illuminate\Support\Facades\Gate;
@@ -18,6 +20,7 @@ public function boot(): void
1820
$this->loadRoutesFrom(__DIR__ . '/../routes/inventory.php');
1921

2022
Gate::policy(Product::class, ProductPolicy::class);
23+
Gate::policy(ProductCategory::class, ProductCategoryPolicy::class);
2124
Gate::policy(WarehouseTransfer::class, WarehouseTransferPolicy::class);
2225
}
2326
}

erp/app/Modules/Inventory/routes/inventory.php

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<?php
22

33
use App\Modules\Inventory\Http\Controllers\CategoryController;
4+
use App\Modules\Inventory\Http\Controllers\ProductCategoryController;
45
use App\Modules\Inventory\Http\Controllers\ProductController;
56
use App\Modules\Inventory\Http\Controllers\PurchaseOrderController;
67
use App\Modules\Inventory\Http\Controllers\StockMovementController;
@@ -15,7 +16,12 @@
1516
Route::resource('products', ProductController::class)
1617
->names('products');
1718

18-
// Categories (index + inline CRUD via back())
19+
// Product Categories (flat, colour-coded)
20+
Route::resource('product-categories', ProductCategoryController::class)
21+
->except(['create', 'edit', 'show'])
22+
->names('product-categories');
23+
24+
// Legacy Categories (hierarchical - index + inline CRUD via back())
1925
Route::get('categories', [CategoryController::class, 'index'])->name('categories.index');
2026
Route::post('categories', [CategoryController::class, 'store'])->name('categories.store');
2127
Route::put('categories/{category}', [CategoryController::class, 'update'])->name('categories.update');
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
public function up(): void
10+
{
11+
Schema::create('product_categories', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
14+
$table->string('name');
15+
$table->string('slug')->nullable();
16+
$table->text('description')->nullable();
17+
$table->string('colour', 7)->default('#6366f1');
18+
$table->timestamps();
19+
$table->softDeletes();
20+
$table->unique(['tenant_id', 'slug']);
21+
});
22+
}
23+
24+
public function down(): void
25+
{
26+
Schema::dropIfExists('product_categories');
27+
}
28+
};

0 commit comments

Comments
 (0)