Commit 45a76a04 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix receptionist registration wizard — full rewrite of broken business logic

Problems fixed:
- MissingRulesException crash on step 4 (empty rules passed to validate())
- Guardian was never actually created as Person + Guardian record
- Guardian was never linked to participant via guardian_participant pivot
- Enrollment in program never happened (service didn't handle program_id)
- medical_notes was passed at wrong level (it's on people table, not participants)
- relation 'guardian' missing from validation (exists in DB CHECK constraint)

Now the wizard properly:
1. Creates guardian Person + Guardian record (or reuses existing)
2. Creates participant Person record with all fields (phone, national_id, medical_notes)
3. Creates Participant via ParticipantService with guardian linked
4. Attaches guardian via guardian_participant pivot
5. Enrolls in program via EnrollmentService (auto-finds/creates group)
6. Added participant phone + national_id fields to step 2
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 2fadfa10
...@@ -2,12 +2,18 @@ ...@@ -2,12 +2,18 @@
namespace App\Livewire\Receptionist; namespace App\Livewire\Receptionist;
use App\Domain\Identity\Models\Guardian;
use App\Domain\Identity\Models\Person;
use App\Domain\Identity\Services\PersonService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Participant\Services\DuplicateDetectionService; use App\Domain\Participant\Services\DuplicateDetectionService;
use App\Domain\Participant\Services\ParticipantService; use App\Domain\Participant\Services\ParticipantService;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope; use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Models\Activity; use App\Domain\Training\Models\Activity;
use App\Domain\Training\Models\TrainingProgram; use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Training\Services\EnrollmentService;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
...@@ -30,11 +36,13 @@ class NewRegistrationWizard extends Component ...@@ -30,11 +36,13 @@ class NewRegistrationWizard extends Component
public string $guardian_national_id = ''; public string $guardian_national_id = '';
public string $guardian_relation = 'father'; public string $guardian_relation = 'father';
// Step 2: Participant info // Step 2: Participant (the actual player/child)
public string $participant_name_ar = ''; public string $participant_name_ar = '';
public string $participant_name = ''; public string $participant_name = '';
public ?string $participant_date_of_birth = null; public ?string $participant_date_of_birth = null;
public string $participant_gender = 'male'; public string $participant_gender = 'male';
public string $participant_phone = '';
public string $participant_national_id = '';
public string $participant_medical_notes = ''; public string $participant_medical_notes = '';
// Step 3: Program selection // Step 3: Program selection
...@@ -62,19 +70,39 @@ public function mount(): void ...@@ -62,19 +70,39 @@ public function mount(): void
$this->branchId = $this->getActiveBranchIdOrFail(); $this->branchId = $this->getActiveBranchIdOrFail();
} }
public function rules(): array public function nextStep(): void
{ {
return match ($this->currentStep) { $rules = $this->rulesForStep($this->currentStep);
if (!empty($rules)) {
$this->validate($rules, $this->messages());
}
if ($this->currentStep === 1 && !$this->duplicateCheckDone) {
$this->checkDuplicates();
if (!empty($this->potentialDuplicates)) {
return;
}
}
$this->currentStep = min($this->currentStep + 1, $this->totalSteps);
}
private function rulesForStep(int $step): array
{
return match ($step) {
1 => [ 1 => [
'guardian_name_ar' => 'required|string|max:255', 'guardian_name_ar' => 'required|string|max:255',
'guardian_phone' => 'required|string|max:20', 'guardian_phone' => 'required|string|max:20',
'guardian_national_id' => 'nullable|string|max:14', 'guardian_national_id' => 'nullable|string|max:14',
'guardian_relation' => 'required|in:father,mother,brother,sister,uncle,aunt,grandfather,grandmother,other', 'guardian_relation' => 'required|in:father,mother,brother,sister,uncle,aunt,grandfather,grandmother,guardian,other',
], ],
2 => [ 2 => [
'participant_name_ar' => 'required|string|max:255', 'participant_name_ar' => 'required|string|max:255',
'participant_date_of_birth' => 'required|date|before:today', 'participant_date_of_birth' => 'required|date|before:today',
'participant_gender' => 'required|in:male,female', 'participant_gender' => 'required|in:male,female',
'participant_phone' => 'nullable|string|max:20',
'participant_national_id' => 'nullable|string|max:14',
'participant_medical_notes' => 'nullable|string|max:1000', 'participant_medical_notes' => 'nullable|string|max:1000',
], ],
3 => [ 3 => [
...@@ -109,24 +137,6 @@ public function messages(): array ...@@ -109,24 +137,6 @@ public function messages(): array
]; ];
} }
public function nextStep(): void
{
$this->validate();
// Check for duplicates when moving from step 1 to step 2
if ($this->currentStep === 1 && !$this->duplicateCheckDone) {
$this->checkDuplicates();
if (!empty($this->potentialDuplicates)) {
return; // Stay on step 1, show warning
}
}
$this->currentStep = min($this->currentStep + 1, $this->totalSteps);
}
/**
* Check for potential duplicate registrations based on guardian info.
*/
public function checkDuplicates(): void public function checkDuplicates(): void
{ {
$service = app(DuplicateDetectionService::class); $service = app(DuplicateDetectionService::class);
...@@ -136,7 +146,6 @@ public function checkDuplicates(): void ...@@ -136,7 +146,6 @@ public function checkDuplicates(): void
phone: $this->guardian_phone ?: null, phone: $this->guardian_phone ?: null,
); );
// Only show matches with confidence >= 80
$highConfidence = $matches->filter(fn ($m) => $m['confidence'] >= 80); $highConfidence = $matches->filter(fn ($m) => $m['confidence'] >= 80);
$this->potentialDuplicates = $highConfidence->map(fn ($m) => [ $this->potentialDuplicates = $highConfidence->map(fn ($m) => [
...@@ -151,24 +160,17 @@ public function checkDuplicates(): void ...@@ -151,24 +160,17 @@ public function checkDuplicates(): void
$this->duplicateCheckDone = true; $this->duplicateCheckDone = true;
// If no high-confidence matches, proceed automatically
if (empty($this->potentialDuplicates)) { if (empty($this->potentialDuplicates)) {
$this->currentStep = min($this->currentStep + 1, $this->totalSteps); $this->currentStep = min($this->currentStep + 1, $this->totalSteps);
} }
} }
/**
* User chose to proceed with registration despite duplicates.
*/
public function proceedDespiteDuplicates(): void public function proceedDespiteDuplicates(): void
{ {
$this->potentialDuplicates = []; $this->potentialDuplicates = [];
$this->currentStep = min($this->currentStep + 1, $this->totalSteps); $this->currentStep = min($this->currentStep + 1, $this->totalSteps);
} }
/**
* User chose to use an existing person record.
*/
public function useExistingPerson(int $personId): void public function useExistingPerson(int $personId): void
{ {
$this->useExistingPersonId = $personId; $this->useExistingPersonId = $personId;
...@@ -210,54 +212,92 @@ public function updatedSelectedActivityId(): void ...@@ -210,54 +212,92 @@ public function updatedSelectedActivityId(): void
$this->selected_program_id = null; $this->selected_program_id = null;
} }
public function confirm(ParticipantService $service): void public function confirm(): void
{ {
try { try {
// Step 4 is review — when confirmed, we process registration DB::transaction(function () {
// ParticipantService::register() handles: $actor = auth()->user();
// - Creating guardian person + guardian record $personService = app(PersonService::class);
// - Creating participant person + participant record $participantService = app(ParticipantService::class);
// - Linking guardian to participant $enrollmentService = app(EnrollmentService::class);
// - EnrollmentService::enrollInProgram() for the selected program
// - InvoiceService::create() for the fee // 1. Create or reuse guardian's Person record
// - PaymentService::recordPayment() if pay_now is true if ($this->useExistingPersonId) {
$guardianPerson = Person::findOrFail($this->useExistingPersonId);
$registrationData = [ } else {
'person' => [ $guardianPerson = Person::where('phone', $this->guardian_phone)->first();
if (!$guardianPerson) {
$guardianPerson = $personService->create([
'name_ar' => $this->guardian_name_ar,
'name' => $this->guardian_name ?: $this->guardian_name_ar,
'phone' => $this->guardian_phone,
'national_id' => $this->guardian_national_id ?: null,
], $actor);
}
}
// 2. Create or find the Guardian record
$guardian = Guardian::firstOrCreate(
['person_id' => $guardianPerson->id],
[
'academy_id' => app('current_academy')->id,
'relationship_type' => $this->guardian_relation,
'is_emergency_contact' => true,
'is_financial_responsible' => true,
'can_pickup' => true,
]
);
// 3. Create participant's Person record
$participantPerson = $personService->create([
'name_ar' => $this->participant_name_ar, 'name_ar' => $this->participant_name_ar,
'name' => $this->participant_name, 'name' => $this->participant_name ?: $this->participant_name_ar,
'date_of_birth' => $this->participant_date_of_birth, 'date_of_birth' => $this->participant_date_of_birth,
'gender' => $this->participant_gender, 'gender' => $this->participant_gender,
], 'phone' => $this->participant_phone ?: null,
'branch_id' => $this->branchId, 'national_id' => $this->participant_national_id ?: null,
'registration_source' => 'walk_in', 'medical_notes' => $this->participant_medical_notes ?: null,
'medical_notes' => $this->participant_medical_notes ?: null, ], $actor);
'guardian' => [
'name_ar' => $this->guardian_name_ar, // 4. Create Participant record via service
'name' => $this->guardian_name, $participant = $participantService->register([
'phone' => $this->guardian_phone, 'person_id' => $participantPerson->id,
'national_id' => $this->guardian_national_id ?: null, 'branch_id' => $this->branchId,
'relation' => $this->guardian_relation, 'registration_source' => 'walk_in',
], 'primary_guardian_id' => $guardian->id,
'program_id' => $this->selected_program_id, 'primary_activity_id' => $this->selected_activity_id,
'pay_now' => $this->pay_now, ], $actor);
'payment_method' => $this->pay_now ? $this->payment_method : null,
]; // 5. Link guardian to participant via pivot
$participant->guardians()->attach($guardian->id, [
// If user chose to use an existing person, pass that info 'relationship_type' => $this->guardian_relation,
if ($this->useExistingPersonId) { 'is_primary' => true,
$registrationData['existing_guardian_person_id'] = $this->useExistingPersonId; 'is_emergency_contact' => true,
} 'can_pickup' => true,
'receives_notifications' => true,
$result = $service->register($registrationData, auth()->user()); 'can_authorize_payment' => true,
]);
$this->completed = true;
$this->participant_number = $result->participant_number ?? null; // 6. Enroll in program
$this->currentStep = 6; $program = TrainingProgram::findOrFail($this->selected_program_id);
$enrollment = $enrollmentService->enrollInProgram(
$participant,
$program,
$actor,
['payment_status' => $this->pay_now ? 'paid' : 'pending']
);
$this->completed = true;
$this->participant_number = $participant->participant_number;
$this->enrollment_summary = $program->name_ar . ' — ' . ($enrollment->group?->name_ar ?? '');
$this->currentStep = 6;
});
session()->flash('success', __('تم التسجيل بنجاح')); session()->flash('success', __('تم التسجيل بنجاح'));
} catch (DomainException $e) { } catch (DomainException $e) {
session()->flash('error', $e->getMessage()); session()->flash('error', $e->getMessage());
} catch (\Throwable $e) {
session()->flash('error', 'حدث خطأ: ' . $e->getMessage());
} }
} }
...@@ -295,6 +335,7 @@ public function render() ...@@ -295,6 +335,7 @@ public function render()
'aunt' => 'عمة/خالة', 'aunt' => 'عمة/خالة',
'grandfather' => 'جد', 'grandfather' => 'جد',
'grandmother' => 'جدة', 'grandmother' => 'جدة',
'guardian' => 'وصي',
'other' => 'أخرى', 'other' => 'أخرى',
], ],
]); ]);
......
...@@ -188,13 +188,13 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-blue-600 text-white ...@@ -188,13 +188,13 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-blue-600 text-white
{{-- Step 2: Participant Info --}} {{-- Step 2: Participant Info --}}
@if($currentStep === 2) @if($currentStep === 2)
<div> <div>
<h2 class="text-lg font-semibold text-gray-800 mb-6">{{ __('بيانات المشترك') }}</h2> <h2 class="text-lg font-semibold text-gray-800 mb-6">{{ __('بيانات المشترك (اللاعب)') }}</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-5"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-5">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الاسم بالعربية') }} <span class="text-red-500">*</span></label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الاسم بالعربية') }} <span class="text-red-500">*</span></label>
<input type="text" wire:model="participant_name_ar" <input type="text" wire:model="participant_name_ar"
class="w-full px-4 py-3 min-h-16 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-lg" class="w-full px-4 py-3 min-h-16 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-lg"
placeholder="{{ __('اسم المشترك') }}"> placeholder="{{ __('اسم اللاعب / المشترك') }}">
@error('participant_name_ar') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror @error('participant_name_ar') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div> </div>
...@@ -202,7 +202,7 @@ class="w-full px-4 py-3 min-h-16 border border-gray-300 rounded-lg focus:ring-2 ...@@ -202,7 +202,7 @@ class="w-full px-4 py-3 min-h-16 border border-gray-300 rounded-lg focus:ring-2
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الاسم بالإنجليزية') }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الاسم بالإنجليزية') }}</label>
<input type="text" wire:model="participant_name" dir="ltr" <input type="text" wire:model="participant_name" dir="ltr"
class="w-full px-4 py-3 min-h-16 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-lg" class="w-full px-4 py-3 min-h-16 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-lg"
placeholder="Participant name"> placeholder="Player / participant name">
</div> </div>
<div> <div>
...@@ -235,6 +235,22 @@ class="w-full px-4 py-3 min-h-16 border border-gray-300 rounded-lg focus:ring-2 ...@@ -235,6 +235,22 @@ class="w-full px-4 py-3 min-h-16 border border-gray-300 rounded-lg focus:ring-2
@error('participant_gender') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror @error('participant_gender') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div> </div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('رقم هاتف المشترك') }}</label>
<input type="tel" wire:model="participant_phone" dir="ltr"
class="w-full px-4 py-3 min-h-16 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-lg"
placeholder="01xxxxxxxxx">
@error('participant_phone') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الرقم القومي للمشترك') }}</label>
<input type="text" wire:model="participant_national_id" dir="ltr"
class="w-full px-4 py-3 min-h-16 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-lg"
placeholder="00000000000000" maxlength="14">
@error('participant_national_id') <p class="mt-1 text-sm text-red-600">{{ $message }}</p> @enderror
</div>
<div class="sm:col-span-2"> <div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('ملاحظات طبية') }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ __('ملاحظات طبية') }}</label>
<textarea wire:model="participant_medical_notes" rows="3" <textarea wire:model="participant_medical_notes" rows="3"
...@@ -386,7 +402,7 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-blue-600 text-white ...@@ -386,7 +402,7 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-blue-600 text-white
{{-- Participant Summary --}} {{-- Participant Summary --}}
<div class="p-4 bg-gray-50 rounded-xl border border-gray-200"> <div class="p-4 bg-gray-50 rounded-xl border border-gray-200">
<div class="flex items-center justify-between mb-3"> <div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-gray-700">{{ __('المشترك') }}</h3> <h3 class="font-semibold text-gray-700">{{ __('المشترك (اللاعب)') }}</h3>
<button wire:click="goToStep(2)" class="text-sm text-blue-600 hover:text-blue-800">{{ __('تعديل') }}</button> <button wire:click="goToStep(2)" class="text-sm text-blue-600 hover:text-blue-800">{{ __('تعديل') }}</button>
</div> </div>
<div class="grid grid-cols-2 gap-3 text-sm"> <div class="grid grid-cols-2 gap-3 text-sm">
...@@ -402,6 +418,18 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-blue-600 text-white ...@@ -402,6 +418,18 @@ class="inline-flex items-center gap-2 px-6 py-3 min-h-16 bg-blue-600 text-white
<span class="text-gray-500">{{ __('الجنس') }}:</span> <span class="text-gray-500">{{ __('الجنس') }}:</span>
<span class="font-medium text-gray-800 ms-1">{{ $participant_gender === 'male' ? __('ذكر') : __('أنثى') }}</span> <span class="font-medium text-gray-800 ms-1">{{ $participant_gender === 'male' ? __('ذكر') : __('أنثى') }}</span>
</div> </div>
@if($participant_phone)
<div>
<span class="text-gray-500">{{ __('الهاتف') }}:</span>
<span class="font-medium text-gray-800 ms-1" dir="ltr">{{ $participant_phone }}</span>
</div>
@endif
@if($participant_national_id)
<div>
<span class="text-gray-500">{{ __('الرقم القومي') }}:</span>
<span class="font-medium text-gray-800 ms-1" dir="ltr">{{ $participant_national_id }}</span>
</div>
@endif
@if($participant_medical_notes) @if($participant_medical_notes)
<div class="col-span-2"> <div class="col-span-2">
<span class="text-gray-500">{{ __('ملاحظات طبية') }}:</span> <span class="text-gray-500">{{ __('ملاحظات طبية') }}:</span>
......
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