Skip to content

Commit 921746a

Browse files
committed
feat(hr): Phase 91 — Timesheet Management with weekly entries and approval workflow
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent ac0f504 commit 921746a

14 files changed

Lines changed: 939 additions & 0 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\Employee;
7+
use App\Modules\HR\Models\Timesheet;
8+
use Carbon\Carbon;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class TimesheetController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$this->authorize('viewAny', Timesheet::class);
19+
20+
$timesheets = Timesheet::with(['employee'])
21+
->when($request->employee_id, fn ($q) => $q->where('employee_id', $request->employee_id))
22+
->when($request->status, fn ($q) => $q->where('status', $request->status))
23+
->orderBy('week_start', 'desc')
24+
->paginate(20)
25+
->withQueryString();
26+
27+
$employees = Employee::where('status', 'active')
28+
->orderBy('last_name')
29+
->get(['id', 'first_name', 'last_name']);
30+
31+
return Inertia::render('HR/Timesheets/Index', [
32+
'timesheets' => $timesheets,
33+
'employees' => $employees,
34+
'filters' => $request->only(['employee_id', 'status']),
35+
]);
36+
}
37+
38+
public function create(): Response
39+
{
40+
$this->authorize('create', Timesheet::class);
41+
42+
$employees = Employee::where('status', 'active')
43+
->orderBy('last_name')
44+
->get(['id', 'first_name', 'last_name']);
45+
46+
return Inertia::render('HR/Timesheets/Create', [
47+
'employees' => $employees,
48+
]);
49+
}
50+
51+
public function store(Request $request): RedirectResponse
52+
{
53+
$this->authorize('create', Timesheet::class);
54+
55+
$validated = $request->validate([
56+
'week_start' => 'required|date',
57+
'employee_id' => 'required|exists:employees,id',
58+
'notes' => 'nullable|string',
59+
]);
60+
61+
$weekEnd = Carbon::parse($validated['week_start'])->endOfWeek(Carbon::SUNDAY)->toDateString();
62+
63+
Timesheet::create([
64+
'tenant_id' => auth()->user()->tenant_id,
65+
'employee_id' => $validated['employee_id'],
66+
'week_start' => $validated['week_start'],
67+
'week_end' => $weekEnd,
68+
'notes' => $validated['notes'] ?? null,
69+
'status' => 'draft',
70+
'total_hours' => 0,
71+
]);
72+
73+
return redirect()->back();
74+
}
75+
76+
public function show(Timesheet $timesheet): Response
77+
{
78+
$this->authorize('view', $timesheet);
79+
80+
$timesheet->load(['employee', 'entries', 'approvedBy']);
81+
82+
return Inertia::render('HR/Timesheets/Show', [
83+
'timesheet' => $timesheet,
84+
]);
85+
}
86+
87+
public function submit(Timesheet $timesheet): RedirectResponse
88+
{
89+
$this->authorize('update', $timesheet);
90+
91+
$timesheet->submit();
92+
93+
return redirect()->back();
94+
}
95+
96+
public function approve(Timesheet $timesheet): RedirectResponse
97+
{
98+
$this->authorize('update', $timesheet);
99+
100+
$timesheet->approve(auth()->id());
101+
102+
return redirect()->back();
103+
}
104+
105+
public function reject(Timesheet $timesheet): RedirectResponse
106+
{
107+
$this->authorize('update', $timesheet);
108+
109+
$timesheet->reject();
110+
111+
return redirect()->back();
112+
}
113+
114+
public function addEntry(Request $request, Timesheet $timesheet): RedirectResponse
115+
{
116+
$this->authorize('update', $timesheet);
117+
118+
$validated = $request->validate([
119+
'work_date' => 'required|date',
120+
'hours' => 'required|numeric|min:0.25|max:24',
121+
'project' => 'nullable|string|max:255',
122+
'description' => 'nullable|string',
123+
]);
124+
125+
$timesheet->entries()->create([
126+
'tenant_id' => auth()->user()->tenant_id,
127+
'timesheet_id' => $timesheet->id,
128+
'work_date' => $validated['work_date'],
129+
'hours' => $validated['hours'],
130+
'project' => $validated['project'] ?? null,
131+
'description' => $validated['description'] ?? null,
132+
]);
133+
134+
$timesheet->recalculateHours();
135+
136+
return redirect()->back();
137+
}
138+
139+
public function destroy(Timesheet $timesheet): RedirectResponse
140+
{
141+
$this->authorize('delete', $timesheet);
142+
143+
$timesheet->delete();
144+
145+
return redirect()->route('hr.timesheets.index');
146+
}
147+
148+
}
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\HR\Models;
4+
5+
use App\Models\User;
6+
use App\Modules\Core\Traits\BelongsToTenant;
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 Timesheet extends Model
13+
{
14+
use BelongsToTenant;
15+
use SoftDeletes;
16+
17+
protected $fillable = [
18+
'tenant_id', 'employee_id', 'week_start', 'week_end', 'status',
19+
'total_hours', 'approved_by', 'approved_at', 'notes',
20+
];
21+
22+
protected $casts = [
23+
'week_start' => 'date',
24+
'week_end' => 'date',
25+
'total_hours' => 'float',
26+
'approved_at' => 'datetime',
27+
];
28+
29+
public function employee(): BelongsTo
30+
{
31+
return $this->belongsTo(Employee::class);
32+
}
33+
34+
public function approvedBy(): BelongsTo
35+
{
36+
return $this->belongsTo(User::class, 'approved_by');
37+
}
38+
39+
public function entries(): HasMany
40+
{
41+
return $this->hasMany(TimesheetEntry::class);
42+
}
43+
44+
public function submit(): void
45+
{
46+
$this->status = 'submitted';
47+
$this->save();
48+
}
49+
50+
public function approve(int $userId): void
51+
{
52+
$this->status = 'approved';
53+
$this->approved_by = $userId;
54+
$this->approved_at = now();
55+
$this->save();
56+
}
57+
58+
public function reject(): void
59+
{
60+
$this->status = 'rejected';
61+
$this->save();
62+
}
63+
64+
public function recalculateHours(): void
65+
{
66+
$this->total_hours = $this->entries()->sum('hours');
67+
$this->save();
68+
}
69+
70+
public function getIsEditableAttribute(): bool
71+
{
72+
return $this->status === 'draft';
73+
}
74+
75+
public function getIsApprovedAttribute(): bool
76+
{
77+
return $this->status === 'approved';
78+
}
79+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
8+
9+
class TimesheetEntry extends Model
10+
{
11+
use BelongsToTenant;
12+
13+
protected $fillable = [
14+
'tenant_id', 'timesheet_id', 'work_date', 'hours', 'project', 'description',
15+
];
16+
17+
protected $casts = [
18+
'work_date' => 'date',
19+
'hours' => 'float',
20+
];
21+
22+
public function timesheet(): BelongsTo
23+
{
24+
return $this->belongsTo(Timesheet::class);
25+
}
26+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Policies;
4+
5+
use App\Models\User;
6+
7+
class TimesheetPolicy
8+
{
9+
public function viewAny(User $user): bool
10+
{
11+
return $user->hasPermissionTo('hr.view');
12+
}
13+
14+
public function view(User $user, $model): bool
15+
{
16+
return $user->hasPermissionTo('hr.view');
17+
}
18+
19+
public function create(User $user): bool
20+
{
21+
return $user->hasPermissionTo('hr.create');
22+
}
23+
24+
public function update(User $user, $model): bool
25+
{
26+
return $user->hasPermissionTo('hr.create');
27+
}
28+
29+
public function delete(User $user, $model): bool
30+
{
31+
return $user->hasPermissionTo('hr.delete');
32+
}
33+
}

erp/app/Modules/HR/Providers/HRServiceProvider.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
use App\Modules\HR\Models\WorkSchedule;
3333
use App\Modules\HR\Models\DisciplinaryCase;
3434
use App\Modules\HR\Models\Grievance;
35+
use App\Modules\HR\Models\Timesheet;
36+
use App\Modules\HR\Models\TimesheetEntry;
3537
use App\Modules\HR\Policies\AttendancePolicy;
3638
use App\Modules\HR\Policies\DepartmentPolicy;
3739
use App\Modules\HR\Policies\EmployeeOnboardingPolicy;
@@ -49,6 +51,7 @@
4951
use App\Modules\HR\Policies\ShiftPolicy;
5052
use App\Modules\HR\Policies\TrainingPolicy;
5153
use App\Modules\HR\Policies\DisciplinaryPolicy;
54+
use App\Modules\HR\Policies\TimesheetPolicy;
5255
use Illuminate\Support\Facades\Gate;
5356
use Illuminate\Support\ServiceProvider;
5457

@@ -90,5 +93,7 @@ public function boot(): void
9093
Gate::policy(ShiftAssignment::class, ShiftPolicy::class);
9194
Gate::policy(DisciplinaryCase::class, DisciplinaryPolicy::class);
9295
Gate::policy(Grievance::class, DisciplinaryPolicy::class);
96+
Gate::policy(Timesheet::class, TimesheetPolicy::class);
97+
Gate::policy(TimesheetEntry::class, TimesheetPolicy::class);
9398
}
9499
}

erp/app/Modules/HR/routes/hr.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,13 @@
172172
Route::resource('grievances', GrievanceController::class)->except(['edit', 'update']);
173173
});
174174

175+
176+
// Timesheet Management — custom actions BEFORE resource
177+
use App\Modules\HR\Http\Controllers\TimesheetController;
178+
Route::middleware(['web', 'auth', 'verified'])->prefix('hr')->name('hr.')->group(function () {
179+
Route::post('timesheets/{timesheet}/submit', [TimesheetController::class, 'submit'])->name('timesheets.submit');
180+
Route::post('timesheets/{timesheet}/approve', [TimesheetController::class, 'approve'])->name('timesheets.approve');
181+
Route::post('timesheets/{timesheet}/reject', [TimesheetController::class, 'reject'])->name('timesheets.reject');
182+
Route::post('timesheets/{timesheet}/entries', [TimesheetController::class, 'addEntry'])->name('timesheets.entries.store');
183+
Route::resource('timesheets', TimesheetController::class)->only(['index', 'create', 'store', 'show', 'destroy']);
184+
});
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('timesheets', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->unsignedBigInteger('employee_id');
15+
$table->date('week_start');
16+
$table->date('week_end');
17+
$table->enum('status', ['draft', 'submitted', 'approved', 'rejected'])->default('draft');
18+
$table->decimal('total_hours', 6, 2)->default(0);
19+
$table->unsignedBigInteger('approved_by')->nullable();
20+
$table->timestamp('approved_at')->nullable();
21+
$table->text('notes')->nullable();
22+
$table->timestamps();
23+
$table->softDeletes();
24+
});
25+
}
26+
27+
public function down(): void
28+
{
29+
Schema::dropIfExists('timesheets');
30+
}
31+
};
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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('timesheet_entries', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->unsignedBigInteger('timesheet_id');
15+
$table->date('work_date');
16+
$table->decimal('hours', 5, 2);
17+
$table->string('project')->nullable();
18+
$table->text('description')->nullable();
19+
$table->timestamps();
20+
});
21+
}
22+
23+
public function down(): void
24+
{
25+
Schema::dropIfExists('timesheet_entries');
26+
}
27+
};

erp/resources/js/Components/Layout/Sidebar.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ const navItems: NavItem[] = [
175175
{ label: 'Payroll', href: '/hr/payroll', icon: <span /> },
176176
{ label: 'Disciplinary', href: '/hr/disciplinary-cases', icon: <span /> },
177177
{ label: 'Grievances', href: '/hr/grievances', icon: <span /> },
178+
{ label: 'Timesheets', href: '/hr/timesheets', icon: <span /> },
178179
],
179180
},
180181
{

0 commit comments

Comments
 (0)