feat: add Shift Planning module with full CRUD, attendance, and reporting

- Add shifts, shift_staff, and shift_attendance migrations
- Add Shift, ShiftStaff, ShiftAttendance Eloquent models with auditing
- Add ShiftService with business logic (create, start, complete, assign staff, mark attendance, reports, timesheets)
- Add ShiftResource with list, create, edit, and attendance management pages
- Add staff and attendance relation managers
- Add standalone pages: ActiveShifts, StaffManagement, ShiftReports, Timesheets
- Add dashboard widgets: ShiftOverview stats, TodaysShifts table
- Add ShiftPolicy for role-based authorization
- Add 10 shift permissions and manager role to RolePermissionSeeder
- Update User model with shift relationships
- Fix audits table migration with required columns for owen-it/laravel-auditing
- Update DatabaseSeeder to create admin user and call RolePermissionSeeder
- Switch docker-compose to MariaDB 10.11 for Docker Desktop compatibility
This commit is contained in:
2026-03-10 09:44:17 +02:00
parent b4355fee17
commit 961f288d97
36 changed files with 1827 additions and 14 deletions

View File

@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>

View File

@@ -0,0 +1,69 @@
<x-filament-panels::page>
<form wire:submit="generateReport">
{{ $this->form }}
<div class="mt-4">
<x-filament::button type="submit" icon="heroicon-o-funnel">
Generate Report
</x-filament::button>
</div>
</form>
@if($reportData)
<div class="mt-8 space-y-6">
{{-- Summary Cards --}}
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<div class="text-sm text-gray-500 dark:text-gray-400">Total Shifts</div>
<div class="text-2xl font-bold mt-1">{{ $reportData['total_shifts'] }}</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<div class="text-sm text-gray-500 dark:text-gray-400">Planned</div>
<div class="text-2xl font-bold mt-1 text-yellow-600">{{ $reportData['planned_count'] }}</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<div class="text-sm text-gray-500 dark:text-gray-400">In Progress</div>
<div class="text-2xl font-bold mt-1 text-green-600">{{ $reportData['in_progress_count'] }}</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<div class="text-sm text-gray-500 dark:text-gray-400">Completed</div>
<div class="text-2xl font-bold mt-1 text-gray-600">{{ $reportData['completed_count'] }}</div>
</div>
</div>
{{-- Staff Summary Table --}}
@if(count($reportData['staff_summary']) > 0)
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10 overflow-hidden">
<div class="p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold">Staff Summary</h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ $reportData['date_from'] }} to {{ $reportData['date_to'] }}
</p>
</div>
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Staff Member</th>
<th class="px-4 py-3 text-right font-medium text-gray-500 dark:text-gray-400">Shifts Worked</th>
<th class="px-4 py-3 text-right font-medium text-gray-500 dark:text-gray-400">Total Hours</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach($reportData['staff_summary'] as $staff)
<tr>
<td class="px-4 py-3">{{ $staff['name'] }}</td>
<td class="px-4 py-3 text-right">{{ $staff['shifts_count'] }}</td>
<td class="px-4 py-3 text-right">{{ number_format($staff['total_hours'], 2) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 text-center shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<p class="text-gray-500 dark:text-gray-400">No shift data found for the selected criteria.</p>
</div>
@endif
</div>
@endif
</x-filament-panels::page>

View File

@@ -0,0 +1,3 @@
<x-filament-panels::page>
{{ $this->table }}
</x-filament-panels::page>

View File

@@ -0,0 +1,76 @@
<x-filament-panels::page>
<form wire:submit="generateTimesheet">
{{ $this->form }}
<div class="mt-4">
<x-filament::button type="submit" icon="heroicon-o-document-text">
Generate Timesheet
</x-filament::button>
</div>
</form>
@if($timesheetData)
<div class="mt-8 space-y-6">
{{-- Header --}}
<div class="bg-white dark:bg-gray-800 rounded-xl p-6 shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<div class="flex justify-between items-start">
<div>
<h3 class="text-xl font-bold">{{ $timesheetData['staff_name'] }}</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Timesheet: {{ $timesheetData['date_from'] }} to {{ $timesheetData['date_to'] }}
</p>
</div>
<div class="text-right">
<div class="text-sm text-gray-500 dark:text-gray-400">Total Hours</div>
<div class="text-3xl font-bold text-primary-600">{{ number_format($timesheetData['total_hours'], 2) }}</div>
</div>
</div>
</div>
{{-- Timesheet Entries --}}
@if(count($timesheetData['entries']) > 0)
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10 overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Date</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">Start Time</th>
<th class="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400">End Time</th>
<th class="px-4 py-3 text-right font-medium text-gray-500 dark:text-gray-400">Hours</th>
<th class="px-4 py-3 text-center font-medium text-gray-500 dark:text-gray-400">Status</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
@foreach($timesheetData['entries'] as $entry)
<tr>
<td class="px-4 py-3">{{ \Carbon\Carbon::parse($entry['date'])->format('D, d M Y') }}</td>
<td class="px-4 py-3">{{ $entry['start_time'] }}</td>
<td class="px-4 py-3">{{ $entry['end_time'] }}</td>
<td class="px-4 py-3 text-right font-medium">{{ number_format($entry['hours'], 2) }}</td>
<td class="px-4 py-3 text-center">
@if($entry['status'] === 'completed')
<span class="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-800 dark:bg-gray-700 dark:text-gray-200">Completed</span>
@else
<span class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800 dark:bg-green-700 dark:text-green-200">In Progress</span>
@endif
</td>
</tr>
@endforeach
</tbody>
<tfoot class="bg-gray-50 dark:bg-gray-700">
<tr>
<td colspan="3" class="px-4 py-3 font-semibold">Total</td>
<td class="px-4 py-3 text-right font-bold">{{ number_format($timesheetData['total_hours'], 2) }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
@else
<div class="bg-white dark:bg-gray-800 rounded-xl p-8 text-center shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<p class="text-gray-500 dark:text-gray-400">No timesheet entries found for the selected period.</p>
</div>
@endif
</div>
@endif
</x-filament-panels::page>

View File

@@ -0,0 +1,31 @@
<x-filament-panels::page>
<div class="mb-6">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<div class="text-sm text-gray-500 dark:text-gray-400">Status</div>
<div class="text-lg font-semibold mt-1">
@if($record->isInProgress())
<span class="text-green-600">In Progress</span>
@elseif($record->isCompleted())
<span class="text-gray-600">Completed</span>
@else
<span class="text-yellow-600">Planned</span>
@endif
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<div class="text-sm text-gray-500 dark:text-gray-400">Planned Time</div>
<div class="text-lg font-semibold mt-1">
{{ \Carbon\Carbon::parse($record->planned_start_time)->format('H:i') }}
{{ \Carbon\Carbon::parse($record->planned_end_time)->format('H:i') }}
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-xl p-4 shadow-sm ring-1 ring-gray-950/5 dark:ring-white/10">
<div class="text-sm text-gray-500 dark:text-gray-400">Staff Assigned</div>
<div class="text-lg font-semibold mt-1">{{ $record->staff()->count() }}</div>
</div>
</div>
</div>
{{ $this->table }}
</x-filament-panels::page>