Commit 3d2c2052 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Program cascade delete + trainer-group assignment from both sides

1. Program delete:
   - Removed delete button from program list (was broken)
   - Added red "حذف البرنامج" button inside program show page
   - Cascade deletes: sessions, schedules, enrollments, waitlists,
     assignments, groups — all force-deleted in one transaction

2. Trainer-group assignment:
   - GroupShow: assign/remove trainers via polymorphic assignments table
   - TrainerShow: assign/remove groups, shows all assigned groups
     (both head_trainer_id and assignments-based)
   - A trainer can be assigned to multiple groups
   - A group can have multiple trainers assigned
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent b950ca10
......@@ -101,10 +101,18 @@ public function changeStatus(TrainingProgram $program, string $newStatus): Train
public function delete(TrainingProgram $program): void
{
DB::transaction(function () use ($program) {
if ($program->groups()->whereIn('status', ['active', 'forming'])->exists()) {
throw new DomainException('لا يمكن حذف برنامج يحتوي على مجموعات نشطة');
foreach ($program->groups as $group) {
$group->sessions()->forceDelete();
$group->schedules()->delete();
$group->enrollments()->forceDelete();
$group->waitlists()->delete();
\App\Domain\Scheduling\Models\Assignment::where('assignable_type', TrainingGroup::class)
->where('assignable_id', $group->id)
->delete();
$group->forceDelete();
}
$program->delete();
$program->enrollments()->forceDelete();
$program->forceDelete();
});
}
}
......@@ -4,10 +4,12 @@
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Scheduling\Models\Assignment;
use App\Domain\Training\Enums\SessionStatus;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSession;
use App\Models\User;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
......@@ -18,6 +20,7 @@ class GroupShow extends Component
{
public TrainingGroup $group;
public string $activeTab = 'overview';
public ?int $assignTrainerId = null;
public function mount(TrainingGroup $group): void
{
......@@ -34,6 +37,53 @@ public function mount(TrainingGroup $group): void
]);
}
public function assignTrainer(): void
{
$this->authorize('groups.update');
if (!$this->assignTrainerId) {
return;
}
$exists = Assignment::where('assignable_type', TrainingGroup::class)
->where('assignable_id', $this->group->id)
->where('user_id', $this->assignTrainerId)
->where('status', 'active')
->exists();
if ($exists) {
session()->flash('error', __('هذا المدرب مُعيَّن بالفعل لهذه المجموعة'));
return;
}
Assignment::create([
'academy_id' => $this->group->academy_id,
'user_id' => $this->assignTrainerId,
'assignable_type' => TrainingGroup::class,
'assignable_id' => $this->group->id,
'role_label' => 'مدرب',
'scope' => 'full',
'status' => 'active',
'is_primary' => false,
'start_date' => now()->toDateString(),
]);
$this->assignTrainerId = null;
session()->flash('success', __('تم تعيين المدرب بنجاح'));
}
public function removeTrainer(int $assignmentId): void
{
$this->authorize('groups.update');
Assignment::where('id', $assignmentId)
->where('assignable_type', TrainingGroup::class)
->where('assignable_id', $this->group->id)
->update(['status' => 'cancelled']);
session()->flash('success', __('تم إلغاء تعيين المدرب'));
}
public function render()
{
// Active enrollments with participant details
......@@ -114,6 +164,19 @@ public function render()
// Waitlist count
$waitlistCount = $this->group->waitlist_count ?? 0;
// Trainer assignments for this group
$trainerAssignments = Assignment::where('assignable_type', TrainingGroup::class)
->where('assignable_id', $this->group->id)
->where('status', 'active')
->with('user')
->get();
$availableTrainers = User::whereHas('roles', fn ($q) => $q->whereIn('slug', ['trainer', 'head_trainer']))
->where('status', 'active')
->whereNotIn('id', $trainerAssignments->pluck('user_id')->toArray())
->orderBy('name_ar')
->get(['id', 'name_ar', 'name']);
return view('livewire.groups.group-show', [
'activeEnrollments' => $activeEnrollments,
'totalEnrollments' => $totalEnrollments,
......@@ -131,6 +194,8 @@ public function render()
'capacityPercent' => $capacityPercent,
'participantAttendanceRates' => $participantAttendanceRates,
'waitlistCount' => $waitlistCount,
'trainerAssignments' => $trainerAssignments,
'availableTrainers' => $availableTrainers,
]);
}
}
......@@ -5,6 +5,7 @@
use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\HR\Models\Trainer;
use App\Domain\Scheduling\Models\Assignment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSession;
use Livewire\Attributes\Layout;
......@@ -17,6 +18,7 @@ class TrainerShow extends Component
{
public Trainer $trainer;
public string $activeTab = 'overview';
public ?int $assignGroupId = null;
public function mount(Trainer $trainer): void
{
......@@ -31,19 +33,97 @@ public function mount(Trainer $trainer): void
]);
}
public function assignGroup(): void
{
$this->authorize('trainers.update');
$trainerUserId = $this->trainer->employee?->user_id;
if (!$this->assignGroupId || !$trainerUserId) {
return;
}
$exists = Assignment::where('assignable_type', TrainingGroup::class)
->where('assignable_id', $this->assignGroupId)
->where('user_id', $trainerUserId)
->where('status', 'active')
->exists();
if ($exists) {
session()->flash('error', __('هذا المدرب مُعيَّن بالفعل لهذه المجموعة'));
return;
}
$group = TrainingGroup::find($this->assignGroupId);
if (!$group) {
return;
}
Assignment::create([
'academy_id' => $group->academy_id,
'user_id' => $trainerUserId,
'assignable_type' => TrainingGroup::class,
'assignable_id' => $this->assignGroupId,
'role_label' => 'مدرب',
'scope' => 'full',
'status' => 'active',
'is_primary' => false,
'start_date' => now()->toDateString(),
]);
$this->assignGroupId = null;
session()->flash('success', __('تم تعيين المجموعة بنجاح'));
}
public function removeGroupAssignment(int $assignmentId): void
{
$this->authorize('trainers.update');
Assignment::where('id', $assignmentId)
->where('user_id', $this->trainer->employee?->user_id)
->update(['status' => 'cancelled']);
session()->flash('success', __('تم إلغاء تعيين المجموعة'));
}
public function render()
{
$person = $this->trainer->employee?->person ?? $this->trainer->person;
$trainerUserId = $this->trainer->employee?->user_id;
// Groups where this trainer is head trainer
$groups = TrainingGroup::where('head_trainer_id', $trainerUserId)
// Groups where this trainer is head trainer OR assigned via assignments
$assignedGroupIds = Assignment::where('user_id', $trainerUserId)
->where('assignable_type', TrainingGroup::class)
->where('status', 'active')
->pluck('assignable_id')
->toArray();
$groups = TrainingGroup::where(function ($q) use ($trainerUserId, $assignedGroupIds) {
$q->where('head_trainer_id', $trainerUserId)
->orWhereIn('id', $assignedGroupIds);
})
->with(['program', 'schedules', 'branch'])
->withCount(['enrollments as active_enrollments_count' => function ($q) {
$q->where('status', 'active');
}])
->get();
// Assignments for this trainer (for UI remove buttons)
$trainerAssignments = Assignment::where('user_id', $trainerUserId)
->where('assignable_type', TrainingGroup::class)
->where('status', 'active')
->get()
->keyBy('assignable_id');
// Available groups not yet assigned
$availableGroups = TrainingGroup::whereNotIn('id', array_merge(
$assignedGroupIds,
TrainingGroup::where('head_trainer_id', $trainerUserId)->pluck('id')->toArray()
))
->whereIn('status', ['active', 'forming'])
->with('program')
->orderBy('name_ar')
->get(['id', 'name_ar', 'name', 'training_program_id']);
// Upcoming sessions (next 7 days)
$upcomingSessions = TrainingSession::whereHas('group', function ($q) use ($trainerUserId) {
$q->where('head_trainer_id', $trainerUserId);
......@@ -111,6 +191,8 @@ public function render()
'recentCompensations' => $recentCompensations,
'monthlyEarnings' => $monthlyEarnings,
'pendingAdvances' => $pendingAdvances,
'trainerAssignments' => $trainerAssignments,
'availableGroups' => $availableGroups,
]);
}
}
......@@ -2,9 +2,11 @@
namespace App\Livewire\Programs;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Training\Services\TrainingProgramService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
......@@ -32,6 +34,19 @@ public function mount(TrainingProgram $program): void
]);
}
public function deleteProgram(TrainingProgramService $service): void
{
$this->authorize('programs.delete');
try {
$service->delete($this->program);
session()->flash('success', __('تم حذف البرنامج وجميع مجموعاته بنجاح'));
$this->redirect(route('programs.list'), navigate: true);
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function render()
{
$groups = $this->program->groups;
......
......@@ -257,6 +257,60 @@ class="text-blue-600 hover:text-blue-800 hover:underline">
@endif
</div>
{{-- Trainer Assignments Card --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h3 class="text-base font-semibold text-gray-800 mb-4">{{ __('المدربون المُعيَّنون') }}</h3>
{{-- Current assignments --}}
@if($trainerAssignments->isNotEmpty())
<div class="space-y-2 mb-4">
@foreach($trainerAssignments as $assignment)
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-full bg-indigo-100 flex items-center justify-center">
<svg class="w-5 h-5 text-indigo-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>
<div>
<p class="text-sm font-medium text-gray-800">{{ $assignment->user?->name_ar ?? $assignment->user?->name ?? '—' }}</p>
<p class="text-xs text-gray-500">{{ __('منذ') }} {{ $assignment->start_date?->format('Y-m-d') ?? '—' }}</p>
</div>
</div>
@can('groups.update')
<button wire:click="removeTrainer({{ $assignment->id }})"
wire:confirm="{{ __('هل تريد إلغاء تعيين هذا المدرب؟') }}"
class="text-red-500 hover:text-red-700 p-1">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
@endcan
</div>
@endforeach
</div>
@else
<p class="text-sm text-gray-500 mb-4">{{ __('لا يوجد مدربون مُعيَّنون') }}</p>
@endif
{{-- Assign new trainer --}}
@can('groups.update')
<div class="flex items-center gap-2 pt-3 border-t border-gray-100">
<select wire:model="assignTrainerId" class="flex-1 text-sm border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<option value="">{{ __('اختر مدرب...') }}</option>
@foreach($availableTrainers as $trainer)
<option value="{{ $trainer->id }}">{{ $trainer->name_ar ?? $trainer->name }}</option>
@endforeach
</select>
<button wire:click="assignTrainer"
wire:loading.attr="disabled"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm font-medium whitespace-nowrap">
{{ __('تعيين') }}
</button>
</div>
@endcan
</div>
{{-- Schedule Grid Card --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h3 class="text-base font-semibold text-gray-800 mb-4">{{ __('مواعيد التدريب') }}</h3>
......
......@@ -325,17 +325,30 @@ class="text-sm font-semibold text-blue-600 hover:text-blue-800 hover:underline">
@endif
</div>
<div class="text-end">
<div class="flex items-center gap-1 text-sm">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"/></svg>
<span class="font-medium text-gray-700">{{ $group->active_enrollments_count }}</span>
<span class="text-gray-400">/</span>
<span class="text-gray-500">{{ $group->max_capacity }}</span>
</div>
@php $fillRate = $group->max_capacity > 0 ? round($group->active_enrollments_count / $group->max_capacity * 100) : 0; @endphp
<div class="w-20 bg-gray-200 rounded-full h-1.5 mt-2">
<div class="h-1.5 rounded-full {{ $fillRate >= 90 ? 'bg-red-500' : ($fillRate >= 70 ? 'bg-amber-500' : 'bg-green-500') }}" style="width: {{ min($fillRate, 100) }}%"></div>
<div class="flex items-center gap-3">
<div class="text-end">
<div class="flex items-center gap-1 text-sm">
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z"/></svg>
<span class="font-medium text-gray-700">{{ $group->active_enrollments_count }}</span>
<span class="text-gray-400">/</span>
<span class="text-gray-500">{{ $group->max_capacity }}</span>
</div>
@php $fillRate = $group->max_capacity > 0 ? round($group->active_enrollments_count / $group->max_capacity * 100) : 0; @endphp
<div class="w-20 bg-gray-200 rounded-full h-1.5 mt-2">
<div class="h-1.5 rounded-full {{ $fillRate >= 90 ? 'bg-red-500' : ($fillRate >= 70 ? 'bg-amber-500' : 'bg-green-500') }}" style="width: {{ min($fillRate, 100) }}%"></div>
</div>
</div>
@if(isset($trainerAssignments[$group->id]))
@can('trainers.update')
<button wire:click="removeGroupAssignment({{ $trainerAssignments[$group->id]->id }})"
wire:confirm="{{ __('إلغاء تعيين هذه المجموعة؟') }}"
class="text-red-400 hover:text-red-600 p-1" title="{{ __('إلغاء التعيين') }}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
@endcan
@endif
</div>
</div>
</div>
......@@ -348,6 +361,26 @@ class="text-sm font-semibold text-blue-600 hover:text-blue-800 hover:underline">
</div>
@endif
</div>
{{-- Assign new group --}}
@can('trainers.update')
<div class="mt-4 bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<h4 class="text-sm font-semibold text-gray-700 mb-3">{{ __('تعيين مجموعة جديدة') }}</h4>
<div class="flex items-center gap-2">
<select wire:model="assignGroupId" class="flex-1 text-sm border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500">
<option value="">{{ __('اختر مجموعة...') }}</option>
@foreach($availableGroups as $availGroup)
<option value="{{ $availGroup->id }}">{{ $availGroup->name_ar ?? $availGroup->name }} — {{ $availGroup->program?->name_ar }}</option>
@endforeach
</select>
<button wire:click="assignGroup"
wire:loading.attr="disabled"
class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm font-medium whitespace-nowrap">
{{ __('تعيين') }}
</button>
</div>
</div>
@endcan
</div>
{{-- Schedule Tab --}}
......
......@@ -140,14 +140,6 @@ class="text-blue-600 hover:text-blue-800 text-sm font-medium">
{{ __('تعديل') }}
</a>
@endpermission
@permission('programs.delete')
<button wire:click="delete('{{ $program->uuid }}')"
wire:confirm="{{ __('هل أنت متأكد من حذف هذا البرنامج؟') }}"
wire:loading.attr="disabled"
class="text-red-600 hover:text-red-800 text-sm font-medium">
{{ __('حذف') }}
</button>
@endpermission
</div>
</td>
</tr>
......@@ -222,15 +214,6 @@ class="inline-flex items-center gap-1 py-2 text-blue-600 hover:text-blue-800 tex
{{ __('تعديل') }}
</a>
@endpermission
@permission('programs.delete')
<button wire:click="delete('{{ $program->uuid }}')"
wire:confirm="{{ __('هل أنت متأكد من حذف هذا البرنامج؟') }}"
wire:loading.attr="disabled"
class="inline-flex items-center gap-1 py-2 text-red-600 hover:text-red-800 text-sm font-medium">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/></svg>
{{ __('حذف') }}
</button>
@endpermission
</div>
</div>
@empty
......
......@@ -80,6 +80,14 @@ class="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 text-sm
{{ __('تعديل') }}
</a>
@endcan
@can('programs.delete')
<button wire:click="deleteProgram"
wire:confirm="{{ __('هل أنت متأكد؟ سيتم حذف البرنامج وجميع المجموعات والاشتراكات المرتبطة به نهائياً.') }}"
wire:loading.attr="disabled"
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 text-sm font-medium">
{{ __('حذف البرنامج') }}
</button>
@endcan
<a href="{{ route('programs.list') }}" wire:navigate
class="px-4 py-2 text-gray-600 hover:text-gray-800 text-sm">
{{ __('رجوع') }}
......
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