Skip to content

Commit 3669530

Browse files
committed
feat(hr): Phase 67 — HR Shift Scheduling with templates and assignments
https://claude.ai/code/session_01RdUGwo74JXChRCF88Yu27d
1 parent 32957df commit 3669530

17 files changed

Lines changed: 1203 additions & 0 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
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\ShiftAssignment;
8+
use App\Modules\HR\Models\ShiftTemplate;
9+
use Illuminate\Http\RedirectResponse;
10+
use Illuminate\Http\Request;
11+
use Inertia\Inertia;
12+
use Inertia\Response;
13+
14+
class ShiftAssignmentController extends Controller
15+
{
16+
public function index(Request $request): Response
17+
{
18+
$this->authorize('viewAny', ShiftAssignment::class);
19+
20+
$query = ShiftAssignment::with(['shiftTemplate', 'employee'])
21+
->orderBy('assigned_date', 'desc');
22+
23+
if ($request->filled('employee_id')) {
24+
$query->where('employee_id', $request->employee_id);
25+
}
26+
27+
if ($request->filled('date_from')) {
28+
$query->where('assigned_date', '>=', $request->date_from);
29+
}
30+
31+
if ($request->filled('date_to')) {
32+
$query->where('assigned_date', '<=', $request->date_to);
33+
}
34+
35+
$assignments = $query->paginate(20)->withQueryString();
36+
37+
$filters = $request->only(['employee_id', 'date_from', 'date_to']);
38+
39+
return Inertia::render('HR/ShiftAssignments/Index', compact('assignments', 'filters'));
40+
}
41+
42+
public function create(): Response
43+
{
44+
$this->authorize('create', ShiftAssignment::class);
45+
46+
$shiftTemplates = ShiftTemplate::where('is_active', true)->orderBy('name')->get();
47+
$employees = Employee::orderBy('first_name')->get(['id', 'first_name', 'last_name']);
48+
49+
return Inertia::render('HR/ShiftAssignments/Create', compact('shiftTemplates', 'employees'));
50+
}
51+
52+
public function store(Request $request): RedirectResponse
53+
{
54+
$this->authorize('create', ShiftAssignment::class);
55+
56+
$data = $request->validate([
57+
'shift_template_id' => ['required', 'exists:shift_templates,id'],
58+
'employee_id' => ['required', 'exists:employees,id'],
59+
'assigned_date' => ['required', 'date'],
60+
'notes' => ['nullable', 'string'],
61+
]);
62+
63+
$data['tenant_id'] = auth()->user()->tenant_id;
64+
$data['status'] = 'scheduled';
65+
66+
ShiftAssignment::create($data);
67+
68+
return redirect()->route('hr.shift-assignments.index')->with('success', 'Shift assignment created.');
69+
}
70+
71+
public function destroy(ShiftAssignment $shiftAssignment): RedirectResponse
72+
{
73+
$this->authorize('delete', $shiftAssignment);
74+
75+
$shiftAssignment->delete();
76+
77+
return redirect()->back()->with('success', 'Shift assignment deleted.');
78+
}
79+
80+
public function markStatus(Request $request, ShiftAssignment $shiftAssignment): RedirectResponse
81+
{
82+
$this->authorize('update', $shiftAssignment);
83+
84+
$data = $request->validate([
85+
'status' => ['required', 'in:scheduled,completed,absent,swapped'],
86+
]);
87+
88+
$shiftAssignment->update($data);
89+
90+
return redirect()->back()->with('success', 'Status updated.');
91+
}
92+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php
2+
3+
namespace App\Modules\HR\Http\Controllers;
4+
5+
use App\Http\Controllers\Controller;
6+
use App\Modules\HR\Models\ShiftTemplate;
7+
use Illuminate\Http\RedirectResponse;
8+
use Illuminate\Http\Request;
9+
use Inertia\Inertia;
10+
use Inertia\Response;
11+
12+
class ShiftTemplateController extends Controller
13+
{
14+
public function index(): Response
15+
{
16+
$this->authorize('viewAny', ShiftTemplate::class);
17+
18+
$shiftTemplates = ShiftTemplate::withCount('assignments')
19+
->orderBy('name')
20+
->paginate(15);
21+
22+
return Inertia::render('HR/ShiftTemplates/Index', compact('shiftTemplates'));
23+
}
24+
25+
public function create(): Response
26+
{
27+
$this->authorize('create', ShiftTemplate::class);
28+
29+
return Inertia::render('HR/ShiftTemplates/Create');
30+
}
31+
32+
public function store(Request $request): RedirectResponse
33+
{
34+
$this->authorize('create', ShiftTemplate::class);
35+
36+
$data = $request->validate([
37+
'name' => ['required', 'string', 'max:255'],
38+
'start_time' => ['required', 'string'],
39+
'end_time' => ['required', 'string'],
40+
'break_minutes'=> ['integer', 'min:0'],
41+
'days_of_week' => ['array'],
42+
'color' => ['nullable', 'string', 'max:20'],
43+
'is_active' => ['boolean'],
44+
]);
45+
46+
$data['break_minutes'] = $data['break_minutes'] ?? 0;
47+
$data['tenant_id'] = auth()->user()->tenant_id;
48+
49+
$template = ShiftTemplate::create($data);
50+
51+
return redirect()->route('hr.shift-templates.show', $template);
52+
}
53+
54+
public function show(ShiftTemplate $shiftTemplate): Response
55+
{
56+
$this->authorize('view', $shiftTemplate);
57+
58+
$shiftTemplate->load([
59+
'assignments' => function ($query) {
60+
$query->with('employee')
61+
->where('assigned_date', '>=', now()->toDateString())
62+
->orderBy('assigned_date')
63+
->limit(20);
64+
},
65+
]);
66+
67+
return Inertia::render('HR/ShiftTemplates/Show', compact('shiftTemplate'));
68+
}
69+
70+
public function destroy(ShiftTemplate $shiftTemplate): RedirectResponse
71+
{
72+
$this->authorize('delete', $shiftTemplate);
73+
74+
$shiftTemplate->delete();
75+
76+
return redirect()->route('hr.shift-templates.index')->with('success', 'Shift template deleted.');
77+
}
78+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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+
10+
class ShiftAssignment extends Model
11+
{
12+
use BelongsToTenant;
13+
14+
protected $table = 'shift_assignments';
15+
16+
protected $fillable = [
17+
'tenant_id',
18+
'shift_template_id',
19+
'employee_id',
20+
'assigned_date',
21+
'notes',
22+
'status',
23+
];
24+
25+
protected $casts = [
26+
'assigned_date' => 'date',
27+
'status' => 'string',
28+
];
29+
30+
public function shiftTemplate(): BelongsTo
31+
{
32+
return $this->belongsTo(ShiftTemplate::class);
33+
}
34+
35+
public function employee(): BelongsTo
36+
{
37+
return $this->belongsTo(Employee::class);
38+
}
39+
40+
public function getIsUpcomingAttribute(): bool
41+
{
42+
return $this->assigned_date->greaterThanOrEqualTo(Carbon::today());
43+
}
44+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
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\HasMany;
9+
use Illuminate\Database\Eloquent\SoftDeletes;
10+
11+
class ShiftTemplate extends Model
12+
{
13+
use BelongsToTenant;
14+
use SoftDeletes;
15+
16+
protected $table = 'shift_templates';
17+
18+
protected $fillable = [
19+
'tenant_id',
20+
'name',
21+
'start_time',
22+
'end_time',
23+
'break_minutes',
24+
'days_of_week',
25+
'color',
26+
'is_active',
27+
];
28+
29+
protected $casts = [
30+
'days_of_week' => 'array',
31+
'break_minutes' => 'integer',
32+
'is_active' => 'boolean',
33+
];
34+
35+
public function assignments(): HasMany
36+
{
37+
return $this->hasMany(ShiftAssignment::class);
38+
}
39+
40+
public function getDurationHoursAttribute(): float
41+
{
42+
$start = Carbon::createFromFormat('H:i', substr($this->start_time, 0, 5));
43+
$end = Carbon::createFromFormat('H:i', substr($this->end_time, 0, 5));
44+
45+
$totalMinutes = $start->diffInMinutes($end);
46+
$workMinutes = $totalMinutes - (int) $this->break_minutes;
47+
48+
return round($workMinutes / 60, 2);
49+
}
50+
}
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 ShiftPolicy
8+
{
9+
public function viewAny(User $user): bool
10+
{
11+
return $user->can('hr.view');
12+
}
13+
14+
public function view(User $user): bool
15+
{
16+
return $user->can('hr.view');
17+
}
18+
19+
public function create(User $user): bool
20+
{
21+
return $user->can('hr.create');
22+
}
23+
24+
public function update(User $user): bool
25+
{
26+
return $user->can('hr.create');
27+
}
28+
29+
public function delete(User $user): bool
30+
{
31+
return $user->can('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
@@ -16,6 +16,8 @@
1616
use App\Modules\HR\Models\OnboardingTemplate;
1717
use App\Modules\HR\Models\PayrollRun;
1818
use App\Modules\HR\Models\PerformanceReview;
19+
use App\Modules\HR\Models\ShiftAssignment;
20+
use App\Modules\HR\Models\ShiftTemplate;
1921
use App\Modules\HR\Models\TrainingCourse;
2022
use App\Modules\HR\Models\WorkSchedule;
2123
use App\Modules\HR\Policies\AttendancePolicy;
@@ -29,6 +31,7 @@
2931
use App\Modules\HR\Policies\PayrollRunPolicy;
3032
use App\Modules\HR\Policies\PerformanceReviewPolicy;
3133
use App\Modules\HR\Policies\RecruitmentPolicy;
34+
use App\Modules\HR\Policies\ShiftPolicy;
3235
use App\Modules\HR\Policies\TrainingPolicy;
3336
use Illuminate\Support\Facades\Gate;
3437
use Illuminate\Support\ServiceProvider;
@@ -57,5 +60,7 @@ public function boot(): void
5760
Gate::policy(PerformanceReview::class, PerformanceReviewPolicy::class);
5861
Gate::policy(TrainingCourse::class, TrainingPolicy::class);
5962
Gate::policy(EmployeeTrainingRecord::class, TrainingPolicy::class);
63+
Gate::policy(ShiftTemplate::class, ShiftPolicy::class);
64+
Gate::policy(ShiftAssignment::class, ShiftPolicy::class);
6065
}
6166
}

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
use App\Modules\HR\Http\Controllers\PayrollRunController;
1616
use App\Modules\HR\Http\Controllers\PerformanceReviewController;
1717
use App\Modules\HR\Http\Controllers\TrainingCourseController;
18+
use App\Modules\HR\Http\Controllers\ShiftAssignmentController;
19+
use App\Modules\HR\Http\Controllers\ShiftTemplateController;
1820
use App\Modules\HR\Http\Controllers\WorkScheduleController;
1921
use Illuminate\Support\Facades\Route;
2022

@@ -107,6 +109,13 @@
107109
// Work Schedules
108110
Route::resource('work-schedules', WorkScheduleController::class)->except(['edit', 'update']);
109111

112+
// Shift Templates
113+
Route::resource('shift-templates', ShiftTemplateController::class)->except(['edit', 'update']);
114+
115+
// Shift Assignments — markStatus BEFORE resource
116+
Route::patch('shift-assignments/{shiftAssignment}/status', [ShiftAssignmentController::class, 'markStatus'])->name('shift-assignments.status');
117+
Route::resource('shift-assignments', ShiftAssignmentController::class)->except(['edit', 'update', 'show']);
118+
110119
// Employee Loans
111120
Route::post('employee-loans/{employeeLoan}/approve', [EmployeeLoanController::class, 'approve'])->name('employee-loans.approve');
112121
Route::post('employee-loans/{employeeLoan}/cancel', [EmployeeLoanController::class, 'cancel'])->name('employee-loans.cancel');
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('shift_templates', function (Blueprint $table) {
12+
$table->id();
13+
$table->unsignedBigInteger('tenant_id');
14+
$table->string('name');
15+
$table->time('start_time');
16+
$table->time('end_time');
17+
$table->unsignedInteger('break_minutes')->default(0);
18+
$table->json('days_of_week')->nullable();
19+
$table->string('color', 20)->default('#6366f1');
20+
$table->boolean('is_active')->default(true);
21+
$table->softDeletes();
22+
$table->timestamps();
23+
$table->index('tenant_id');
24+
});
25+
}
26+
27+
public function down(): void
28+
{
29+
Schema::dropIfExists('shift_templates');
30+
}
31+
};

0 commit comments

Comments
 (0)