Skip to content

Commit b2ff06d

Browse files
committed
feat(inventory): Phase 108 — Units of Measure Management
Implements full CRUD for UnitOfMeasure with conversion support: - Migration adding type, is_base, conversion_factor, is_active columns - Updated model with casts, convertTo() method, display_name accessor - UnitOfMeasurePolicy (inventory.view/create/delete permissions) - UnitOfMeasureController (index/store/show/update/destroy) - Routes via resource route under inventory. prefix - InventoryServiceProvider policy registration - Frontend: Index + Show pages, extended TypeScript interface, Sidebar link - 10 Pest tests covering auth, CRUD, conversion logic, accessor (1110 → 1120) https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 00297a8 commit b2ff06d

11 files changed

Lines changed: 547 additions & 2 deletions

File tree

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Inventory\Models\UnitOfMeasure;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class UnitOfMeasureController extends Controller
13+
{
14+
public function index(Request $request): Response
15+
{
16+
$this->authorize('viewAny', UnitOfMeasure::class);
17+
18+
$query = UnitOfMeasure::query();
19+
20+
if ($request->filled('type')) {
21+
$query->where('type', $request->input('type'));
22+
}
23+
24+
$units = $query->latest()->paginate(20)->withQueryString();
25+
26+
return Inertia::render('Inventory/UnitsOfMeasure/Index', [
27+
'units' => $units,
28+
]);
29+
}
30+
31+
public function store(Request $request): RedirectResponse
32+
{
33+
$this->authorize('create', UnitOfMeasure::class);
34+
35+
$validated = $request->validate([
36+
'name' => 'required|string|max:100',
37+
'abbreviation' => 'required|string|max:20',
38+
'type' => 'nullable|string|max:50',
39+
'is_base' => 'boolean',
40+
'conversion_factor' => 'nullable|numeric|min:0',
41+
'is_active' => 'boolean',
42+
]);
43+
44+
$validated['tenant_id'] = app('tenant')->id;
45+
46+
UnitOfMeasure::create($validated);
47+
48+
return back();
49+
}
50+
51+
public function show(UnitOfMeasure $unitsOfMeasure): Response
52+
{
53+
$this->authorize('view', $unitsOfMeasure);
54+
55+
return Inertia::render('Inventory/UnitsOfMeasure/Show', [
56+
'unit' => $unitsOfMeasure,
57+
]);
58+
}
59+
60+
public function update(Request $request, UnitOfMeasure $unitsOfMeasure): RedirectResponse
61+
{
62+
$this->authorize('update', $unitsOfMeasure);
63+
64+
$validated = $request->validate([
65+
'name' => 'required|string|max:100',
66+
'abbreviation' => 'required|string|max:20',
67+
'type' => 'nullable|string|max:50',
68+
'is_base' => 'boolean',
69+
'conversion_factor' => 'nullable|numeric|min:0',
70+
'is_active' => 'boolean',
71+
]);
72+
73+
$unitsOfMeasure->update($validated);
74+
75+
return back();
76+
}
77+
78+
public function destroy(UnitOfMeasure $unitsOfMeasure): RedirectResponse
79+
{
80+
$this->authorize('delete', $unitsOfMeasure);
81+
82+
$unitsOfMeasure->delete();
83+
84+
return redirect()->route('inventory.units-of-measure.index');
85+
}
86+
}

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,38 @@ class UnitOfMeasure extends Model
1414

1515
protected $table = 'units_of_measure';
1616

17-
protected $fillable = ['tenant_id', 'name', 'abbreviation'];
17+
protected $fillable = [
18+
'tenant_id',
19+
'name',
20+
'abbreviation',
21+
'type',
22+
'is_base',
23+
'conversion_factor',
24+
'is_active',
25+
];
26+
27+
protected $casts = [
28+
'is_base' => 'boolean',
29+
'is_active' => 'boolean',
30+
'conversion_factor' => 'float',
31+
];
1832

1933
public function products(): HasMany
2034
{
2135
return $this->hasMany(Product::class, 'uom_id');
2236
}
37+
38+
public function convertTo(float $quantity, self $target): float
39+
{
40+
if ($target->conversion_factor == 0) {
41+
return 0.0;
42+
}
43+
44+
return ($quantity * $this->conversion_factor) / $target->conversion_factor;
45+
}
46+
47+
public function getDisplayNameAttribute(): string
48+
{
49+
return "{$this->name} ({$this->abbreviation})";
50+
}
2351
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace App\Modules\Inventory\Policies;
4+
5+
use App\Models\User;
6+
7+
class UnitOfMeasurePolicy
8+
{
9+
public function viewAny(User $user): bool
10+
{
11+
return $user->hasPermissionTo('inventory.view');
12+
}
13+
14+
public function view(User $user, $model): bool
15+
{
16+
return $user->hasPermissionTo('inventory.view');
17+
}
18+
19+
public function create(User $user): bool
20+
{
21+
return $user->hasPermissionTo('inventory.create');
22+
}
23+
24+
public function update(User $user, $model): bool
25+
{
26+
return $user->hasPermissionTo('inventory.create');
27+
}
28+
29+
public function delete(User $user, $model): bool
30+
{
31+
return $user->hasPermissionTo('inventory.delete');
32+
}
33+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@
5858
use App\Modules\Inventory\Models\PriceListItem;
5959
use App\Modules\Inventory\Models\CustomerDiscount;
6060
use App\Modules\Inventory\Policies\PriceListPolicy;
61+
use App\Modules\Inventory\Models\UnitOfMeasure;
62+
use App\Modules\Inventory\Policies\UnitOfMeasurePolicy;
6163
use Illuminate\Support\Facades\Gate;
6264
use Illuminate\Support\ServiceProvider;
6365

@@ -107,5 +109,6 @@ public function boot(): void
107109
Gate::policy(PriceList::class, PriceListPolicy::class);
108110
Gate::policy(PriceListItem::class, PriceListPolicy::class);
109111
Gate::policy(CustomerDiscount::class, PriceListPolicy::class);
112+
Gate::policy(UnitOfMeasure::class, UnitOfMeasurePolicy::class);
110113
}
111114
}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,3 +215,9 @@
215215
Route::resource('price-lists', PriceListController::class)->only(['index', 'store', 'show', 'destroy']);
216216
Route::resource('customer-discounts', CustomerDiscountController::class)->only(['index', 'store', 'destroy']);
217217
});
218+
219+
// Units of Measure
220+
use App\Modules\Inventory\Http\Controllers\UnitOfMeasureController;
221+
Route::middleware(['web', 'auth', 'verified'])->prefix('inventory')->name('inventory.')->group(function () {
222+
Route::resource('units-of-measure', UnitOfMeasureController::class)->names('units-of-measure');
223+
});
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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::table('units_of_measure', function (Blueprint $table) {
12+
if (! Schema::hasColumn('units_of_measure', 'type')) {
13+
$table->string('type')->default('unit')->after('abbreviation');
14+
}
15+
if (! Schema::hasColumn('units_of_measure', 'is_base')) {
16+
$table->boolean('is_base')->default(false)->after('type');
17+
}
18+
if (! Schema::hasColumn('units_of_measure', 'conversion_factor')) {
19+
$table->decimal('conversion_factor', 15, 6)->default(1.000000)->after('is_base');
20+
}
21+
if (! Schema::hasColumn('units_of_measure', 'is_active')) {
22+
$table->boolean('is_active')->default(true)->after('conversion_factor');
23+
}
24+
});
25+
}
26+
27+
public function down(): void
28+
{
29+
Schema::table('units_of_measure', function (Blueprint $table) {
30+
$table->dropColumn(['type', 'is_base', 'conversion_factor', 'is_active']);
31+
});
32+
}
33+
};

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ const navItems: NavItem[] = [
8686
{ label: 'Sales Orders', href: '/inventory/sales-orders', icon: <span /> },
8787
{ label: 'Price Lists', href: '/inventory/price-lists', icon: <span /> },
8888
{ label: 'Discounts', href: '/inventory/customer-discounts', icon: <span /> },
89+
{ label: 'Units of Measure', href: '/inventory/units-of-measure', icon: <span /> },
8990
],
9091
},
9192
{
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import React, { useState } from 'react';
2+
import { Head, useForm } from '@inertiajs/react';
3+
import AppLayout from '@/Layouts/AppLayout';
4+
import { UnitOfMeasure, Paginator } from '@/types/inventory';
5+
6+
interface Props {
7+
units: Paginator<UnitOfMeasure>;
8+
}
9+
10+
export default function Index({ units }: Props) {
11+
const [showForm, setShowForm] = useState(false);
12+
const { data, setData, post, processing, errors, reset } = useForm({
13+
name: '',
14+
abbreviation: '',
15+
type: 'unit',
16+
is_base: false as boolean,
17+
conversion_factor: 1,
18+
is_active: true as boolean,
19+
});
20+
21+
function submit(e: React.FormEvent) {
22+
e.preventDefault();
23+
post('/inventory/units-of-measure', {
24+
onSuccess: () => {
25+
reset();
26+
setShowForm(false);
27+
},
28+
});
29+
}
30+
31+
return (
32+
<AppLayout>
33+
<Head title="Units of Measure" />
34+
<div className="p-6">
35+
<div className="flex justify-between items-center mb-4">
36+
<h1 className="text-2xl font-bold">Units of Measure</h1>
37+
<button
38+
onClick={() => setShowForm(!showForm)}
39+
className="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700 text-sm"
40+
>
41+
New Unit
42+
</button>
43+
</div>
44+
45+
{showForm && (
46+
<form onSubmit={submit} className="mb-6 bg-white border rounded p-4 flex flex-wrap gap-4 items-end">
47+
<div>
48+
<label className="block text-xs font-medium text-gray-700 mb-1">Name *</label>
49+
<input
50+
type="text"
51+
value={data.name}
52+
onChange={e => setData('name', e.target.value)}
53+
className="border rounded px-3 py-1.5 text-sm"
54+
placeholder="Kilogram"
55+
/>
56+
{errors.name && <p className="text-red-500 text-xs mt-1">{errors.name}</p>}
57+
</div>
58+
<div>
59+
<label className="block text-xs font-medium text-gray-700 mb-1">Abbreviation *</label>
60+
<input
61+
type="text"
62+
value={data.abbreviation}
63+
onChange={e => setData('abbreviation', e.target.value)}
64+
className="border rounded px-3 py-1.5 text-sm"
65+
placeholder="kg"
66+
/>
67+
{errors.abbreviation && <p className="text-red-500 text-xs mt-1">{errors.abbreviation}</p>}
68+
</div>
69+
<div>
70+
<label className="block text-xs font-medium text-gray-700 mb-1">Type</label>
71+
<select
72+
value={data.type}
73+
onChange={e => setData('type', e.target.value)}
74+
className="border rounded px-3 py-1.5 text-sm"
75+
>
76+
<option value="unit">Unit</option>
77+
<option value="weight">Weight</option>
78+
<option value="volume">Volume</option>
79+
<option value="length">Length</option>
80+
<option value="area">Area</option>
81+
<option value="time">Time</option>
82+
</select>
83+
</div>
84+
<div>
85+
<label className="block text-xs font-medium text-gray-700 mb-1">Conversion Factor</label>
86+
<input
87+
type="number"
88+
step="0.000001"
89+
value={data.conversion_factor}
90+
onChange={e => setData('conversion_factor', parseFloat(e.target.value))}
91+
className="border rounded px-3 py-1.5 text-sm w-32"
92+
/>
93+
</div>
94+
<div className="flex items-center gap-2">
95+
<input
96+
type="checkbox"
97+
id="is_base"
98+
checked={data.is_base}
99+
onChange={e => setData('is_base', e.target.checked)}
100+
/>
101+
<label htmlFor="is_base" className="text-xs font-medium text-gray-700">Base Unit</label>
102+
</div>
103+
<div className="flex items-center gap-2">
104+
<input
105+
type="checkbox"
106+
id="is_active"
107+
checked={data.is_active}
108+
onChange={e => setData('is_active', e.target.checked)}
109+
/>
110+
<label htmlFor="is_active" className="text-xs font-medium text-gray-700">Active</label>
111+
</div>
112+
<button
113+
type="submit"
114+
disabled={processing}
115+
className="px-4 py-1.5 bg-indigo-600 text-white rounded text-sm hover:bg-indigo-700 disabled:opacity-50"
116+
>
117+
Save
118+
</button>
119+
<button
120+
type="button"
121+
onClick={() => setShowForm(false)}
122+
className="px-4 py-1.5 border rounded text-sm hover:bg-gray-50"
123+
>
124+
Cancel
125+
</button>
126+
</form>
127+
)}
128+
129+
<div className="bg-white rounded border overflow-hidden">
130+
<table className="min-w-full divide-y divide-gray-200">
131+
<thead className="bg-gray-50">
132+
<tr>
133+
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Name</th>
134+
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Abbreviation</th>
135+
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
136+
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Base</th>
137+
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Factor</th>
138+
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Active</th>
139+
</tr>
140+
</thead>
141+
<tbody className="divide-y divide-gray-200">
142+
{units.data.map(unit => (
143+
<tr key={unit.id} className="hover:bg-gray-50">
144+
<td className="px-4 py-3 text-sm font-medium text-gray-900">{unit.name}</td>
145+
<td className="px-4 py-3 text-sm text-gray-500">{unit.abbreviation}</td>
146+
<td className="px-4 py-3 text-sm text-gray-500 capitalize">{unit.type}</td>
147+
<td className="px-4 py-3 text-sm text-gray-500">{unit.is_base ? 'Yes' : 'No'}</td>
148+
<td className="px-4 py-3 text-sm text-gray-500">{unit.conversion_factor}</td>
149+
<td className="px-4 py-3 text-sm">
150+
<span className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${unit.is_active ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>
151+
{unit.is_active ? 'Active' : 'Inactive'}
152+
</span>
153+
</td>
154+
</tr>
155+
))}
156+
{units.data.length === 0 && (
157+
<tr>
158+
<td colSpan={6} className="px-4 py-8 text-center text-sm text-gray-500">
159+
No units of measure found.
160+
</td>
161+
</tr>
162+
)}
163+
</tbody>
164+
</table>
165+
</div>
166+
</div>
167+
</AppLayout>
168+
);
169+
}

0 commit comments

Comments
 (0)