Skip to content

Commit d0dc070

Browse files
committed
feat(finance): Phase 72 — Project Management with tasks and time tracking
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent d8dbf6d commit d0dc070

15 files changed

Lines changed: 860 additions & 601 deletions

erp/app/Modules/Finance/Http/Controllers/ProjectController.php

Lines changed: 72 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use App\Http\Controllers\Controller;
66
use App\Modules\Finance\Models\Contact;
77
use App\Modules\Finance\Models\Project;
8+
use App\Modules\Finance\Models\ProjectTask;
89
use App\Modules\Finance\Models\ProjectTimeEntry;
910
use Illuminate\Http\RedirectResponse;
1011
use Illuminate\Http\Request;
@@ -13,34 +14,21 @@
1314

1415
class ProjectController extends Controller
1516
{
16-
public function index(): Response
17+
public function index(Request $request): Response
1718
{
1819
$this->authorize('viewAny', Project::class);
1920

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-
]);
21+
$query = Project::with('contact')->orderByDesc('created_at');
22+
23+
if ($request->filled('status')) {
24+
$query->where('status', $request->status);
25+
}
26+
27+
$projects = $query->paginate(15)->withQueryString();
3728

3829
return Inertia::render('Finance/Projects/Index', [
3930
'projects' => $projects,
40-
'breadcrumbs' => [
41-
['label' => 'Finance'],
42-
['label' => 'Projects'],
43-
],
31+
'filters' => $request->only('status'),
4432
]);
4533
}
4634

@@ -52,11 +40,6 @@ public function create(): Response
5240

5341
return Inertia::render('Finance/Projects/Create', [
5442
'contacts' => $contacts,
55-
'breadcrumbs' => [
56-
['label' => 'Finance'],
57-
['label' => 'Projects', 'href' => '/finance/projects'],
58-
['label' => 'New Project'],
59-
],
6043
]);
6144
}
6245

@@ -65,133 +48,110 @@ public function store(Request $request): RedirectResponse
6548
$this->authorize('create', Project::class);
6649

6750
$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'],
51+
'name' => ['required', 'string', 'max:255'],
52+
'contact_id' => ['nullable', 'exists:contacts,id'],
53+
'status' => ['nullable', 'in:planning,active,on_hold,completed,cancelled'],
54+
'start_date' => ['nullable', 'date'],
55+
'end_date' => ['nullable', 'date'],
56+
'budget' => ['nullable', 'numeric', 'min:0'],
57+
'billing_type' => ['required', 'in:fixed,hourly,non_billable'],
58+
'hourly_rate' => ['nullable', 'numeric', 'min:0'],
59+
'description' => ['nullable', 'string'],
7660
]);
7761

62+
$validated['status'] = $validated['status'] ?? 'planning';
63+
7864
$project = Project::create(array_merge($validated, [
7965
'tenant_id' => $request->user()->tenant_id,
8066
]));
8167

82-
return redirect()->route('finance.projects.show', $project)
83-
->with('success', 'Project created successfully.');
68+
return redirect()->route('finance.projects.show', $project);
8469
}
8570

8671
public function show(Project $project): Response
8772
{
8873
$this->authorize('view', $project);
8974

90-
$project->load(['timeEntries.user', 'contact', 'invoice', 'attachments']);
75+
$project->load(['tasks.assignedTo', 'timeEntries.user', 'timeEntries.task', 'contact']);
76+
77+
$projectData = $project->toArray();
78+
$projectData['total_hours'] = $project->total_hours;
79+
$projectData['total_billed'] = $project->total_billed;
80+
$projectData['completion_percent'] = $project->completion_percent;
9181

9282
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-
'attachments' => $project->attachments->map(fn ($a) => [
108-
'id' => $a->id, 'filename' => $a->filename, 'disk' => $a->disk,
109-
'path' => $a->path, 'mime_type' => $a->mime_type, 'size' => $a->size,
110-
'uploaded_by' => $a->uploaded_by, 'created_at' => $a->created_at?->toIso8601String(),
111-
]),
112-
'time_entries' => $project->timeEntries->map(fn ($e) => [
113-
'id' => $e->id,
114-
'project_id' => $e->project_id,
115-
'user_id' => $e->user_id,
116-
'description' => $e->description,
117-
'hours' => $e->hours,
118-
'billable' => $e->billable,
119-
'billed' => $e->billed,
120-
'entry_date' => $e->entry_date->toDateString(),
121-
'user' => $e->user ? ['id' => $e->user->id, 'name' => $e->user->name] : null,
122-
]),
123-
],
124-
'contacts' => Contact::orderBy('name')->get(['id', 'name']),
125-
'breadcrumbs' => [
126-
['label' => 'Finance'],
127-
['label' => 'Projects', 'href' => '/finance/projects'],
128-
['label' => $project->name],
129-
],
83+
'project' => $projectData,
13084
]);
13185
}
13286

133-
public function update(Request $request, Project $project): RedirectResponse
87+
public function destroy(Project $project): RedirectResponse
13488
{
135-
$this->authorize('update', $project);
136-
137-
$validated = $request->validate([
138-
'name' => ['required', 'string', 'max:255'],
139-
'description' => ['nullable', 'string'],
140-
'status' => ['required', 'in:draft,active,completed,cancelled'],
141-
'budget' => ['nullable', 'numeric', 'min:0'],
142-
'contact_id' => ['nullable', 'exists:contacts,id'],
143-
'invoice_id' => ['nullable', 'exists:invoices,id'],
144-
'starts_on' => ['nullable', 'date'],
145-
'ends_on' => ['nullable', 'date'],
146-
]);
89+
$this->authorize('delete', $project);
14790

148-
$project->update($validated);
91+
$project->delete();
14992

150-
return redirect()->route('finance.projects.show', $project)
151-
->with('success', 'Project updated successfully.');
93+
return redirect()->route('finance.projects.index');
15294
}
15395

154-
public function destroy(Project $project): RedirectResponse
96+
public function activate(Project $project): RedirectResponse
15597
{
156-
$this->authorize('delete', $project);
98+
$this->authorize('update', $project);
15799

158-
$project->delete();
100+
$project->activate();
159101

160-
return redirect()->route('finance.projects.index')
161-
->with('success', 'Project deleted.');
102+
return redirect()->back();
162103
}
163104

164-
public function storeTimeEntry(Request $request, Project $project): RedirectResponse
105+
public function complete(Project $project): RedirectResponse
165106
{
166107
$this->authorize('update', $project);
167108

109+
$project->complete();
110+
111+
return redirect()->back();
112+
}
113+
114+
public function addTask(Request $request, Project $project): RedirectResponse
115+
{
116+
$this->authorize('create', Project::class);
117+
168118
$validated = $request->validate([
169-
'description' => ['required', 'string'],
170-
'hours' => ['required', 'numeric', 'min:0.1'],
171-
'billable' => ['boolean'],
172-
'entry_date' => ['required', 'date'],
119+
'title' => ['required', 'string', 'max:255'],
120+
'priority' => ['nullable', 'in:low,medium,high'],
121+
'due_date' => ['nullable', 'date'],
122+
'estimated_hours' => ['nullable', 'numeric'],
123+
'description' => ['nullable', 'string'],
124+
'assigned_to' => ['nullable', 'exists:users,id'],
173125
]);
174126

175-
$project->timeEntries()->create(array_merge($validated, [
176-
'user_id' => auth()->id(),
127+
$validated['priority'] = $validated['priority'] ?? 'medium';
128+
129+
ProjectTask::create(array_merge($validated, [
130+
'project_id' => $project->id,
131+
'tenant_id' => $project->tenant_id,
177132
]));
178133

179-
return back()->with('success', 'Time entry logged.');
134+
return redirect()->back();
180135
}
181136

182-
public function markBilled(Request $request, Project $project): RedirectResponse
137+
public function addTimeEntry(Request $request, Project $project): RedirectResponse
183138
{
184-
$this->authorize('update', $project);
139+
$this->authorize('create', Project::class);
185140

186141
$validated = $request->validate([
187-
'entry_ids' => ['array'],
188-
'entry_ids.*' => ['exists:project_time_entries,id'],
142+
'hours' => ['required', 'numeric', 'min:0.01'],
143+
'entry_date' => ['required', 'date'],
144+
'description' => ['nullable', 'string'],
145+
'task_id' => ['nullable', 'exists:project_tasks,id'],
146+
'is_billable' => ['boolean'],
189147
]);
190148

191-
ProjectTimeEntry::whereIn('id', $validated['entry_ids'] ?? [])
192-
->where('project_id', $project->id)
193-
->update(['billed' => true]);
149+
ProjectTimeEntry::create(array_merge($validated, [
150+
'project_id' => $project->id,
151+
'tenant_id' => $project->tenant_id,
152+
'user_id' => auth()->id(),
153+
]));
194154

195-
return back()->with('success', 'Entries marked as billed.');
155+
return redirect()->back();
196156
}
197157
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<?php
2+
3+
namespace App\Modules\Finance\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\Finance\Models\Project;
7+
use App\Modules\Finance\Models\ProjectTask;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
11+
class ProjectTaskController extends Controller
12+
{
13+
public function update(Request $request, Project $project, ProjectTask $task): RedirectResponse
14+
{
15+
$this->authorize('create', Project::class);
16+
17+
$validated = $request->validate([
18+
'status' => ['nullable', 'in:todo,in_progress,done,cancelled'],
19+
'actual_hours' => ['nullable', 'numeric'],
20+
]);
21+
22+
$task->update($validated);
23+
24+
return redirect()->back();
25+
}
26+
27+
public function destroy(Project $project, ProjectTask $task): RedirectResponse
28+
{
29+
$this->authorize('delete', $project);
30+
31+
$task->delete();
32+
33+
return redirect()->back();
34+
}
35+
}

0 commit comments

Comments
 (0)