Skip to content

Commit d756b71

Browse files
committed
feat: Phase 31 — Project Tracking with billable hours and invoice linking
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent f921e31 commit d756b71

14 files changed

Lines changed: 1136 additions & 0 deletions

File tree

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\Contact;
7+
use App\Modules\Finance\Models\Project;
8+
use App\Modules\Finance\Models\ProjectTimeEntry;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class ProjectController extends Controller
15+
{
16+
public function index(): Response
17+
{
18+
$this->authorize('viewAny', Project::class);
19+
20+
$projects = Project::withCount(['timeEntries'])
21+
->with('contact')
22+
->orderByDesc('created_at')
23+
->get()
24+
->map(fn ($p) => [
25+
'id' => $p->id,
26+
'name' => $p->name,
27+
'description' => $p->description,
28+
'status' => $p->status,
29+
'budget' => $p->budget,
30+
'contact_id' => $p->contact_id,
31+
'invoice_id' => $p->invoice_id,
32+
'starts_on' => $p->starts_on?->toDateString(),
33+
'ends_on' => $p->ends_on?->toDateString(),
34+
'contact' => $p->contact ? ['id' => $p->contact->id, 'name' => $p->contact->name] : null,
35+
'time_entries_count' => $p->time_entries_count,
36+
]);
37+
38+
return Inertia::render('Finance/Projects/Index', [
39+
'projects' => $projects,
40+
'breadcrumbs' => [
41+
['label' => 'Finance'],
42+
['label' => 'Projects'],
43+
],
44+
]);
45+
}
46+
47+
public function create(): Response
48+
{
49+
$this->authorize('create', Project::class);
50+
51+
$contacts = Contact::orderBy('name')->get(['id', 'name']);
52+
53+
return Inertia::render('Finance/Projects/Create', [
54+
'contacts' => $contacts,
55+
'breadcrumbs' => [
56+
['label' => 'Finance'],
57+
['label' => 'Projects', 'href' => '/finance/projects'],
58+
['label' => 'New Project'],
59+
],
60+
]);
61+
}
62+
63+
public function store(Request $request): RedirectResponse
64+
{
65+
$this->authorize('create', Project::class);
66+
67+
$validated = $request->validate([
68+
'name' => ['required', 'string', 'max:255'],
69+
'description' => ['nullable', 'string'],
70+
'status' => ['required', 'in:draft,active,completed,cancelled'],
71+
'budget' => ['nullable', 'numeric', 'min:0'],
72+
'contact_id' => ['nullable', 'exists:contacts,id'],
73+
'invoice_id' => ['nullable', 'exists:invoices,id'],
74+
'starts_on' => ['nullable', 'date'],
75+
'ends_on' => ['nullable', 'date'],
76+
]);
77+
78+
$project = Project::create(array_merge($validated, [
79+
'tenant_id' => $request->user()->tenant_id,
80+
]));
81+
82+
return redirect()->route('finance.projects.show', $project)
83+
->with('success', 'Project created successfully.');
84+
}
85+
86+
public function show(Project $project): Response
87+
{
88+
$this->authorize('view', $project);
89+
90+
$project->load(['timeEntries.user', 'contact', 'invoice']);
91+
92+
return Inertia::render('Finance/Projects/Show', [
93+
'project' => [
94+
'id' => $project->id,
95+
'name' => $project->name,
96+
'description' => $project->description,
97+
'status' => $project->status,
98+
'budget' => $project->budget,
99+
'contact_id' => $project->contact_id,
100+
'invoice_id' => $project->invoice_id,
101+
'starts_on' => $project->starts_on?->toDateString(),
102+
'ends_on' => $project->ends_on?->toDateString(),
103+
'contact' => $project->contact ? ['id' => $project->contact->id, 'name' => $project->contact->name] : null,
104+
'invoice' => $project->invoice ? ['id' => $project->invoice->id, 'reference' => $project->invoice->number ?? '#' . $project->invoice->id] : null,
105+
'total_hours' => $project->total_hours,
106+
'billable_hours' => $project->billable_hours,
107+
'time_entries' => $project->timeEntries->map(fn ($e) => [
108+
'id' => $e->id,
109+
'project_id' => $e->project_id,
110+
'user_id' => $e->user_id,
111+
'description' => $e->description,
112+
'hours' => $e->hours,
113+
'billable' => $e->billable,
114+
'billed' => $e->billed,
115+
'entry_date' => $e->entry_date->toDateString(),
116+
'user' => $e->user ? ['id' => $e->user->id, 'name' => $e->user->name] : null,
117+
]),
118+
],
119+
'contacts' => Contact::orderBy('name')->get(['id', 'name']),
120+
'breadcrumbs' => [
121+
['label' => 'Finance'],
122+
['label' => 'Projects', 'href' => '/finance/projects'],
123+
['label' => $project->name],
124+
],
125+
]);
126+
}
127+
128+
public function update(Request $request, Project $project): RedirectResponse
129+
{
130+
$this->authorize('update', $project);
131+
132+
$validated = $request->validate([
133+
'name' => ['required', 'string', 'max:255'],
134+
'description' => ['nullable', 'string'],
135+
'status' => ['required', 'in:draft,active,completed,cancelled'],
136+
'budget' => ['nullable', 'numeric', 'min:0'],
137+
'contact_id' => ['nullable', 'exists:contacts,id'],
138+
'invoice_id' => ['nullable', 'exists:invoices,id'],
139+
'starts_on' => ['nullable', 'date'],
140+
'ends_on' => ['nullable', 'date'],
141+
]);
142+
143+
$project->update($validated);
144+
145+
return redirect()->route('finance.projects.show', $project)
146+
->with('success', 'Project updated successfully.');
147+
}
148+
149+
public function destroy(Project $project): RedirectResponse
150+
{
151+
$this->authorize('delete', $project);
152+
153+
$project->delete();
154+
155+
return redirect()->route('finance.projects.index')
156+
->with('success', 'Project deleted.');
157+
}
158+
159+
public function storeTimeEntry(Request $request, Project $project): RedirectResponse
160+
{
161+
$this->authorize('update', $project);
162+
163+
$validated = $request->validate([
164+
'description' => ['required', 'string'],
165+
'hours' => ['required', 'numeric', 'min:0.1'],
166+
'billable' => ['boolean'],
167+
'entry_date' => ['required', 'date'],
168+
]);
169+
170+
$project->timeEntries()->create(array_merge($validated, [
171+
'user_id' => auth()->id(),
172+
]));
173+
174+
return back()->with('success', 'Time entry logged.');
175+
}
176+
177+
public function markBilled(Request $request, Project $project): RedirectResponse
178+
{
179+
$this->authorize('update', $project);
180+
181+
$validated = $request->validate([
182+
'entry_ids' => ['array'],
183+
'entry_ids.*' => ['exists:project_time_entries,id'],
184+
]);
185+
186+
ProjectTimeEntry::whereIn('id', $validated['entry_ids'] ?? [])
187+
->where('project_id', $project->id)
188+
->update(['billed' => true]);
189+
190+
return back()->with('success', 'Entries marked as billed.');
191+
}
192+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Builder;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\Relations\HasMany;
10+
use Illuminate\Database\Eloquent\SoftDeletes;
11+
12+
class Project extends Model
13+
{
14+
use BelongsToTenant;
15+
use SoftDeletes;
16+
17+
protected $fillable = [
18+
'tenant_id',
19+
'name',
20+
'description',
21+
'status',
22+
'budget',
23+
'contact_id',
24+
'invoice_id',
25+
'starts_on',
26+
'ends_on',
27+
];
28+
29+
protected $casts = [
30+
'budget' => 'float',
31+
'starts_on' => 'date',
32+
'ends_on' => 'date',
33+
];
34+
35+
// Relations
36+
37+
public function timeEntries(): HasMany
38+
{
39+
return $this->hasMany(ProjectTimeEntry::class);
40+
}
41+
42+
public function contact(): BelongsTo
43+
{
44+
return $this->belongsTo(Contact::class);
45+
}
46+
47+
public function invoice(): BelongsTo
48+
{
49+
return $this->belongsTo(Invoice::class);
50+
}
51+
52+
// Accessors
53+
54+
public function getTotalHoursAttribute(): float
55+
{
56+
return (float) $this->timeEntries()->sum('hours');
57+
}
58+
59+
public function getBillableHoursAttribute(): float
60+
{
61+
return (float) $this->timeEntries()
62+
->where('billable', true)
63+
->where('billed', false)
64+
->sum('hours');
65+
}
66+
67+
// Scopes
68+
69+
public function scopeActive(Builder $query): Builder
70+
{
71+
return $query->where('status', 'active');
72+
}
73+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Models;
4+
5+
use App\Models\User;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class ProjectTimeEntry extends Model
10+
{
11+
protected $fillable = [
12+
'project_id',
13+
'user_id',
14+
'description',
15+
'hours',
16+
'billable',
17+
'billed',
18+
'entry_date',
19+
];
20+
21+
protected $casts = [
22+
'hours' => 'float',
23+
'billable' => 'boolean',
24+
'billed' => 'boolean',
25+
'entry_date' => 'date',
26+
];
27+
28+
public function project(): BelongsTo
29+
{
30+
return $this->belongsTo(Project::class);
31+
}
32+
33+
public function user(): BelongsTo
34+
{
35+
return $this->belongsTo(User::class);
36+
}
37+
}
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\Finance\Policies;
4+
5+
use App\Models\User;
6+
use App\Modules\Finance\Models\Project;
7+
8+
class ProjectPolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('finance.view');
13+
}
14+
15+
public function view(User $user, Project $project): 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, Project $project): bool
26+
{
27+
return $user->can('finance.create');
28+
}
29+
30+
public function delete(User $user, Project $project): bool
31+
{
32+
return $user->can('finance.delete');
33+
}
34+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@
1818
use App\Modules\Finance\Models\PriceList;
1919
use App\Modules\Finance\Models\DepreciationEntry;
2020
use App\Modules\Finance\Models\FixedAsset;
21+
use App\Modules\Finance\Models\Project;
2122
use App\Modules\Finance\Policies\AccountPolicy;
2223
use App\Modules\Finance\Policies\PriceListPolicy;
24+
use App\Modules\Finance\Policies\ProjectPolicy;
2325
use App\Modules\Finance\Policies\BudgetPolicy;
2426
use App\Modules\Finance\Policies\FixedAssetPolicy;
2527
use App\Modules\Finance\Policies\BankAccountPolicy;
@@ -60,6 +62,7 @@ public function boot(): void
6062
Gate::policy(FixedAsset::class, FixedAssetPolicy::class);
6163
Gate::policy(DepreciationEntry::class, FixedAssetPolicy::class);
6264
Gate::policy(PriceList::class, PriceListPolicy::class);
65+
Gate::policy(Project::class, ProjectPolicy::class);
6366

6467
if ($this->app->runningInConsole()) {
6568
$this->commands([\App\Modules\Finance\Console\Commands\GenerateRecurringInvoices::class]);

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use App\Modules\Finance\Http\Controllers\SalesOrderController;
1818
use App\Modules\Finance\Http\Controllers\FixedAssetController;
1919
use App\Modules\Finance\Http\Controllers\PriceListController;
20+
use App\Modules\Finance\Http\Controllers\ProjectController;
2021
use Illuminate\Support\Facades\Route;
2122

2223
Route::middleware(['web', 'auth', 'verified'])->prefix('finance')->name('finance.')->group(function () {
@@ -146,4 +147,9 @@
146147
// Price Lists
147148
Route::get('price-lists/price-for-contact', [PriceListController::class, 'priceForContact'])->name('price-lists.price-for-contact');
148149
Route::resource('price-lists', PriceListController::class)->except(['edit']);
150+
151+
// Projects
152+
Route::resource('projects', ProjectController::class)->except(['edit']);
153+
Route::post('projects/{project}/time-entries', [ProjectController::class, 'storeTimeEntry'])->name('projects.time-entries.store');
154+
Route::post('projects/{project}/mark-billed', [ProjectController::class, 'markBilled'])->name('projects.mark-billed');
149155
});

0 commit comments

Comments
 (0)