Commit fc41ae25 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add sorting to all tables, fix group/program delete FK, fix Arabic name match

- Add WithSorting trait to POSHistory, ActivityLog, MessageLog
- Migrate GroupList and ProgramList to use WithSorting trait (remove duplicate)
- Add sortable columns (name, date, payment) to GroupShow enrollments subtable
- Add sort-header components to POS, ActivityLog, and MessageLog blade views
- Fix FK violation on group/program delete: nullify enrollments.transferred_to_id
- Fix Arabic name confirmation: normalize ي/ى, ة/ه, hamza variants before compare
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent f0df71cf
......@@ -101,6 +101,14 @@ public function changeStatus(TrainingProgram $program, string $newStatus): Train
public function delete(TrainingProgram $program): void
{
DB::transaction(function () use ($program) {
$groupIds = $program->groups()->pluck('id')->toArray();
// Nullify transferred_to_id references from other enrollments pointing to these groups
if ($groupIds) {
\App\Domain\Training\Models\Enrollment::whereIn('transferred_to_id', $groupIds)
->update(['transferred_to_id' => null]);
}
foreach ($program->groups as $group) {
$group->sessions()->forceDelete();
$group->schedules()->delete();
......
......@@ -3,6 +3,7 @@
namespace App\Livewire\Admin;
use App\Domain\Audit\Models\AuditLog;
use App\Livewire\Concerns\WithSorting;
use App\Models\User;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -14,7 +15,7 @@
#[Title('سجل النشاطات')]
class ActivityLog extends Component
{
use WithPagination;
use WithPagination, WithSorting;
#[Url]
public string $search = '';
......@@ -166,7 +167,7 @@ public function render()
->when($this->dateFrom, fn ($q) => $q->whereDate('created_at', '>=', $this->dateFrom))
->when($this->dateTo, fn ($q) => $q->whereDate('created_at', '<=', $this->dateTo))
->when($this->userFilter, fn ($q) => $q->where('user_id', $this->userFilter))
->orderByDesc('created_at');
->orderBy($this->sortBy, $this->sortDir);
$users = User::query()
->select('id', 'name', 'name_ar')
......
......@@ -10,6 +10,7 @@
use App\Domain\Training\Services\TrainingGroupService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Livewire\Concerns\AppliesRoleScope;
use App\Livewire\Concerns\WithSorting;
use Illuminate\Support\Facades\DB;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -21,7 +22,7 @@
#[Title('المجموعات التدريبية')]
class GroupList extends Component
{
use WithPagination, UsesBranchScope, AppliesRoleScope;
use WithPagination, WithSorting, UsesBranchScope, AppliesRoleScope;
protected string $scopePermission = 'groups.list';
......@@ -37,11 +38,6 @@ class GroupList extends Component
#[Url]
public string $programFilter = '';
#[Url]
public string $sortBy = 'name_ar';
#[Url]
public string $sortDir = 'asc';
public function updatedSearch(): void
{
......@@ -64,12 +60,12 @@ public function updatedProgramFilter(): void
$this->resetPage();
}
public function sortColumn(string $column): void
public function mount(): void
{
if ($this->sortBy === $column) {
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $column;
if (!request()->has('sortBy')) {
$this->sortBy = 'name_ar';
}
if (!request()->has('sortDir')) {
$this->sortDir = 'asc';
}
}
......@@ -93,6 +89,9 @@ public function deleteGroup(string $uuid): void
try {
DB::transaction(function () use ($group) {
// Nullify transferred_to_id references from other enrollments
\App\Domain\Training\Models\Enrollment::where('transferred_to_id', $group->id)
->update(['transferred_to_id' => null]);
// Sessions cascade-delete their attendance records automatically
$group->sessions()->forceDelete();
$group->schedules()->delete();
......
......@@ -27,6 +27,18 @@ class GroupShow extends Component
public TrainingGroup $group;
public string $activeTab = 'overview';
public ?int $assignTrainerId = null;
public string $enrollSortBy = 'name_ar';
public string $enrollSortDir = 'asc';
public function sortEnrollments(string $column): void
{
if ($this->enrollSortBy === $column) {
$this->enrollSortDir = $this->enrollSortDir === 'asc' ? 'desc' : 'asc';
} else {
$this->enrollSortBy = $column;
$this->enrollSortDir = 'asc';
}
}
public function mount(TrainingGroup $group): void
{
......@@ -94,11 +106,24 @@ public function removeTrainer(int $assignmentId): void
public function render()
{
// Active enrollments with participant details
$activeEnrollments = Enrollment::where('training_group_id', $this->group->id)
$enrollQuery = Enrollment::where('training_group_id', $this->group->id)
->where('status', 'active')
->with(['participant.person'])
->orderBy('enrollment_date')
->get();
->with(['participant.person']);
if ($this->enrollSortBy === 'name_ar') {
$enrollQuery->join('participants', 'enrollments.participant_id', '=', 'participants.id')
->join('people', 'participants.person_id', '=', 'people.id')
->orderBy('people.name_ar', $this->enrollSortDir)
->select('enrollments.*');
} elseif ($this->enrollSortBy === 'enrollment_date') {
$enrollQuery->orderBy('enrollment_date', $this->enrollSortDir);
} elseif ($this->enrollSortBy === 'payment_status') {
$enrollQuery->orderBy('payment_status', $this->enrollSortDir);
} else {
$enrollQuery->orderBy('enrollment_date', 'asc');
}
$activeEnrollments = $enrollQuery->get();
// Subscription payment status — ONLY checks program subscription, not products
$participantIds = $activeEnrollments->pluck('participant_id')->toArray();
......
......@@ -112,7 +112,11 @@ public function confirmDelete(TrainerService $service): void
$person = $this->trainer->employee?->person ?? $this->trainer->person;
$expectedName = $person?->name_ar ?? '';
if ($this->deleteConfirmation !== $expectedName) {
$normalize = fn (string $s) => trim(preg_replace('/\s+/u', ' ',
str_replace(['ي', 'ة', 'أ', 'إ', 'آ'], ['ى', 'ه', 'ا', 'ا', 'ا'], $s)
));
if ($normalize($this->deleteConfirmation) !== $normalize($expectedName)) {
$this->addError('deleteConfirmation', __('الاسم المدخل لا يتطابق مع اسم المدرب'));
return;
}
......
......@@ -4,6 +4,7 @@
use App\Domain\POS\Models\POSTransaction;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Livewire\Concerns\WithSorting;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -14,7 +15,7 @@
#[Title('سجل المبيعات')]
class POSHistory extends Component
{
use WithPagination, UsesBranchScope;
use WithPagination, WithSorting, UsesBranchScope;
#[Url]
public string $search = '';
......@@ -33,6 +34,9 @@ public function mount(): void
$this->authorize('pos.list');
$this->dateFrom = now()->subDays(7)->format('Y-m-d');
$this->dateTo = now()->format('Y-m-d');
if (!request()->has('sortBy')) {
$this->sortBy = 'processed_at';
}
}
public function updatedSearch(): void
......@@ -69,7 +73,7 @@ public function render()
->when($this->dateFrom, fn ($q) => $q->whereDate('processed_at', '>=', $this->dateFrom))
->when($this->dateTo, fn ($q) => $q->whereDate('processed_at', '<=', $this->dateTo))
->when($this->paymentMethodFilter, fn ($q) => $q->where('payment_method', $this->paymentMethodFilter))
->orderByDesc('processed_at');
->orderBy($this->sortBy, $this->sortDir);
return view('livewire.pos.pos-history', [
'transactions' => $query->paginate(20),
......
......@@ -9,6 +9,7 @@
use App\Domain\Training\Services\TrainingProgramService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Livewire\Concerns\AppliesRoleScope;
use App\Livewire\Concerns\WithSorting;
use App\Models\User;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
......@@ -20,7 +21,7 @@
#[Title('البرامج التدريبية')]
class ProgramList extends Component
{
use WithPagination, UsesBranchScope, AppliesRoleScope;
use WithPagination, WithSorting, UsesBranchScope, AppliesRoleScope;
protected string $scopePermission = 'programs.list';
......@@ -36,11 +37,6 @@ class ProgramList extends Component
#[Url]
public string $trainerFilter = '';
#[Url]
public string $sortBy = 'created_at';
#[Url]
public string $sortDir = 'desc';
public function updatedSearch(): void
{
......@@ -63,12 +59,12 @@ public function updatedTrainerFilter(): void
$this->resetPage();
}
public function sortColumn(string $column): void
public function mount(): void
{
if ($this->sortBy === $column) {
$this->sortDir = $this->sortDir === 'asc' ? 'desc' : 'asc';
} else {
$this->sortBy = $column;
if (!request()->has('sortBy')) {
$this->sortBy = 'name_ar';
}
if (!request()->has('sortDir')) {
$this->sortDir = 'asc';
}
}
......
......@@ -5,6 +5,7 @@
use App\Domain\WhatsApp\Enums\MessageStatus;
use App\Domain\WhatsApp\Enums\MessageType;
use App\Domain\WhatsApp\Models\WhatsAppMessage;
use App\Livewire\Concerns\WithSorting;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
......@@ -15,7 +16,7 @@
#[Title('سجل رسائل الواتساب')]
class MessageLog extends Component
{
use WithPagination;
use WithPagination, WithSorting;
#[Url]
public string $search = '';
......@@ -58,7 +59,7 @@ public function render()
})
->when($this->status, fn ($q) => $q->where('status', $this->status))
->when($this->type, fn ($q) => $q->where('type', $this->type))
->orderByDesc('created_at');
->orderBy($this->sortBy, $this->sortDir);
return view('livewire.whatsapp.message-log', [
'messages' => $query->paginate(25),
......
......@@ -184,11 +184,11 @@ class="w-full text-center py-2 text-xs font-medium rounded-md transition-colors
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('المستخدم') }}</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('الإجراء') }}</th>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('الكيان') }}</th>
<x-ui.sort-header column="action" :sortBy="$sortBy" :sortDir="$sortDir" align="center">{{ __('الإجراء') }}</x-ui.sort-header>
<x-ui.sort-header column="auditable_type" :sortBy="$sortBy" :sortDir="$sortDir">{{ __('الكيان') }}</x-ui.sort-header>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('ملخص التغييرات') }}</th>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('عنوان IP') }}</th>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('الوقت') }}</th>
<x-ui.sort-header column="created_at" :sortBy="$sortBy" :sortDir="$sortDir">{{ __('الوقت') }}</x-ui.sort-header>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('تفاصيل') }}</th>
</tr>
</thead>
......
......@@ -415,9 +415,36 @@ class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('المشترك') }}</th>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('تاريخ التسجيل') }}</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('الدفع') }}</th>
<th class="px-4 py-3 text-start font-medium text-gray-600">
<button type="button" wire:click="sortEnrollments('name_ar')" class="inline-flex items-center gap-1.5 cursor-pointer hover:text-blue-600 transition-colors group">
{{ __('المشترك') }}
@if($enrollSortBy === 'name_ar')
<svg class="w-4 h-4 text-blue-600 {{ $enrollSortDir === 'asc' ? '' : 'rotate-180' }}" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 4.414 6.707 7.707a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
@else
<svg class="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/></svg>
@endif
</button>
</th>
<th class="px-4 py-3 text-start font-medium text-gray-600">
<button type="button" wire:click="sortEnrollments('enrollment_date')" class="inline-flex items-center gap-1.5 cursor-pointer hover:text-blue-600 transition-colors group">
{{ __('تاريخ التسجيل') }}
@if($enrollSortBy === 'enrollment_date')
<svg class="w-4 h-4 text-blue-600 {{ $enrollSortDir === 'asc' ? '' : 'rotate-180' }}" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 4.414 6.707 7.707a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
@else
<svg class="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/></svg>
@endif
</button>
</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">
<button type="button" wire:click="sortEnrollments('payment_status')" class="inline-flex items-center gap-1.5 cursor-pointer hover:text-blue-600 transition-colors group">
{{ __('الدفع') }}
@if($enrollSortBy === 'payment_status')
<svg class="w-4 h-4 text-blue-600 {{ $enrollSortDir === 'asc' ? '' : 'rotate-180' }}" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.293 7.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L10 4.414 6.707 7.707a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
@else
<svg class="w-4 h-4 text-gray-400 opacity-0 group-hover:opacity-100 transition-opacity" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16V4m0 0L3 8m4-4l4 4m6 0v12m0 0l4-4m-4 4l-4-4"/></svg>
@endif
</button>
</th>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('أقساط') }}</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('الحالة') }}</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('نسبة الحضور') }}</th>
......
......@@ -64,13 +64,13 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
<table class="w-full text-sm hidden md:table">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-4 py-3 text-start text-xs font-semibold text-gray-500 uppercase">{{ __('رقم الإيصال') }}</th>
<x-ui.sort-header column="receipt_number" :sortBy="$sortBy" :sortDir="$sortDir">{{ __('رقم الإيصال') }}</x-ui.sort-header>
<th class="px-4 py-3 text-start text-xs font-semibold text-gray-500 uppercase">{{ __('المشترك') }}</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase">{{ __('العناصر') }}</th>
<th class="px-4 py-3 text-start text-xs font-semibold text-gray-500 uppercase">{{ __('الإجمالي') }}</th>
<th class="px-4 py-3 text-center text-xs font-semibold text-gray-500 uppercase">{{ __('الدفع') }}</th>
<x-ui.sort-header column="total_amount" :sortBy="$sortBy" :sortDir="$sortDir">{{ __('الإجمالي') }}</x-ui.sort-header>
<x-ui.sort-header column="payment_method" :sortBy="$sortBy" :sortDir="$sortDir" align="center">{{ __('الدفع') }}</x-ui.sort-header>
<th class="px-4 py-3 text-start text-xs font-semibold text-gray-500 uppercase">{{ __('الكاشير') }}</th>
<th class="px-4 py-3 text-start text-xs font-semibold text-gray-500 uppercase">{{ __('التاريخ') }}</th>
<x-ui.sort-header column="processed_at" :sortBy="$sortBy" :sortDir="$sortDir">{{ __('التاريخ') }}</x-ui.sort-header>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
......
......@@ -42,12 +42,12 @@ class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:rin
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('الرقم') }}</th>
<x-ui.sort-header column="phone_number" :sortBy="$sortBy" :sortDir="$sortDir">{{ __('الرقم') }}</x-ui.sort-header>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('المحتوى') }}</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('النوع') }}</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('الحالة') }}</th>
<x-ui.sort-header column="type" :sortBy="$sortBy" :sortDir="$sortDir" align="center">{{ __('النوع') }}</x-ui.sort-header>
<x-ui.sort-header column="status" :sortBy="$sortBy" :sortDir="$sortDir" align="center">{{ __('الحالة') }}</x-ui.sort-header>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('المرسل') }}</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('التاريخ') }}</th>
<x-ui.sort-header column="created_at" :sortBy="$sortBy" :sortDir="$sortDir" align="center">{{ __('التاريخ') }}</x-ui.sort-header>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
......
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