Commit a652baab authored by Mahmoud Aglan's avatar Mahmoud Aglan

Fix participant CSV export: export all records, add filters, apply role scope

- Switch streamCsv from chunk() to cursor() to fix truncated export
- Mirror Livewire query in ExportController (search by membership_id/national_id)
- Apply PermissionService::applyScope() to export query
- Fix branch scoping fallback to match UsesBranchScope trait
- Add membership_type and is_free filters to list and export
- Pass sort params to export so CSV respects current sort order
- Whitelist sort columns to prevent SQL injection
- Output enum values and is_free column in CSV
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 13c77bfa
......@@ -4,6 +4,7 @@
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment;
use App\Domain\Identity\Services\PermissionService;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Services\ReportService;
use App\Domain\Training\Models\Enrollment;
......@@ -18,36 +19,57 @@ public function participants(Request $request): StreamedResponse
{
Gate::authorize('participants.list');
$branchId = session('active_branch_id');
$user = auth()->user();
$branchId = session()->has('active_branch_id')
? session('active_branch_id')
: $user->branch_id;
$query = Participant::with(['person', 'branch', 'primaryActivity'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($request->status, fn ($q, $s) => $q->where('status', $s))
->when($request->search, function ($q) use ($request) {
$search = $request->search;
$q->where(function ($q2) use ($search) {
$q2->where('participant_number', 'ilike', "%{$search}%")
->orWhere('membership_id', 'ilike', "%{$search}%")
->orWhereHas('person', function ($pq) use ($search) {
$pq->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%");
->orWhere('phone', 'like', "%{$search}%")
->orWhere('national_id', 'like', "%{$search}%");
});
});
})
->when($request->status, fn ($q, $s) => $q->where('status', $s))
->when($request->activity, fn ($q, $a) => $q->where('primary_activity_id', $a))
->when($request->skill_level, fn ($q, $s) => $q->where('skill_level', $s))
->orderByDesc('created_at');
->when($request->membership_type, fn ($q, $m) => $q->where('membership_type', $m))
->when($request->is_free !== null && $request->is_free !== '', fn ($q) => $q->where('is_free', $request->is_free === '1'));
app(PermissionService::class)->applyScope($query, $user, 'participants.list');
$allowedSorts = ['name_ar', 'participant_number', 'primary_activity_id', 'membership_type', 'status', 'created_at'];
$sortBy = in_array($request->get('sort_by'), $allowedSorts) ? $request->get('sort_by') : 'name_ar';
$sortDir = in_array($request->get('sort_dir'), ['asc', 'desc']) ? $request->get('sort_dir') : 'asc';
if ($sortBy === 'name_ar') {
$query->join('people', 'participants.person_id', '=', 'people.id')
->orderBy('people.name_ar', $sortDir)
->select('participants.*');
} else {
$query->orderBy($sortBy, $sortDir);
}
return $this->streamCsv('participants', [
'الاسم', 'الهاتف', 'البريد', 'الحالة', 'نوع العضوية', 'رقم العضوية', 'الفرع', 'النشاط', 'تاريخ التسجيل',
'الاسم', 'الهاتف', 'البريد', 'الحالة', 'نوع العضوية', 'رقم العضوية', 'لاعب حر', 'الفرع', 'النشاط', 'تاريخ التسجيل',
], $query, function ($p) {
return [
$p->person?->name_ar ?? $p->person?->name ?? '',
$p->person?->phone ?? '',
$p->person?->email ?? '',
$p->status ?? '',
$p->membership_type ?? '',
$p->status?->value ?? $p->status ?? '',
$p->membership_type?->value ?? $p->membership_type ?? '',
$p->membership_id ?? '',
$p->is_free ? 'نعم' : 'لا',
$p->branch?->name_ar ?? '',
$p->primaryActivity?->name_ar ?? '',
$p->created_at?->format('Y-m-d'),
......@@ -211,11 +233,9 @@ private function streamCsv(string $filename, array $headers, $query, callable $r
fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($handle, $headers);
$query->chunk(500, function ($records) use ($handle, $rowMapper) {
foreach ($records as $record) {
fputcsv($handle, $rowMapper($record));
}
});
foreach ($query->cursor() as $record) {
fputcsv($handle, $rowMapper($record));
}
fclose($handle);
}, "{$filename}-{$date}.csv", [
......
......@@ -27,6 +27,12 @@ class ParticipantList extends Component
#[Url]
public string $status = '';
#[Url]
public string $membershipType = '';
#[Url]
public string $isFree = '';
public string $activity = '';
public string $skillLevel = '';
......@@ -60,6 +66,15 @@ public function updatedSkillLevel(): void
$this->resetPage();
}
public function updatedMembershipType(): void
{
$this->resetPage();
}
public function updatedIsFree(): void
{
$this->resetPage();
}
public function render()
{
......@@ -83,7 +98,9 @@ public function render()
})
->when($this->status, fn ($q) => $q->where('status', $this->status))
->when($this->activity, fn ($q) => $q->where('primary_activity_id', $this->activity))
->when($this->skillLevel, fn ($q) => $q->where('skill_level', $this->skillLevel));
->when($this->skillLevel, fn ($q) => $q->where('skill_level', $this->skillLevel))
->when($this->membershipType, fn ($q) => $q->where('membership_type', $this->membershipType))
->when($this->isFree !== '', fn ($q) => $q->where('is_free', $this->isFree === '1'));
if ($this->sortBy === 'name_ar') {
$query->join('people', 'participants.person_id', '=', 'people.id')
......@@ -115,6 +132,14 @@ public function render()
'advanced' => 'متقدم',
'professional' => 'محترف',
],
'membershipTypeOptions' => [
'member' => 'عضو',
'non_member' => 'غير عضو',
],
'isFreeOptions' => [
'1' => 'لاعب حر',
'0' => 'غير حر',
],
]);
}
}
......@@ -3,7 +3,7 @@
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 sm:mb-6">
<h1 class="text-xl sm:text-2xl font-bold text-gray-800">{{ __('المشتركين') }}</h1>
<div class="flex items-center gap-2">
<a href="{{ route('export.participants', ['status' => $status, 'search' => $search, 'activity' => $activity, 'skill_level' => $skillLevel]) }}"
<a href="{{ route('export.participants', ['status' => $status, 'search' => $search, 'activity' => $activity, 'skill_level' => $skillLevel, 'membership_type' => $membershipType, 'is_free' => $isFree, 'sort_by' => $sortBy, 'sort_dir' => $sortDir]) }}"
class="inline-flex items-center gap-2 px-3 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-xs sm:text-sm font-medium transition-colors">
<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="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
<span class="hidden sm:inline">{{ __('تصدير CSV') }}</span>
......@@ -27,8 +27,8 @@ class="inline-flex items-center gap-2 px-3 sm:px-4 py-2 bg-blue-600 text-white r
{{-- Filters --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-3 sm:p-4 mb-4">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
<div class="col-span-2 md:col-span-1 lg:col-span-2">
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<div class="col-span-2 md:col-span-3 lg:col-span-4">
<input type="text" wire:model.live.debounce.300ms="search"
placeholder="{{ __('بحث بالاسم، الرقم القومي، رقم العضوية، أو الهاتف...') }}"
class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
......@@ -45,6 +45,18 @@ class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 f
<option value="{{ $act->id }}">{{ $act->name_ar }}</option>
@endforeach
</select>
<select wire:model.live="membershipType" class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
<option value="">{{ __('كل أنواع العضوية') }}</option>
@foreach($membershipTypeOptions as $value => $label)
<option value="{{ $value }}">{{ __($label) }}</option>
@endforeach
</select>
<select wire:model.live="isFree" class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
<option value="">{{ __('الكل') }}</option>
@foreach($isFreeOptions as $value => $label)
<option value="{{ $value }}">{{ __($label) }}</option>
@endforeach
</select>
<select wire:model.live="skillLevel" class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
<option value="">{{ __('كل المستويات') }}</option>
@foreach($skillLevelOptions as $value => $label)
......
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