Commit eea6ad2e authored by Mahmoud Aglan's avatar Mahmoud Aglan

Split attendance page: trainers on top, players below with role-based access

Trainers section (purple header) shows first with max 2-3 records.
Players section (blue header) shows below with full table.

Access control:
- Trainer account: can only mark their own attendance (others disabled)
- Supervisor/head_trainer (role level >= 60): can mark all trainers
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 04dcc7e4
......@@ -11,6 +11,7 @@
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
......@@ -55,6 +56,11 @@ public function saveRecordNote(int $recordId): void
public function markAs(int $recordId, string $status, AttendanceMarkingService $service): void
{
$record = AttendanceRecord::findOrFail($recordId);
if (!$this->canMarkRecord($record)) {
return;
}
$statusEnum = AttendanceStatus::from($status);
$service->markStatus($record, $statusEnum, auth()->user());
}
......@@ -62,6 +68,11 @@ public function markAs(int $recordId, string $status, AttendanceMarkingService $
public function markPresent(int $recordId, AttendanceMarkingService $service): void
{
$record = AttendanceRecord::findOrFail($recordId);
if (!$this->canMarkRecord($record)) {
return;
}
$service->markPresent($record, auth()->user());
}
......@@ -72,9 +83,42 @@ public function markAllPresent(AttendanceMarkingService $service): void
->get();
foreach ($records as $record) {
if ($this->canMarkRecord($record)) {
$service->markPresent($record, auth()->user());
}
}
}
private function canMarkRecord(AttendanceRecord $record): bool
{
$user = auth()->user();
if ($record->subject_type === Participant::class) {
return true;
}
if ($record->subject_type === User::class) {
if ($this->canManageTrainers()) {
return true;
}
return $record->subject_id === $user->id;
}
return true;
}
private function canManageTrainers(): bool
{
$user = auth()->user();
if ($user->is_super_admin) {
return true;
}
$roleLevel = $user->roles->max('level') ?? 0;
return $roleLevel >= 60;
}
public function render()
{
......@@ -84,6 +128,10 @@ public function render()
->orderBy('status')
->get();
// Split into trainers and participants
$trainerRecords = $records->where('subject_type', User::class)->values();
$participantRecords = $records->where('subject_type', Participant::class)->values();
// Populate per-record notes from DB (only on first load or if not yet set)
foreach ($records as $record) {
if (!array_key_exists($record->id, $this->recordNotes)) {
......@@ -92,8 +140,7 @@ public function render()
}
// Detect unpaid/unrenewed participants for red highlight
$participantIds = $records
->where('subject_type', Participant::class)
$participantIds = $participantRecords
->pluck('subject_id')
->unique()
->toArray();
......@@ -130,11 +177,17 @@ public function render()
'excused' => $records->where('status', AttendanceStatus::Excused)->count(),
];
$canManageTrainers = $this->canManageTrainers();
return view('livewire.attendance.take-attendance', [
'trainerRecords' => $trainerRecords,
'participantRecords' => $participantRecords,
'records' => $records,
'summary' => $summary,
'statuses' => AttendanceStatus::cases(),
'notPaidParticipantIds' => $notPaidParticipantIds,
'canManageTrainers' => $canManageTrainers,
'currentUserId' => auth()->id(),
]);
}
}
......@@ -91,29 +91,159 @@ class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-white
</div>
@endif
<!-- Attendance Records -->
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden" wire:loading.class="opacity-50 pointer-events-none" wire:target="markAs, markPresent, markAllPresent, saveRecordNote">
@if($records->isEmpty())
<div wire:loading.class="opacity-50 pointer-events-none" wire:target="markAs, markPresent, markAllPresent, saveRecordNote">
{{-- ========== TRAINERS SECTION ========== --}}
@if($trainerRecords->isNotEmpty())
<div class="mb-6">
<div class="flex items-center gap-2 mb-3">
<div class="w-8 h-8 bg-purple-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"/></svg>
</div>
<h2 class="text-base font-bold text-gray-800">{{ __('المدربون') }}</h2>
<span class="text-xs text-gray-500">({{ $trainerRecords->count() }})</span>
</div>
<div class="bg-white border border-purple-200 rounded-lg overflow-hidden">
{{-- Mobile cards --}}
<div class="md:hidden space-y-2 p-3">
@foreach($trainerRecords as $record)
@php
$isOwnRecord = $record->subject_id === $currentUserId;
$canMark = $canManageTrainers || $isOwnRecord;
@endphp
<div class="border {{ $isOwnRecord ? 'border-purple-300 bg-purple-50' : 'border-gray-200 bg-white' }} rounded-lg p-3 {{ !$canMark ? 'opacity-50' : '' }}">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-900">
{{ $record->subject?->name ?? '-' }}
@if($isOwnRecord)
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-purple-600 text-white ms-1">{{ __('أنت') }}</span>
@endif
</span>
@php $color = $record->status->color(); @endphp
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
@switch($color)
@case('green') bg-green-100 text-green-800 @break
@case('amber') bg-amber-100 text-amber-800 @break
@case('red') bg-red-100 text-red-800 @break
@case('blue') bg-blue-100 text-blue-800 @break
@default bg-gray-100 text-gray-800
@endswitch
">
{{ $record->status->label() }}
</span>
</div>
<div class="grid grid-cols-4 gap-1.5">
@foreach(['present' => ['حاضر', 'green'], 'late' => ['متأخر', 'amber'], 'absent' => ['غائب', 'red'], 'excused' => ['معذور', 'blue']] as $st => [$label, $clr])
<button wire:click="markAs({{ $record->id }}, '{{ $st }}')" wire:loading.attr="disabled"
@if(!$canMark) disabled @endif
class="py-2.5 text-xs font-medium rounded border text-center
{{ $record->status->value === $st ? "bg-{$clr}-600 text-white border-{$clr}-600" : "text-{$clr}-700 border-{$clr}-300 hover:bg-{$clr}-50" }}
disabled:opacity-50 disabled:cursor-not-allowed">
{{ __($label) }}
</button>
@endforeach
</div>
</div>
@endforeach
</div>
{{-- Desktop table --}}
<div class="hidden md:block overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-purple-50">
<tr>
<th class="px-4 py-3 text-start text-xs font-medium text-purple-700 uppercase">{{ __('المدرب') }}</th>
<th class="px-4 py-3 text-start text-xs font-medium text-purple-700 uppercase">{{ __('الحالة') }}</th>
<th class="px-4 py-3 text-start text-xs font-medium text-purple-700 uppercase">{{ __('وقت الحضور') }}</th>
<th class="px-4 py-3 text-start text-xs font-medium text-purple-700 uppercase">{{ __('الإجراءات') }}</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@foreach($trainerRecords as $record)
@php
$isOwnRecord = $record->subject_id === $currentUserId;
$canMark = $canManageTrainers || $isOwnRecord;
@endphp
<tr class="{{ $isOwnRecord ? 'bg-purple-50' : 'hover:bg-gray-50' }} {{ !$canMark ? 'opacity-50' : '' }}">
<td class="px-4 py-3 whitespace-nowrap">
<span class="text-sm font-medium text-gray-900">
{{ $record->subject?->name ?? '-' }}
</span>
@if($isOwnRecord)
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-purple-600 text-white ms-1">{{ __('أنت') }}</span>
@endif
</td>
<td class="px-4 py-3 whitespace-nowrap">
@php $color = $record->status->color(); @endphp
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
@switch($color)
@case('green') bg-green-100 text-green-800 @break
@case('amber') bg-amber-100 text-amber-800 @break
@case('red') bg-red-100 text-red-800 @break
@case('blue') bg-blue-100 text-blue-800 @break
@default bg-gray-100 text-gray-800
@endswitch
">
{{ $record->status->label() }}
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500" dir="ltr">
{{ $record->check_in_at?->format('H:i') ?? '-' }}
</td>
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center gap-1">
@foreach(['present' => ['حاضر', 'green', \App\Domain\Attendance\Enums\AttendanceStatus::Present], 'late' => ['متأخر', 'amber', \App\Domain\Attendance\Enums\AttendanceStatus::Late], 'absent' => ['غائب', 'red', \App\Domain\Attendance\Enums\AttendanceStatus::Absent], 'excused' => ['معذور', 'blue', \App\Domain\Attendance\Enums\AttendanceStatus::Excused]] as $st => [$label, $clr, $enum])
<button
wire:click="markAs({{ $record->id }}, '{{ $st }}')"
wire:loading.attr="disabled"
@if(!$canMark) disabled @endif
class="inline-flex items-center px-2 py-1 text-xs font-medium rounded border
{{ $record->status === $enum ? "bg-{$clr}-600 text-white border-{$clr}-600" : "text-{$clr}-700 border-{$clr}-300 hover:bg-{$clr}-50" }}
disabled:opacity-50 disabled:cursor-not-allowed"
title="{{ __($label) }}">
{{ __($label) }}
</button>
@endforeach
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
</div>
@endif
{{-- ========== PARTICIPANTS SECTION ========== --}}
@if($participantRecords->isNotEmpty())
<div>
<div class="flex items-center gap-2 mb-3">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
</div>
<h2 class="text-base font-bold text-gray-800">{{ __('اللاعبون') }}</h2>
<span class="text-xs text-gray-500">({{ $participantRecords->count() }})</span>
</div>
<div class="bg-white border border-gray-200 rounded-lg overflow-hidden">
@if($participantRecords->isEmpty())
<div class="p-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
<p class="mt-4 text-sm text-gray-500">{{ __('لا توجد سجلات حضور لهذه الجلسة') }}</p>
<p class="text-sm text-gray-500">{{ __('لا يوجد لاعبون في هذه الجلسة') }}</p>
</div>
@else
{{-- Mobile cards --}}
<div class="md:hidden space-y-2 p-3">
@foreach($records as $record)
@php $isUnpaid = $record->subject_type === \App\Domain\Participant\Models\Participant::class && in_array($record->subject_id, $notPaidParticipantIds); @endphp
@foreach($participantRecords as $record)
@php $isUnpaid = in_array($record->subject_id, $notPaidParticipantIds); @endphp
<div class="{{ $isUnpaid ? 'bg-red-50 border-red-300' : 'bg-white border-gray-200' }} border rounded-lg p-3">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium {{ $isUnpaid ? 'text-red-700' : 'text-gray-900' }}">
@if($record->subject_type === \App\Domain\Participant\Models\Participant::class)
{{ $record->subject?->person?->name_ar ?? '-' }}
@if($isUnpaid)
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-red-600 text-white ms-1">{{ __('غير مدفوع') }}</span>
@endif
@else
{{ $record->subject?->name_ar ?? $record->subject?->name ?? '-' }}
@endif
</span>
@php $color = $record->status->color(); @endphp
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
......@@ -167,7 +297,6 @@ class="py-2.5 text-xs font-medium rounded border text-center
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-start text-xs font-medium text-gray-500 uppercase">{{ __('الاسم') }}</th>
<th class="px-4 py-3 text-start text-xs font-medium text-gray-500 uppercase">{{ __('النوع') }}</th>
<th class="px-4 py-3 text-start text-xs font-medium text-gray-500 uppercase">{{ __('الحالة') }}</th>
<th class="px-4 py-3 text-start text-xs font-medium text-gray-500 uppercase">{{ __('وقت الحضور') }}</th>
<th class="px-4 py-3 text-start text-xs font-medium text-gray-500 uppercase">{{ __('ملاحظة') }}</th>
......@@ -175,37 +304,17 @@ class="py-2.5 text-xs font-medium rounded border text-center
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
@foreach($records as $record)
@php $isUnpaidRow = $record->subject_type === \App\Domain\Participant\Models\Participant::class && in_array($record->subject_id, $notPaidParticipantIds); @endphp
@foreach($participantRecords as $record)
@php $isUnpaidRow = in_array($record->subject_id, $notPaidParticipantIds); @endphp
<tr class="{{ $isUnpaidRow ? 'bg-red-50 hover:bg-red-100' : 'hover:bg-gray-50' }}">
<!-- Subject Name -->
<td class="px-4 py-3 whitespace-nowrap">
<span class="text-sm font-medium {{ $isUnpaidRow ? 'text-red-700' : 'text-gray-900' }}">
@if($record->subject_type === \App\Domain\Participant\Models\Participant::class)
{{ $record->subject?->person?->name_ar ?? '-' }}
@if($isUnpaidRow)
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-red-600 text-white ms-1">{{ __('غير مدفوع') }}</span>
@endif
@else
{{ $record->subject?->name_ar ?? $record->subject?->name ?? '-' }}
@endif
</span>
</td>
<!-- Subject Type Badge -->
<td class="px-4 py-3 whitespace-nowrap">
@if($record->subject_type === \App\Domain\Participant\Models\Participant::class)
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
{{ __('مشترك') }}
</span>
@else
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800">
{{ __('مدرب') }}
</span>
@endif
</td>
<!-- Status Badge -->
<td class="px-4 py-3 whitespace-nowrap">
@php $color = $record->status->color(); @endphp
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
......@@ -226,13 +335,9 @@ class="py-2.5 text-xs font-medium rounded border text-center
<span class="ms-1 text-xs text-gray-500" dir="ltr">({{ $record->late_minutes }} {{ __('د') }})</span>
@endif
</td>
<!-- Check-in Time -->
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500" dir="ltr">
{{ $record->check_in_at?->format('H:i') ?? '-' }}
</td>
<!-- Per-Record Note -->
<td class="px-4 py-3" x-data="{ expanded: false }">
<div class="flex items-center gap-1">
<input
......@@ -268,11 +373,8 @@ class="text-gray-400 hover:text-gray-600 shrink-0"
<span class="text-xs text-green-600 mt-0.5 block">{{ __('تم') }}</span>
@endif
</td>
<!-- Actions -->
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center gap-1">
<!-- Present -->
<button
wire:click="markAs({{ $record->id }}, 'present')"
wire:loading.attr="disabled"
......@@ -286,8 +388,6 @@ class="inline-flex items-center px-2 py-1 text-xs font-medium rounded border
<svg class="animate-spin w-3 h-3" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
</span>
</button>
<!-- Late -->
<button
wire:click="markAs({{ $record->id }}, 'late')"
wire:loading.attr="disabled"
......@@ -301,8 +401,6 @@ class="inline-flex items-center px-2 py-1 text-xs font-medium rounded border
<svg class="animate-spin w-3 h-3" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
</span>
</button>
<!-- Absent -->
<button
wire:click="markAs({{ $record->id }}, 'absent')"
wire:loading.attr="disabled"
......@@ -316,8 +414,6 @@ class="inline-flex items-center px-2 py-1 text-xs font-medium rounded border
<svg class="animate-spin w-3 h-3" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path></svg>
</span>
</button>
<!-- Excused -->
<button
wire:click="markAs({{ $record->id }}, 'excused')"
wire:loading.attr="disabled"
......@@ -340,4 +436,16 @@ class="inline-flex items-center px-2 py-1 text-xs font-medium rounded border
</div>
@endif
</div>
</div>
@endif
{{-- Empty state --}}
@if($trainerRecords->isEmpty() && $participantRecords->isEmpty())
<div class="bg-white border border-gray-200 rounded-lg p-12 text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
<p class="mt-4 text-sm text-gray-500">{{ __('لا توجد سجلات حضور لهذه الجلسة') }}</p>
</div>
@endif
</div>
</div>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment