Skip to content

Commit 7ad792d

Browse files
committed
feat(finance): Phase 148 — Finance Profit Centers
Adds ProfitCenter model with hierarchical parent-child structure, three center types (profit/cost/investment), and activate/deactivate transitions. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent fa4b151 commit 7ad792d

11 files changed

Lines changed: 634 additions & 0 deletions

File tree

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Modules\Finance\Models\ProfitCenter;
6+
use Illuminate\Http\RedirectResponse;
7+
use Illuminate\Http\Request;
8+
use Inertia\Inertia;
9+
use Inertia\Response;
10+
11+
class ProfitCenterController
12+
{
13+
public function index(): Response
14+
{
15+
$profitCenters = ProfitCenter::with('parent', 'manager')
16+
->orderBy('code')
17+
->paginate(20);
18+
19+
return Inertia::render('Finance/ProfitCenters/Index', compact('profitCenters'));
20+
}
21+
22+
public function create(): Response
23+
{
24+
$parents = ProfitCenter::orderBy('name')->get(['id', 'name', 'code']);
25+
return Inertia::render('Finance/ProfitCenters/Create', compact('parents'));
26+
}
27+
28+
public function store(Request $request): RedirectResponse
29+
{
30+
$data = $request->validate([
31+
'code' => 'required|string|max:50|unique:profit_centers,code',
32+
'name' => 'required|string|max:255',
33+
'type' => 'required|string|in:profit,cost,investment',
34+
'parent_id' => 'nullable|exists:profit_centers,id',
35+
'manager_id' => 'nullable|exists:users,id',
36+
'budget' => 'nullable|numeric|min:0',
37+
'description' => 'nullable|string',
38+
]);
39+
40+
$data['tenant_id'] = app('tenant')->id;
41+
42+
ProfitCenter::create($data);
43+
44+
return redirect()->route('finance.profit-centers.index');
45+
}
46+
47+
public function show(ProfitCenter $profitCenter): Response
48+
{
49+
$profitCenter->load('parent', 'children', 'manager');
50+
return Inertia::render('Finance/ProfitCenters/Show', compact('profitCenter'));
51+
}
52+
53+
public function edit(ProfitCenter $profitCenter): Response
54+
{
55+
$parents = ProfitCenter::where('id', '!=', $profitCenter->id)->orderBy('name')->get(['id', 'name', 'code']);
56+
return Inertia::render('Finance/ProfitCenters/Edit', compact('profitCenter', 'parents'));
57+
}
58+
59+
public function update(Request $request, ProfitCenter $profitCenter): RedirectResponse
60+
{
61+
$data = $request->validate([
62+
'code' => 'required|string|max:50|unique:profit_centers,code,' . $profitCenter->id,
63+
'name' => 'required|string|max:255',
64+
'type' => 'required|string|in:profit,cost,investment',
65+
'parent_id' => 'nullable|exists:profit_centers,id',
66+
'manager_id' => 'nullable|exists:users,id',
67+
'budget' => 'nullable|numeric|min:0',
68+
'description' => 'nullable|string',
69+
]);
70+
71+
$profitCenter->update($data);
72+
73+
return redirect()->route('finance.profit-centers.index');
74+
}
75+
76+
public function destroy(ProfitCenter $profitCenter): RedirectResponse
77+
{
78+
$profitCenter->delete();
79+
return redirect()->route('finance.profit-centers.index');
80+
}
81+
82+
public function activate(ProfitCenter $profitCenter): RedirectResponse
83+
{
84+
$profitCenter->activate();
85+
return redirect()->route('finance.profit-centers.index');
86+
}
87+
88+
public function deactivate(ProfitCenter $profitCenter): RedirectResponse
89+
{
90+
$profitCenter->deactivate();
91+
return redirect()->route('finance.profit-centers.index');
92+
}
93+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
7+
use Illuminate\Database\Eloquent\Casts\Attribute;
8+
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
10+
use Illuminate\Database\Eloquent\Relations\HasMany;
11+
use Illuminate\Database\Eloquent\SoftDeletes;
12+
13+
class ProfitCenter extends Model
14+
{
15+
use BelongsToTenant, SoftDeletes;
16+
17+
protected $fillable = [
18+
'tenant_id',
19+
'code',
20+
'name',
21+
'type',
22+
'status',
23+
'parent_id',
24+
'manager_id',
25+
'budget',
26+
'description',
27+
];
28+
29+
protected $casts = [
30+
'budget' => 'float',
31+
];
32+
33+
protected $attributes = [
34+
'type' => 'profit',
35+
'status' => 'active',
36+
];
37+
38+
public function parent(): BelongsTo
39+
{
40+
return $this->belongsTo(ProfitCenter::class, 'parent_id');
41+
}
42+
43+
public function children(): HasMany
44+
{
45+
return $this->hasMany(ProfitCenter::class, 'parent_id');
46+
}
47+
48+
public function manager(): BelongsTo
49+
{
50+
return $this->belongsTo(User::class, 'manager_id');
51+
}
52+
53+
public function activate(): void
54+
{
55+
$this->status = 'active';
56+
$this->save();
57+
}
58+
59+
public function deactivate(): void
60+
{
61+
$this->status = 'inactive';
62+
$this->save();
63+
}
64+
65+
protected function isActive(): Attribute
66+
{
67+
return Attribute::make(get: fn () => $this->status === 'active');
68+
}
69+
70+
protected function isCostCenter(): Attribute
71+
{
72+
return Attribute::make(get: fn () => $this->type === 'cost');
73+
}
74+
75+
protected function isProfitCenter(): Attribute
76+
{
77+
return Attribute::make(get: fn () => $this->type === 'profit');
78+
}
79+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\ProfitCenter;
7+
8+
class ProfitCenterPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('finance.view');
13+
}
14+
15+
public function view(User $user, ProfitCenter $profitCenter): bool
16+
{
17+
return $user->can('finance.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->can('finance.create');
23+
}
24+
25+
public function update(User $user, ProfitCenter $profitCenter): bool
26+
{
27+
return $user->can('finance.create');
28+
}
29+
30+
public function activate(User $user, ProfitCenter $profitCenter): bool
31+
{
32+
return $user->can('finance.create');
33+
}
34+
35+
public function deactivate(User $user, ProfitCenter $profitCenter): bool
36+
{
37+
return $user->can('finance.create');
38+
}
39+
40+
public function delete(User $user, ProfitCenter $profitCenter): bool
41+
{
42+
return $user->can('finance.delete');
43+
}
44+
}

erp/app/Modules/Finance/Providers/FinanceServiceProvider.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@
119119
use App\Modules\Finance\Policies\CustomerCreditPolicy;
120120
use App\Modules\Finance\Models\ExpenseBudget;
121121
use App\Modules\Finance\Policies\ExpenseBudgetPolicy;
122+
use App\Modules\Finance\Models\ProfitCenter;
123+
use App\Modules\Finance\Policies\ProfitCenterPolicy;
122124
use Illuminate\Support\Facades\Gate;
123125
use Illuminate\Support\ServiceProvider;
124126

@@ -207,6 +209,7 @@ public function boot(): void
207209
Gate::policy(PaymentScheduleItem::class, PaymentSchedulePolicy::class);
208210
Gate::policy(CustomerCredit::class, CustomerCreditPolicy::class);
209211
Gate::policy(ExpenseBudget::class, ExpenseBudgetPolicy::class);
212+
Gate::policy(ProfitCenter::class, ProfitCenterPolicy::class);
210213
if ($this->app->runningInConsole()) {
211214
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);
212215
}

erp/app/Modules/Finance/routes/finance.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,3 +445,11 @@
445445
Route::post('expense-budgets/{expense_budget}/close', [ExpenseBudgetController::class, 'close'])->name('expense-budgets.close');
446446
Route::resource('expense-budgets', ExpenseBudgetController::class);
447447
});
448+
449+
// Profit Centers
450+
use App\Modules\Finance\Http\Controllers\ProfitCenterController;
451+
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
452+
Route::post('profit-centers/{profit_center}/activate', [ProfitCenterController::class, 'activate'])->name('profit-centers.activate');
453+
Route::post('profit-centers/{profit_center}/deactivate', [ProfitCenterController::class, 'deactivate'])->name('profit-centers.deactivate');
454+
Route::resource('profit-centers', ProfitCenterController::class);
455+
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
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('profit_centers', function (Blueprint $table) {
12+
$table->id();
13+
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
14+
$table->string('code')->unique();
15+
$table->string('name');
16+
$table->string('type')->default('profit'); // profit|cost|investment
17+
$table->string('status')->default('active'); // active|inactive
18+
$table->foreignId('parent_id')->nullable()->constrained('profit_centers')->nullOnDelete();
19+
$table->foreignId('manager_id')->nullable()->constrained('users')->nullOnDelete();
20+
$table->decimal('budget', 15, 2)->nullable();
21+
$table->text('description')->nullable();
22+
$table->timestamps();
23+
$table->softDeletes();
24+
});
25+
}
26+
27+
public function down(): void
28+
{
29+
Schema::dropIfExists('profit_centers');
30+
}
31+
};
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import React from 'react';
2+
import { Head, useForm } from '@inertiajs/react';
3+
4+
interface Parent { id: number; code: string; name: string; }
5+
6+
export default function Create({ parents }: { parents: Parent[] }) {
7+
const { data, setData, post, errors } = useForm({
8+
code: '',
9+
name: '',
10+
type: 'profit',
11+
parent_id: '',
12+
budget: '',
13+
description: '',
14+
});
15+
16+
return (
17+
<>
18+
<Head title="New Profit Center" />
19+
<div className="p-6 max-w-xl">
20+
<h1 className="text-2xl font-bold mb-6">New Profit Center</h1>
21+
<form onSubmit={e => { e.preventDefault(); post('/finance/profit-centers'); }}>
22+
<div className="grid grid-cols-2 gap-4 mb-4">
23+
<div>
24+
<label className="block text-sm font-medium mb-1">Code *</label>
25+
<input value={data.code} onChange={e => setData('code', e.target.value)} className="w-full border rounded px-3 py-2" />
26+
{errors.code && <p className="text-red-600 text-sm">{errors.code}</p>}
27+
</div>
28+
<div>
29+
<label className="block text-sm font-medium mb-1">Type *</label>
30+
<select value={data.type} onChange={e => setData('type', e.target.value)} className="w-full border rounded px-3 py-2">
31+
<option value="profit">Profit Center</option>
32+
<option value="cost">Cost Center</option>
33+
<option value="investment">Investment Center</option>
34+
</select>
35+
</div>
36+
</div>
37+
<div className="mb-4">
38+
<label className="block text-sm font-medium mb-1">Name *</label>
39+
<input value={data.name} onChange={e => setData('name', e.target.value)} className="w-full border rounded px-3 py-2" />
40+
{errors.name && <p className="text-red-600 text-sm">{errors.name}</p>}
41+
</div>
42+
<div className="grid grid-cols-2 gap-4 mb-4">
43+
<div>
44+
<label className="block text-sm font-medium mb-1">Parent Center</label>
45+
<select value={data.parent_id} onChange={e => setData('parent_id', e.target.value)} className="w-full border rounded px-3 py-2">
46+
<option value="">— None —</option>
47+
{parents.map(p => <option key={p.id} value={p.id}>{p.code}{p.name}</option>)}
48+
</select>
49+
</div>
50+
<div>
51+
<label className="block text-sm font-medium mb-1">Budget</label>
52+
<input type="number" value={data.budget} onChange={e => setData('budget', e.target.value)} className="w-full border rounded px-3 py-2" />
53+
</div>
54+
</div>
55+
<div className="mb-6">
56+
<label className="block text-sm font-medium mb-1">Description</label>
57+
<textarea value={data.description} onChange={e => setData('description', e.target.value)} rows={3} className="w-full border rounded px-3 py-2" />
58+
</div>
59+
<button type="submit" className="bg-blue-600 text-white px-6 py-2 rounded">Create</button>
60+
</form>
61+
</div>
62+
</>
63+
);
64+
}

0 commit comments

Comments
 (0)