Skip to content

Commit 53aa649

Browse files
committed
feat(hr): Phase 49 — Time & Attendance tracking
Adds AttendanceRecord and WorkSchedule models with migrations, RBAC-protected controllers, shared AttendancePolicy, Inertia React pages, and 10 Pest tests covering worked_hours computation, duplicate date validation, and permission guards. https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent a2c9e75 commit 53aa649

18 files changed

Lines changed: 1431 additions & 12 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\AttendanceRecord;
7+
use App\Modules\HR\Models\Employee;
8+
use Illuminate\Http\RedirectResponse;
9+
use Illuminate\Http\Request;
10+
use Illuminate\Validation\Rule;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class AttendanceController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$this->authorize('viewAny', AttendanceRecord::class);
19+
20+
$query = AttendanceRecord::with('employee');
21+
22+
if ($request->filled('employee_id')) {
23+
$query->where('employee_id', $request->employee_id);
24+
}
25+
26+
if ($request->filled('month')) {
27+
[$year, $month] = explode('-', $request->month);
28+
$query->whereYear('work_date', $year)->whereMonth('work_date', $month);
29+
}
30+
31+
$records = $query->orderByDesc('work_date')->paginate(20)->withQueryString();
32+
$employees = Employee::where('status', 'active')->orderBy('first_name')->get(['id', 'first_name', 'last_name']);
33+
34+
return Inertia::render('HR/Attendance/Index', compact('records', 'employees'));
35+
}
36+
37+
public function create(): Response
38+
{
39+
$this->authorize('create', AttendanceRecord::class);
40+
41+
$employees = Employee::where('status', 'active')->orderBy('first_name')->get(['id', 'first_name', 'last_name']);
42+
43+
return Inertia::render('HR/Attendance/Create', compact('employees'));
44+
}
45+
46+
public function store(Request $request): RedirectResponse
47+
{
48+
$this->authorize('create', AttendanceRecord::class);
49+
50+
$data = $request->validate([
51+
'employee_id' => ['required', Rule::exists('employees', 'id')],
52+
'work_date' => [
53+
'required',
54+
'date',
55+
function ($attribute, $value, $fail) use ($request) {
56+
$exists = \Illuminate\Support\Facades\DB::table('attendance_records')
57+
->whereNull('deleted_at')
58+
->where('employee_id', $request->input('employee_id'))
59+
->whereDate('work_date', $value)
60+
->exists();
61+
if ($exists) {
62+
$fail('The employee already has an attendance record for this date.');
63+
}
64+
},
65+
],
66+
'clock_in' => ['nullable', 'date_format:H:i'],
67+
'clock_out' => ['nullable', 'date_format:H:i', 'after:clock_in'],
68+
'break_minutes' => ['nullable', 'integer', 'min:0'],
69+
'status' => ['required', Rule::in(['present', 'absent', 'half_day', 'holiday', 'leave'])],
70+
'notes' => ['nullable', 'string'],
71+
]);
72+
73+
AttendanceRecord::create([
74+
'tenant_id' => auth()->user()->tenant_id,
75+
...$data,
76+
]);
77+
78+
return redirect()->route('hr.attendance.index')->with('success', 'Attendance record logged.');
79+
}
80+
81+
public function show(AttendanceRecord $attendance): Response
82+
{
83+
$this->authorize('view', $attendance);
84+
85+
$attendance->load('employee');
86+
87+
return Inertia::render('HR/Attendance/Show', compact('attendance'));
88+
}
89+
90+
public function update(Request $request, AttendanceRecord $attendance): RedirectResponse
91+
{
92+
$this->authorize('update', $attendance);
93+
94+
$data = $request->validate([
95+
'clock_in' => ['nullable', 'date_format:H:i'],
96+
'clock_out' => ['nullable', 'date_format:H:i', 'after:clock_in'],
97+
'break_minutes' => ['nullable', 'integer', 'min:0'],
98+
'status' => ['nullable', Rule::in(['present', 'absent', 'half_day', 'holiday', 'leave'])],
99+
'notes' => ['nullable', 'string'],
100+
]);
101+
102+
$attendance->update($data);
103+
104+
return back()->with('success', 'Attendance record updated.');
105+
}
106+
107+
public function destroy(AttendanceRecord $attendance): RedirectResponse
108+
{
109+
$this->authorize('delete', $attendance);
110+
111+
$attendance->delete();
112+
113+
return redirect()->route('hr.attendance.index')->with('success', 'Attendance record deleted.');
114+
}
115+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\WorkSchedule;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class WorkScheduleController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$this->authorize('viewAny', WorkSchedule::class);
17+
18+
$schedules = WorkSchedule::orderBy('name')->get();
19+
20+
return Inertia::render('HR/WorkSchedules/Index', compact('schedules'));
21+
}
22+
23+
public function create(): Response
24+
{
25+
$this->authorize('create', WorkSchedule::class);
26+
27+
return Inertia::render('HR/WorkSchedules/Create');
28+
}
29+
30+
public function store(Request $request): RedirectResponse
31+
{
32+
$this->authorize('create', WorkSchedule::class);
33+
34+
$data = $request->validate([
35+
'name' => ['required', 'string', 'max:255'],
36+
'is_default' => ['boolean'],
37+
'monday_start' => ['nullable', 'date_format:H:i'],
38+
'monday_end' => ['nullable', 'date_format:H:i', 'after:monday_start'],
39+
'tuesday_start' => ['nullable', 'date_format:H:i'],
40+
'tuesday_end' => ['nullable', 'date_format:H:i', 'after:tuesday_start'],
41+
'wednesday_start' => ['nullable', 'date_format:H:i'],
42+
'wednesday_end' => ['nullable', 'date_format:H:i', 'after:wednesday_start'],
43+
'thursday_start' => ['nullable', 'date_format:H:i'],
44+
'thursday_end' => ['nullable', 'date_format:H:i', 'after:thursday_start'],
45+
'friday_start' => ['nullable', 'date_format:H:i'],
46+
'friday_end' => ['nullable', 'date_format:H:i', 'after:friday_start'],
47+
'saturday_start' => ['nullable', 'date_format:H:i'],
48+
'saturday_end' => ['nullable', 'date_format:H:i', 'after:saturday_start'],
49+
'sunday_start' => ['nullable', 'date_format:H:i'],
50+
'sunday_end' => ['nullable', 'date_format:H:i', 'after:sunday_start'],
51+
]);
52+
53+
$schedule = WorkSchedule::create([
54+
'tenant_id' => auth()->user()->tenant_id,
55+
...$data,
56+
]);
57+
58+
return redirect()->route('hr.work-schedules.show', $schedule)->with('success', 'Work schedule created.');
59+
}
60+
61+
public function show(WorkSchedule $workSchedule): Response
62+
{
63+
$this->authorize('view', $workSchedule);
64+
65+
return Inertia::render('HR/WorkSchedules/Show', compact('workSchedule'));
66+
}
67+
68+
public function destroy(WorkSchedule $workSchedule): RedirectResponse
69+
{
70+
$this->authorize('delete', $workSchedule);
71+
72+
$workSchedule->delete();
73+
74+
return redirect()->route('hr.work-schedules.index')->with('success', 'Work schedule deleted.');
75+
}
76+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Models;
4+
5+
use App\Modules\Core\Traits\BelongsToTenant;
6+
use Carbon\Carbon;
7+
use Illuminate\Database\Eloquent\Model;
8+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
9+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
11+
class AttendanceRecord extends Model
12+
{
13+
use BelongsToTenant;
14+
use SoftDeletes;
15+
16+
protected $fillable = [
17+
'tenant_id',
18+
'employee_id',
19+
'work_date',
20+
'clock_in',
21+
'clock_out',
22+
'break_minutes',
23+
'status',
24+
'notes',
25+
];
26+
27+
protected $casts = [
28+
'work_date' => 'date',
29+
];
30+
31+
public function employee(): BelongsTo
32+
{
33+
return $this->belongsTo(Employee::class);
34+
}
35+
36+
public function getWorkedHoursAttribute(): ?float
37+
{
38+
if (empty($this->clock_in) || empty($this->clock_out)) {
39+
return null;
40+
}
41+
42+
$clockIn = Carbon::createFromFormat('H:i:s', $this->clock_in);
43+
$clockOut = Carbon::createFromFormat('H:i:s', $this->clock_out);
44+
45+
$minutes = $clockIn->diffInMinutes($clockOut) - ($this->break_minutes ?? 0);
46+
47+
return round($minutes / 60, 2);
48+
}
49+
50+
public function getIsLateAttribute(): bool
51+
{
52+
return false;
53+
}
54+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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\SoftDeletes;
8+
9+
class WorkSchedule extends Model
10+
{
11+
use BelongsToTenant;
12+
use SoftDeletes;
13+
14+
protected $fillable = [
15+
'tenant_id',
16+
'name',
17+
'monday_start',
18+
'monday_end',
19+
'tuesday_start',
20+
'tuesday_end',
21+
'wednesday_start',
22+
'wednesday_end',
23+
'thursday_start',
24+
'thursday_end',
25+
'friday_start',
26+
'friday_end',
27+
'saturday_start',
28+
'saturday_end',
29+
'sunday_start',
30+
'sunday_end',
31+
'is_default',
32+
];
33+
34+
protected $casts = [
35+
'is_default' => 'boolean',
36+
];
37+
38+
public function scopeDefault($query)
39+
{
40+
return $query->where('is_default', true);
41+
}
42+
43+
public function getDayHours(string $day): ?array
44+
{
45+
$start = $this->{"{$day}_start"};
46+
$end = $this->{"{$day}_end"};
47+
48+
if (empty($start)) {
49+
return null;
50+
}
51+
52+
return ['start' => $start, 'end' => $end];
53+
}
54+
}
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\HR\Policies;
4+
5+
use App\Models\User;
6+
use Illuminate\Database\Eloquent\Model;
7+
8+
class AttendancePolicy
9+
{
10+
public function viewAny(User $user): bool
11+
{
12+
return $user->can('hr.view');
13+
}
14+
15+
public function view(User $user, Model $model): bool
16+
{
17+
return $user->can('hr.view');
18+
}
19+
20+
public function create(User $user): bool
21+
{
22+
return $user->can('hr.create');
23+
}
24+
25+
public function update(User $user, Model $model): bool
26+
{
27+
return $user->can('hr.create');
28+
}
29+
30+
public function delete(User $user, Model $model): bool
31+
{
32+
return $user->can('hr.delete');
33+
}
34+
}

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Modules\HR\Providers;
44

5+
use App\Modules\HR\Models\AttendanceRecord;
56
use App\Modules\HR\Models\Department;
67
use App\Modules\HR\Models\Employee;
78
use App\Modules\HR\Models\EmployeeOnboarding;
@@ -14,6 +15,8 @@
1415
use App\Modules\HR\Models\PayrollRun;
1516
use App\Modules\HR\Models\PerformanceReview;
1617
use App\Modules\HR\Models\TrainingCourse;
18+
use App\Modules\HR\Models\WorkSchedule;
19+
use App\Modules\HR\Policies\AttendancePolicy;
1720
use App\Modules\HR\Policies\DepartmentPolicy;
1821
use App\Modules\HR\Policies\EmployeeOnboardingPolicy;
1922
use App\Modules\HR\Policies\EmployeePolicy;
@@ -35,17 +38,19 @@ public function boot(): void
3538
{
3639
$this->loadRoutesFrom(__DIR__ . '/../routes/hr.php');
3740

38-
Gate::policy(Department::class, DepartmentPolicy::class);
39-
Gate::policy(Employee::class, EmployeePolicy::class);
40-
Gate::policy(EmployeeOnboarding::class, EmployeeOnboardingPolicy::class);
41-
Gate::policy(ExpenseClaim::class, ExpenseClaimPolicy::class);
42-
Gate::policy(JobApplication::class, RecruitmentPolicy::class);
43-
Gate::policy(JobPosition::class, RecruitmentPolicy::class);
44-
Gate::policy(LeaveRequest::class, LeaveRequestPolicy::class);
45-
Gate::policy(OnboardingTemplate::class, OnboardingTemplatePolicy::class);
46-
Gate::policy(PayrollRun::class, PayrollRunPolicy::class);
47-
Gate::policy(PerformanceReview::class, PerformanceReviewPolicy::class);
48-
Gate::policy(TrainingCourse::class, TrainingPolicy::class);
49-
Gate::policy(EmployeeTrainingRecord::class, TrainingPolicy::class);
41+
Gate::policy(AttendanceRecord::class, AttendancePolicy::class);
42+
Gate::policy(WorkSchedule::class, AttendancePolicy::class);
43+
Gate::policy(Department::class, DepartmentPolicy::class);
44+
Gate::policy(Employee::class, EmployeePolicy::class);
45+
Gate::policy(EmployeeOnboarding::class, EmployeeOnboardingPolicy::class);
46+
Gate::policy(ExpenseClaim::class, ExpenseClaimPolicy::class);
47+
Gate::policy(JobApplication::class, RecruitmentPolicy::class);
48+
Gate::policy(JobPosition::class, RecruitmentPolicy::class);
49+
Gate::policy(LeaveRequest::class, LeaveRequestPolicy::class);
50+
Gate::policy(OnboardingTemplate::class, OnboardingTemplatePolicy::class);
51+
Gate::policy(PayrollRun::class, PayrollRunPolicy::class);
52+
Gate::policy(PerformanceReview::class, PerformanceReviewPolicy::class);
53+
Gate::policy(TrainingCourse::class, TrainingPolicy::class);
54+
Gate::policy(EmployeeTrainingRecord::class, TrainingPolicy::class);
5055
}
5156
}

0 commit comments

Comments
 (0)