Skip to content

Commit 391685f

Browse files
committed
feat: add Subcontracting and Rental modules (backend, WIP)
Subcontracting: - SubcontractOrder and SubcontractComponent models with BelongsToTenant - Status transitions: draft → sent → in_progress → received/cancelled - SubcontractController with CRUD + send/startProduction/receive/cancel/addComponent/removeComponent - 2 migrations: subcontracts, subcontract_components Rental: - RentalItem and RentalAgreement models with BelongsToTenant - Item statuses: available/rented/maintenance; Agreement: active/returned/overdue/cancelled - RentalController with CRUD + rent/return/calendar/agreements actions - 2 migrations: rental_items, rental_agreements React pages and tests pending (agents still running) https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent a13a767 commit 391685f

15 files changed

Lines changed: 802 additions & 0 deletions

erp/app/Modules/Core/Providers/CoreServiceProvider.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
use App\Modules\Approvals\Providers\ApprovalsServiceProvider;
2222
use App\Modules\Ecommerce\Providers\EcommerceServiceProvider;
2323
use App\Modules\Discuss\Providers\DiscussServiceProvider;
24+
use App\Modules\Subcontracting\Providers\SubcontractingServiceProvider;
25+
use App\Modules\Rental\Providers\RentalServiceProvider;
2426
use Illuminate\Support\Facades\Gate;
2527
use Illuminate\Support\ServiceProvider;
2628

@@ -43,6 +45,7 @@ public function register(): void
4345
$this->app->register(ApprovalsServiceProvider::class);
4446
$this->app->register(EcommerceServiceProvider::class);
4547
$this->app->register(DiscussServiceProvider::class);
48+
$this->app->register(SubcontractingServiceProvider::class);
4649
}
4750

4851
public function boot(): void
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
<?php
2+
3+
namespace App\Modules\Rental\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Rental\Models\RentalAgreement;
7+
use App\Modules\Rental\Models\RentalItem;
8+
use Illuminate\Http\JsonResponse;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class RentalController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$query = RentalItem::query();
19+
20+
if ($request->filled('status')) {
21+
$query->where('status', $request->status);
22+
}
23+
24+
if ($request->filled('category')) {
25+
$query->where('category', $request->category);
26+
}
27+
28+
$items = $query->orderBy('name')->paginate(20)->withQueryString();
29+
30+
return Inertia::render('Rental/Index', [
31+
'items' => $items,
32+
'filters' => $request->only(['status', 'category']),
33+
]);
34+
}
35+
36+
public function show(RentalItem $item): Response
37+
{
38+
$item->load([
39+
'agreements' => fn ($q) => $q->latest()->limit(10),
40+
]);
41+
42+
return Inertia::render('Rental/Show', [
43+
'item' => $item,
44+
]);
45+
}
46+
47+
public function store(Request $request): RedirectResponse|\Symfony\Component\HttpFoundation\Response
48+
{
49+
$validated = $request->validate([
50+
'name' => 'required|string|max:255',
51+
'description' => 'nullable|string',
52+
'category' => 'nullable|string|max:100',
53+
'daily_rate' => 'required|numeric|min:0',
54+
'serial_number' => 'nullable|string|max:100',
55+
'status' => 'nullable|in:available,rented,maintenance',
56+
]);
57+
58+
$item = RentalItem::create($validated);
59+
60+
if ($request->wantsJson()) {
61+
return response()->json($item, 201);
62+
}
63+
64+
return redirect()->route('rental.items.show', $item)->with('success', 'Rental item created.');
65+
}
66+
67+
public function update(Request $request, RentalItem $item): RedirectResponse|\Symfony\Component\HttpFoundation\Response
68+
{
69+
$validated = $request->validate([
70+
'name' => 'required|string|max:255',
71+
'description' => 'nullable|string',
72+
'category' => 'nullable|string|max:100',
73+
'daily_rate' => 'required|numeric|min:0',
74+
'serial_number' => 'nullable|string|max:100',
75+
'status' => 'nullable|in:available,rented,maintenance',
76+
]);
77+
78+
$item->update($validated);
79+
80+
if ($request->wantsJson()) {
81+
return response()->json($item);
82+
}
83+
84+
return redirect()->route('rental.items.show', $item)->with('success', 'Rental item updated.');
85+
}
86+
87+
public function destroy(RentalItem $item): RedirectResponse|\Symfony\Component\HttpFoundation\Response
88+
{
89+
if (! $item->isAvailable()) {
90+
if (request()->wantsJson()) {
91+
return response()->json(['message' => 'Cannot delete an item that is currently rented or under maintenance.'], 422);
92+
}
93+
return redirect()->back()->with('error', 'Cannot delete an item that is currently rented or under maintenance.');
94+
}
95+
96+
$hasActiveAgreement = $item->agreements()->where('status', 'active')->exists();
97+
if ($hasActiveAgreement) {
98+
if (request()->wantsJson()) {
99+
return response()->json(['message' => 'Cannot delete an item with an active rental agreement.'], 422);
100+
}
101+
return redirect()->back()->with('error', 'Cannot delete an item with an active rental agreement.');
102+
}
103+
104+
$item->delete();
105+
106+
if (request()->wantsJson()) {
107+
return response()->json(null, 204);
108+
}
109+
110+
return redirect()->route('rental.items.index')->with('success', 'Rental item deleted.');
111+
}
112+
113+
public function rent(Request $request, RentalItem $item): RedirectResponse|\Symfony\Component\HttpFoundation\Response
114+
{
115+
if (! $item->isAvailable()) {
116+
if ($request->wantsJson()) {
117+
return response()->json(['message' => 'Item is not available for rent.'], 422);
118+
}
119+
return redirect()->back()->with('error', 'Item is not available for rent.')->withErrors(['item' => 'Item is not available for rent.']);
120+
}
121+
122+
$validated = $request->validate([
123+
'customer_name' => 'required|string|max:255',
124+
'customer_email' => 'nullable|email|max:255',
125+
'start_date' => 'required|date',
126+
'end_date' => 'nullable|date|after_or_equal:start_date',
127+
'daily_rate' => 'nullable|numeric|min:0',
128+
'deposit' => 'nullable|numeric|min:0',
129+
'notes' => 'nullable|string',
130+
]);
131+
132+
$agreement = RentalAgreement::create([
133+
'rental_item_id' => $item->id,
134+
'customer_name' => $validated['customer_name'],
135+
'customer_email' => $validated['customer_email'] ?? null,
136+
'start_date' => $validated['start_date'],
137+
'end_date' => $validated['end_date'] ?? null,
138+
'daily_rate' => $validated['daily_rate'] ?? $item->daily_rate,
139+
'deposit' => $validated['deposit'] ?? 0,
140+
'notes' => $validated['notes'] ?? null,
141+
'status' => 'active',
142+
]);
143+
144+
$item->update(['status' => 'rented']);
145+
146+
if ($request->wantsJson()) {
147+
return response()->json($agreement->load('item'), 201);
148+
}
149+
150+
return redirect()->route('rental.items.show', $item)->with('success', 'Item rented successfully.');
151+
}
152+
153+
public function returnItem(Request $request, RentalItem $item): RedirectResponse|\Symfony\Component\HttpFoundation\Response
154+
{
155+
$agreement = $item->agreements()->where('status', 'active')->latest()->first();
156+
157+
if (! $agreement) {
158+
if ($request->wantsJson()) {
159+
return response()->json(['message' => 'No active rental agreement found for this item.'], 422);
160+
}
161+
return redirect()->back()->with('error', 'No active rental agreement found for this item.');
162+
}
163+
164+
$agreement->return();
165+
166+
if ($request->wantsJson()) {
167+
return response()->json($agreement->fresh());
168+
}
169+
170+
return redirect()->route('rental.items.show', $item)->with('success', 'Item returned successfully.');
171+
}
172+
173+
public function calendar(Request $request): Response
174+
{
175+
$items = RentalItem::with([
176+
'agreements' => fn ($q) => $q->whereIn('status', ['active', 'overdue'])
177+
->orderBy('start_date'),
178+
])->orderBy('name')->get();
179+
180+
return Inertia::render('Rental/Calendar', [
181+
'items' => $items,
182+
]);
183+
}
184+
185+
public function agreements(Request $request): Response
186+
{
187+
$query = RentalAgreement::with('item');
188+
189+
if ($request->filled('status')) {
190+
$query->where('status', $request->status);
191+
}
192+
193+
$agreements = $query->latest()->paginate(20)->withQueryString();
194+
195+
return Inertia::render('Rental/Agreements', [
196+
'agreements' => $agreements,
197+
'filters' => $request->only(['status']),
198+
]);
199+
}
200+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
namespace App\Modules\Rental\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Carbon\Carbon;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
11+
class RentalAgreement extends Model
12+
{
13+
use BelongsToTenant, SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'rental_item_id',
18+
'customer_name',
19+
'customer_email',
20+
'start_date',
21+
'end_date',
22+
'daily_rate',
23+
'deposit',
24+
'status',
25+
'notes',
26+
'returned_at',
27+
];
28+
29+
protected $casts = [
30+
'start_date' => 'date',
31+
'end_date' => 'date',
32+
'returned_at' => 'datetime',
33+
];
34+
35+
public function item(): BelongsTo
36+
{
37+
return $this->belongsTo(RentalItem::class, 'rental_item_id');
38+
}
39+
40+
public function daysRented(): int
41+
{
42+
$end = $this->end_date ?? Carbon::today();
43+
return (int) $this->start_date->diffInDays($end) + 1;
44+
}
45+
46+
public function totalAmount(): float
47+
{
48+
return (float) ($this->daily_rate * $this->daysRented());
49+
}
50+
51+
public function isOverdue(): bool
52+
{
53+
return $this->end_date !== null
54+
&& $this->end_date->lt(Carbon::today())
55+
&& $this->status === 'active';
56+
}
57+
58+
public function return(Carbon $returnedAt = null): void
59+
{
60+
$this->update([
61+
'status' => 'returned',
62+
'returned_at' => $returnedAt ?? now(),
63+
]);
64+
65+
if ($this->item) {
66+
$this->item->update(['status' => 'available']);
67+
}
68+
}
69+
70+
public function cancel(): void
71+
{
72+
$this->update(['status' => 'cancelled']);
73+
74+
if ($this->item) {
75+
$this->item->update(['status' => 'available']);
76+
}
77+
}
78+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
<?php
2+
3+
namespace App\Modules\Rental\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\Relations\HasOne;
9+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
11+
class RentalItem extends Model
12+
{
13+
use BelongsToTenant, SoftDeletes;
14+
15+
protected $fillable = [
16+
'tenant_id',
17+
'name',
18+
'description',
19+
'category',
20+
'daily_rate',
21+
'status',
22+
'serial_number',
23+
];
24+
25+
public function agreements(): HasMany
26+
{
27+
return $this->hasMany(RentalAgreement::class, 'rental_item_id');
28+
}
29+
30+
public function currentAgreement(): HasOne
31+
{
32+
return $this->hasOne(RentalAgreement::class, 'rental_item_id')
33+
->where('status', 'active')
34+
->latestOfMany();
35+
}
36+
37+
public function isAvailable(): bool
38+
{
39+
return $this->status === 'available';
40+
}
41+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
namespace App\Modules\Rental\Providers;
4+
5+
use Illuminate\Support\ServiceProvider;
6+
7+
class RentalServiceProvider extends ServiceProvider
8+
{
9+
public function register(): void {}
10+
11+
public function boot(): void
12+
{
13+
$this->loadRoutesFrom(__DIR__ . '/../routes/rental.php');
14+
}
15+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php
2+
3+
use App\Modules\Rental\Http\Controllers\RentalController;
4+
use Illuminate\Support\Facades\Route;
5+
6+
Route::middleware(['web', 'auth', 'verified'])->prefix('rental')->name('rental.')->group(function () {
7+
// Custom action routes BEFORE resource
8+
Route::post('items/{item}/rent', [RentalController::class, 'rent'])->name('items.rent');
9+
Route::post('items/{item}/return', [RentalController::class, 'returnItem'])->name('items.return');
10+
Route::get('calendar', [RentalController::class, 'calendar'])->name('calendar');
11+
Route::get('agreements', [RentalController::class, 'agreements'])->name('agreements');
12+
Route::resource('items', RentalController::class)->except(['create', 'edit']);
13+
});

0 commit comments

Comments
 (0)