Commit 7af211fc authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(attendance): stop the roster moving under the coach's finger

Taking attendance re-sorted the list by status on every render, so the
moment a coach marked someone the row jumped somewhere else and everyone
below it shifted. Coaches lost their place, could not tell who was already
handled, and recorded the same player several times.

The roster is now ordered by name with the record id as a tie-break —
never by anything the coach can change from this screen — so the list
holds still. Marking a player takes them out of the working list entirely
and into a collapsed "تم تسجيلهم" section, grouped by status with counts,
where the decision can be reviewed or changed. A confirmation toast names
the player and the status that was saved, and a progress card shows how
many are left.

Also here:
- markAs/markPresent/saveRecordNote now resolve the record within this
  session instead of by bare id, and reject statuses outside the four the
  screen offers
- service calls are wrapped in try/catch, so a blocked medical certificate
  shows an Arabic message instead of an error page
- the polymorphic subject relation is eager-loaded with morphWith (was an
  N+1 on every player row)
- one responsive card list replaces the duplicated mobile/desktop markup;
  targets are ≥36px, the progress bar carries progressbar semantics and
  the toast is an aria-live region
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent 48a79a76
......@@ -9,9 +9,12 @@
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Illuminate\Support\Collection;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
......@@ -20,6 +23,15 @@
#[Title('تسجيل الحضور')]
class TakeAttendance extends Component
{
/**
* The four statuses a trainer can assign from this screen, in display order.
* Everything else (no_show, partial, ...) is read-only here.
*/
private const ACTION_STATUSES = ['present', 'absent', 'late', 'excused'];
/** Order the recorded groups appear in inside the collapsed section. */
private const RECORDED_GROUP_ORDER = ['present', 'late', 'absent', 'excused'];
public TrainingSession $session;
public string $sessionNotes = '';
......@@ -27,6 +39,15 @@ class TakeAttendance extends Component
/** @var array<int, string> Per-record notes keyed by record id */
public array $recordNotes = [];
/** Whether the "already recorded" section is expanded. */
public bool $showRecorded = false;
/** Details of the last mark, shown as a confirmation toast. */
public ?array $lastMarked = null;
/** Increments on every mark so the toast re-mounts (and re-animates) each time. */
public int $markSeq = 0;
public function mount(TrainingSession $session, AttendanceGenerationService $generationService): void
{
$this->authorize('attendance.mark');
......@@ -47,33 +68,57 @@ public function saveNotes(): void
public function saveRecordNote(int $recordId): void
{
$record = AttendanceRecord::findOrFail($recordId);
$note = $this->recordNotes[$recordId] ?? '';
$record->update(['notes' => $note]);
$record = $this->findSessionRecord($recordId);
if (!$record) {
return;
}
$record->update(['notes' => $this->recordNotes[$recordId] ?? '']);
session()->flash("record_note_saved_{$recordId}", __('تم حفظ الملاحظة'));
}
public function markAs(int $recordId, string $status, AttendanceMarkingService $service): void
{
$record = AttendanceRecord::findOrFail($recordId);
$record = $this->findSessionRecord($recordId);
if (!$record || !$this->canMarkRecord($record)) {
return;
}
if (!$this->canMarkRecord($record)) {
if (!in_array($status, self::ACTION_STATUSES, true)) {
return;
}
$statusEnum = AttendanceStatus::from($status);
$service->markStatus($record, $statusEnum, auth()->user());
try {
$service->markStatus($record, $statusEnum, auth()->user());
} catch (DomainException $e) {
$this->addError('mark', $e->getMessage());
return;
}
$this->confirmMark($record, $statusEnum);
}
public function markPresent(int $recordId, AttendanceMarkingService $service): void
{
$record = AttendanceRecord::findOrFail($recordId);
$record = $this->findSessionRecord($recordId);
if (!$this->canMarkRecord($record)) {
if (!$record || !$this->canMarkRecord($record)) {
return;
}
$service->markPresent($record, auth()->user());
try {
$updated = $service->markPresent($record, auth()->user());
} catch (DomainException $e) {
$this->addError('mark', $e->getMessage());
return;
}
$this->confirmMark($record, $updated->status);
}
public function markAllPresent(AttendanceMarkingService $service): void
......@@ -82,11 +127,70 @@ public function markAllPresent(AttendanceMarkingService $service): void
->where('status', AttendanceStatus::Expected)
->get();
$marked = 0;
$blocked = 0;
foreach ($records as $record) {
if ($this->canMarkRecord($record)) {
if (!$this->canMarkRecord($record)) {
continue;
}
try {
$service->markPresent($record, auth()->user());
$marked++;
} catch (DomainException) {
$blocked++;
}
}
if ($blocked > 0) {
$this->addError('mark', __('تعذر تسجيل :count لاعب — الشهادة الطبية مطلوبة أو منتهية', ['count' => $blocked]));
}
if ($marked > 0) {
$this->markSeq++;
$this->lastMarked = [
'name' => __(':count لاعب', ['count' => $marked]),
'label' => AttendanceStatus::Present->label(),
'color' => AttendanceStatus::Present->color(),
];
}
}
public function toggleRecorded(): void
{
$this->showRecorded = !$this->showRecorded;
}
public function dismissConfirmation(): void
{
$this->lastMarked = null;
}
private function confirmMark(AttendanceRecord $record, AttendanceStatus $status): void
{
$this->markSeq++;
$this->lastMarked = [
'name' => $this->subjectName($record),
'label' => $status->label(),
'color' => $status->color(),
];
}
private function findSessionRecord(int $recordId): ?AttendanceRecord
{
return AttendanceRecord::where('training_session_id', $this->session->id)
->where('id', $recordId)
->first();
}
private function subjectName(AttendanceRecord $record): string
{
if ($record->subject_type === Participant::class) {
return $record->subject?->person?->name_ar ?? '-';
}
return $record->subject?->name ?? '-';
}
private function canMarkRecord(AttendanceRecord $record): bool
......@@ -120,17 +224,47 @@ private function canManageTrainers(): bool
return $roleLevel >= 60;
}
/**
* Stable, status-independent ordering: by name, then by id as a tie-break.
* Marking a player must never move anybody in the list.
*/
private function sortByName(Collection $records): Collection
{
return $records
->sortBy(fn (AttendanceRecord $record) => $this->subjectName($record)
. '#' . str_pad((string) $record->id, 12, '0', STR_PAD_LEFT))
->values();
}
public function render()
{
$records = AttendanceRecord::where('training_session_id', $this->session->id)
->with('subject')
->orderBy('subject_type')
->orderBy('status')
->with(['subject' => fn (MorphTo $morphTo) => $morphTo->morphWith([
Participant::class => ['person'],
])])
->orderBy('id')
->get();
// Split into trainers and participants
$trainerRecords = $records->where('subject_type', User::class)->values();
$participantRecords = $records->where('subject_type', Participant::class)->values();
$trainerRecords = $this->sortByName($records->where('subject_type', User::class));
$participantRecords = $this->sortByName($records->where('subject_type', Participant::class));
// The working list holds only players nobody has decided on yet.
// Once marked, a player leaves it and lands in the recorded section.
$pendingParticipants = $participantRecords
->where('status', AttendanceStatus::Expected)
->values();
$recordedParticipants = $participantRecords
->filter(fn (AttendanceRecord $record) => $record->status !== AttendanceStatus::Expected)
->values();
$recordedGroups = $recordedParticipants
->groupBy(fn (AttendanceRecord $record) => $record->status->value)
->sortBy(function ($group, $statusValue) {
$position = array_search($statusValue, self::RECORDED_GROUP_ORDER, true);
return $position === false ? count(self::RECORDED_GROUP_ORDER) : $position;
});
// Populate per-record notes from DB (only on first load or if not yet set)
foreach ($records as $record) {
......@@ -177,16 +311,28 @@ public function render()
'excused' => $records->where('status', AttendanceStatus::Excused)->count(),
];
$canManageTrainers = $this->canManageTrainers();
$participantsTotal = $participantRecords->count();
$participantsDone = $participantsTotal - $pendingParticipants->count();
$progress = [
'total' => $participantsTotal,
'done' => $participantsDone,
'pending' => $pendingParticipants->count(),
'percent' => $participantsTotal > 0
? (int) round(($participantsDone / $participantsTotal) * 100)
: 0,
];
return view('livewire.attendance.take-attendance', [
'trainerRecords' => $trainerRecords,
'participantRecords' => $participantRecords,
'records' => $records,
'pendingParticipants' => $pendingParticipants,
'recordedParticipants' => $recordedParticipants,
'recordedGroups' => $recordedGroups,
'summary' => $summary,
'statuses' => AttendanceStatus::cases(),
'progress' => $progress,
'notPaidParticipantIds' => $notPaidParticipantIds,
'canManageTrainers' => $canManageTrainers,
'canManageTrainers' => $this->canManageTrainers(),
'currentUserId' => auth()->id(),
]);
}
......
{{--
Per-record note field (desktop only keeps the mobile marking flow uncluttered).
@param \App\Domain\Attendance\Models\AttendanceRecord $record
--}}
<div class="hidden md:flex items-center gap-2 mt-2">
<input
type="text"
wire:model.blur="recordNotes.{{ $record->id }}"
wire:change="saveRecordNote({{ $record->id }})"
class="w-full rounded-lg border-gray-300 text-xs placeholder-gray-400 focus:border-indigo-500 focus:ring-indigo-500"
placeholder="{{ __('ملاحظة على اللاعب...') }}"
aria-label="{{ __('ملاحظة') }}"
>
@if(session("record_note_saved_{$record->id}"))
<span class="shrink-0 text-xs text-green-600">{{ __('تم') }}</span>
@endif
</div>
{{--
Attendance action buttons for a single record.
@param \App\Domain\Attendance\Models\AttendanceRecord $record
@param string $layout 'primary' (working list: حاضر/غائب large) | 'compact' (recorded list)
@param bool $canMark
--}}
@php
$layout = $layout ?? 'primary';
$canMark = $canMark ?? true;
$buttons = [
'present' => ['label' => 'حاضر', 'on' => 'bg-green-600 text-white border-green-600 shadow-sm', 'off' => 'bg-white text-green-700 border-green-300 hover:bg-green-50 active:bg-green-100'],
'absent' => ['label' => 'غائب', 'on' => 'bg-red-600 text-white border-red-600 shadow-sm', 'off' => 'bg-white text-red-700 border-red-300 hover:bg-red-50 active:bg-red-100'],
'late' => ['label' => 'متأخر', 'on' => 'bg-amber-600 text-white border-amber-600 shadow-sm', 'off' => 'bg-white text-amber-700 border-amber-300 hover:bg-amber-50 active:bg-amber-100'],
'excused' => ['label' => 'معذور', 'on' => 'bg-blue-600 text-white border-blue-600 shadow-sm', 'off' => 'bg-white text-blue-700 border-blue-300 hover:bg-blue-50 active:bg-blue-100'],
];
$groups = $layout === 'primary'
? [['present', 'absent'], ['late', 'excused']]
: [['present', 'absent', 'late', 'excused']];
$sizeFor = fn (string $status) => match (true) {
$layout === 'compact' => 'h-9 text-[11px] font-medium',
in_array($status, ['present', 'absent'], true) => 'h-12 text-sm font-bold',
default => 'h-10 text-xs font-medium',
};
@endphp
@foreach($groups as $groupIndex => $group)
<div class="grid gap-1.5 {{ $layout === 'compact' ? 'grid-cols-4' : 'grid-cols-2 gap-2' }} {{ $groupIndex > 0 ? 'mt-2' : '' }}">
@foreach($group as $status)
@php $isCurrent = $record->status->value === $status; @endphp
<button
type="button"
wire:click="markAs({{ $record->id }}, '{{ $status }}')"
wire:loading.attr="disabled"
wire:target="markAs({{ $record->id }}, '{{ $status }}')"
@disabled(!$canMark)
aria-pressed="{{ $isCurrent ? 'true' : 'false' }}"
class="inline-flex items-center justify-center gap-1 rounded-lg border transition-colors disabled:cursor-not-allowed disabled:opacity-50 {{ $sizeFor($status) }} {{ $isCurrent ? $buttons[$status]['on'] : $buttons[$status]['off'] }}">
<span wire:loading.remove wire:target="markAs({{ $record->id }}, '{{ $status }}')">
{{ __($buttons[$status]['label']) }}
</span>
<span wire:loading wire:target="markAs({{ $record->id }}, '{{ $status }}')">
<svg class="w-4 h-4 animate-spin" 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>
@endforeach
</div>
@endforeach
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