Skip to content

Commit fc279bd

Browse files
committed
feat(phase-42): project time tracking API with billable hours summary
- TimeTrackingController: list/create/update/delete time entries - Project summary: total/billable/non-billable hours breakdown by user - Filters: user_id, date from/to, is_billable - Routes: /time-entries CRUD, GET /projects/{project}/time - 8 feature tests covering logging, filtering, and project summaries Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent a18776b commit fc279bd

4 files changed

Lines changed: 277 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ beforeEach(function () {
173173
| 39 | Inventory Reorder Suggestions — deficit calc + urgency levels ||
174174
| 40 | HR Leave Balance API — allocation, team view, year filters ||
175175
| 41 | CRM Pipeline Analytics — funnel, win rate, velocity, leaderboard ||
176+
| 42 | Project Time Tracking API — log hours, project summaries by user ||
176177

177178
## File Locations Reference
178179

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
namespace App\Http\Controllers\Api\V1;
4+
5+
use App\Modules\PM\Models\Project;
6+
use App\Modules\PM\Models\Task;
7+
use App\Modules\PM\Models\TimeEntry;
8+
use Illuminate\Http\JsonResponse;
9+
use Illuminate\Http\Request;
10+
11+
class TimeTrackingController extends ApiController
12+
{
13+
public function index(Request $request): JsonResponse
14+
{
15+
$tenantId = $this->tenantId($request);
16+
17+
$entries = TimeEntry::where('tenant_id', $tenantId)
18+
->when($request->user_id, fn ($q) => $q->where('user_id', $request->user_id))
19+
->when($request->from, fn ($q) => $q->whereDate('date', '>=', $request->from))
20+
->when($request->to, fn ($q) => $q->whereDate('date', '<=', $request->to))
21+
->when($request->is_billable !== null, fn ($q) => $q->where('is_billable', $request->boolean('is_billable')))
22+
->with(['user:id,name', 'task:id,name,project_id'])
23+
->orderByDesc('date')
24+
->paginate(25);
25+
26+
return $this->paginated($entries);
27+
}
28+
29+
public function store(Request $request): JsonResponse
30+
{
31+
$tenantId = $this->tenantId($request);
32+
33+
$data = $request->validate([
34+
'task_id' => ['required', 'exists:tasks,id'],
35+
'hours' => ['required', 'numeric', 'min:0.1', 'max:24'],
36+
'date' => ['required', 'date'],
37+
'description' => ['nullable', 'string', 'max:500'],
38+
'is_billable' => ['sometimes', 'boolean'],
39+
]);
40+
41+
$entry = TimeEntry::create([
42+
...$data,
43+
'tenant_id' => $tenantId,
44+
'user_id' => $request->user()->id,
45+
'created_by' => $request->user()->id,
46+
]);
47+
48+
return $this->success($entry->load('task:id,name,project_id'), 201);
49+
}
50+
51+
public function update(Request $request, TimeEntry $timeEntry): JsonResponse
52+
{
53+
$data = $request->validate([
54+
'hours' => ['sometimes', 'numeric', 'min:0.1', 'max:24'],
55+
'date' => ['sometimes', 'date'],
56+
'description' => ['nullable', 'string', 'max:500'],
57+
'is_billable' => ['sometimes', 'boolean'],
58+
]);
59+
60+
$timeEntry->update($data);
61+
return $this->success($timeEntry->fresh());
62+
}
63+
64+
public function destroy(TimeEntry $timeEntry): JsonResponse
65+
{
66+
$timeEntry->delete();
67+
return $this->success(['message' => 'Time entry deleted.']);
68+
}
69+
70+
public function projectSummary(Request $request, Project $project): JsonResponse
71+
{
72+
$from = $request->get('from', now()->startOfMonth()->toDateString());
73+
$to = $request->get('to', now()->toDateString());
74+
75+
$entries = $project->timeEntries()
76+
->whereDate('date', '>=', $from)
77+
->whereDate('date', '<=', $to)
78+
->with('user:id,name')
79+
->get();
80+
81+
$totalHours = $entries->sum('hours');
82+
$billableHours = $entries->where('is_billable', true)->sum('hours');
83+
84+
$byUser = $entries->groupBy('user_id')->map(fn ($group) => [
85+
'user_id' => $group->first()->user_id,
86+
'name' => $group->first()->user?->name ?? 'Unknown',
87+
'total_hours' => round($group->sum('hours'), 2),
88+
'billable_hours' => round($group->where('is_billable', true)->sum('hours'), 2),
89+
])->values();
90+
91+
return $this->success([
92+
'project_id' => $project->id,
93+
'project_name' => $project->name,
94+
'period' => ['from' => $from, 'to' => $to],
95+
'total_hours' => round($totalHours, 2),
96+
'billable_hours' => round($billableHours, 2),
97+
'non_billable' => round($totalHours - $billableHours, 2),
98+
'entries_count' => $entries->count(),
99+
'by_user' => $byUser,
100+
]);
101+
}
102+
103+
private function tenantId(Request $request): int
104+
{
105+
return app()->has('tenant') ? app('tenant')->id : $request->user()->tenant_id;
106+
}
107+
}

erp/routes/api.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -448,6 +448,15 @@
448448
});
449449
Route::get('products/{product}/matrix', [\App\Http\Controllers\Api\V1\ProductVariantController::class, 'matrix']);
450450

451+
// Time Tracking
452+
Route::prefix('time-entries')->group(function () {
453+
Route::get('/', [\App\Http\Controllers\Api\V1\TimeTrackingController::class, 'index']);
454+
Route::post('/', [\App\Http\Controllers\Api\V1\TimeTrackingController::class, 'store']);
455+
Route::put('/{timeEntry}', [\App\Http\Controllers\Api\V1\TimeTrackingController::class, 'update']);
456+
Route::delete('/{timeEntry}', [\App\Http\Controllers\Api\V1\TimeTrackingController::class, 'destroy']);
457+
});
458+
Route::get('projects/{project}/time', [\App\Http\Controllers\Api\V1\TimeTrackingController::class, 'projectSummary']);
459+
451460
// CRM Pipeline Analytics
452461
Route::prefix('crm/pipeline')->group(function () {
453462
Route::get('/funnel', [\App\Http\Controllers\Api\V1\CrmPipelineController::class, 'funnel']);
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
<?php
2+
3+
use App\Models\User;
4+
use App\Modules\Core\Models\Tenant;
5+
use App\Modules\PM\Models\Project;
6+
use App\Modules\PM\Models\Task;
7+
use App\Modules\PM\Models\TimeEntry;
8+
use Database\Seeders\RolePermissionSeeder;
9+
10+
beforeEach(function () {
11+
$this->seed(RolePermissionSeeder::class);
12+
$this->tenant = Tenant::create(['name' => 'Time Track Co', 'slug' => 'time-track-' . uniqid()]);
13+
$this->user = User::factory()->create(['tenant_id' => $this->tenant->id]);
14+
$this->user->assignRole('super-admin');
15+
$this->token = $this->user->createToken('test')->plainTextToken;
16+
app()->instance('tenant', $this->tenant);
17+
18+
$this->project = Project::create([
19+
'tenant_id' => $this->tenant->id,
20+
'name' => 'Test Project',
21+
'status' => 'active',
22+
'created_by' => $this->user->id,
23+
]);
24+
25+
$this->task = Task::create([
26+
'tenant_id' => $this->tenant->id,
27+
'project_id' => $this->project->id,
28+
'title' => 'Test Task',
29+
'status' => 'in_progress',
30+
'created_by' => $this->user->id,
31+
]);
32+
});
33+
34+
test('can list time entries', function () {
35+
TimeEntry::create([
36+
'tenant_id' => $this->tenant->id,
37+
'task_id' => $this->task->id,
38+
'user_id' => $this->user->id,
39+
'hours' => 2.5,
40+
'date' => now()->toDateString(),
41+
'is_billable' => true,
42+
'created_by' => $this->user->id,
43+
]);
44+
45+
$this->withToken($this->token)
46+
->getJson('/api/v1/time-entries')
47+
->assertStatus(200);
48+
});
49+
50+
test('can create a time entry', function () {
51+
$this->withToken($this->token)
52+
->postJson('/api/v1/time-entries', [
53+
'task_id' => $this->task->id,
54+
'hours' => 3.0,
55+
'date' => now()->toDateString(),
56+
'description' => 'Working on feature X',
57+
])
58+
->assertStatus(201);
59+
60+
expect(TimeEntry::where('task_id', $this->task->id)->exists())->toBeTrue();
61+
});
62+
63+
test('store validates hours range', function () {
64+
$this->withToken($this->token)
65+
->postJson('/api/v1/time-entries', [
66+
'task_id' => $this->task->id,
67+
'hours' => 25,
68+
'date' => now()->toDateString(),
69+
])
70+
->assertStatus(422)
71+
->assertJsonValidationErrors(['hours']);
72+
});
73+
74+
test('can update a time entry', function () {
75+
$entry = TimeEntry::create([
76+
'tenant_id' => $this->tenant->id,
77+
'task_id' => $this->task->id,
78+
'user_id' => $this->user->id,
79+
'hours' => 1.0,
80+
'date' => now()->toDateString(),
81+
'created_by' => $this->user->id,
82+
]);
83+
84+
$this->withToken($this->token)
85+
->putJson("/api/v1/time-entries/{$entry->id}", ['hours' => 2.5])
86+
->assertStatus(200)
87+
->assertJsonPath('data.hours', 2.5);
88+
});
89+
90+
test('can delete a time entry', function () {
91+
$entry = TimeEntry::create([
92+
'tenant_id' => $this->tenant->id,
93+
'task_id' => $this->task->id,
94+
'user_id' => $this->user->id,
95+
'hours' => 1.0,
96+
'date' => now()->toDateString(),
97+
'created_by' => $this->user->id,
98+
]);
99+
100+
$this->withToken($this->token)
101+
->deleteJson("/api/v1/time-entries/{$entry->id}")
102+
->assertStatus(200);
103+
104+
expect(TimeEntry::find($entry->id))->toBeNull();
105+
});
106+
107+
test('project time summary returns hours breakdown', function () {
108+
TimeEntry::create([
109+
'tenant_id' => $this->tenant->id,
110+
'task_id' => $this->task->id,
111+
'user_id' => $this->user->id,
112+
'hours' => 4.0,
113+
'date' => now()->toDateString(),
114+
'is_billable' => true,
115+
'created_by' => $this->user->id,
116+
]);
117+
118+
TimeEntry::create([
119+
'tenant_id' => $this->tenant->id,
120+
'task_id' => $this->task->id,
121+
'user_id' => $this->user->id,
122+
'hours' => 1.0,
123+
'date' => now()->toDateString(),
124+
'is_billable' => false,
125+
'created_by' => $this->user->id,
126+
]);
127+
128+
$response = $this->withToken($this->token)
129+
->getJson("/api/v1/projects/{$this->project->id}/time")
130+
->assertStatus(200)
131+
->assertJsonStructure(['data' => ['project_id', 'total_hours', 'billable_hours', 'by_user']]);
132+
133+
expect($response->json('data.total_hours'))->toBe(5);
134+
expect($response->json('data.billable_hours'))->toBe(4);
135+
});
136+
137+
test('time list supports user filter', function () {
138+
$other = User::factory()->create(['tenant_id' => $this->tenant->id]);
139+
140+
TimeEntry::create([
141+
'tenant_id' => $this->tenant->id,
142+
'task_id' => $this->task->id,
143+
'user_id' => $other->id,
144+
'hours' => 2.0,
145+
'date' => now()->toDateString(),
146+
'created_by' => $this->user->id,
147+
]);
148+
149+
$response = $this->withToken($this->token)
150+
->getJson("/api/v1/time-entries?user_id={$this->user->id}")
151+
->assertStatus(200);
152+
153+
$entries = $response->json('data');
154+
$userIds = collect($entries)->pluck('user_id')->unique()->values();
155+
expect($userIds)->not->toContain($other->id);
156+
});
157+
158+
test('requires authentication', function () {
159+
$this->getJson('/api/v1/time-entries')->assertStatus(401);
160+
});

0 commit comments

Comments
 (0)