Commit 521f6d7b authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add group payment/product stats + fix WithSorting trait conflict

- Group view: added KPI cards for paid/unpaid/free player counts
- Group view: added essential product purchase stats with progress bars
- Group view: added installment info column per player in enrollments tab
- Fixed PHP 8.2+ trait property conflict: removed $sortBy/$sortDir
  redeclarations from 13 Livewire components that use WithSorting trait,
  moved custom defaults to mount() instead
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 77c43332
......@@ -23,8 +23,15 @@ class ActivityList extends Component
#[Url]
public string $category = '';
public string $sortBy = 'name_ar';
public string $sortDir = 'asc';
public function mount(): void
{
if (!request()->has('sortBy')) {
$this->sortBy = 'name_ar';
}
if (!request()->has('sortDir')) {
$this->sortDir = 'asc';
}
}
public function updatedSearch(): void
{
......
......@@ -18,9 +18,6 @@ class AttendanceList extends Component
{
use WithPagination, UsesBranchScope, AppliesRoleScope, WithSorting;
public string $sortBy = 'session_date';
public string $sortDir = 'desc';
protected string $scopePermission = 'attendance.list';
#[Url]
......@@ -38,6 +35,12 @@ class AttendanceList extends Component
public function mount(): void
{
$this->authorize('attendance.list');
if (!request()->has('sortBy')) {
$this->sortBy = 'session_date';
}
if (!request()->has('sortDir')) {
$this->sortDir = 'desc';
}
if (!$this->dateFrom) {
$this->dateFrom = now()->subDays(7)->format('Y-m-d');
}
......
......@@ -18,8 +18,15 @@ class BranchList extends Component
public string $search = '';
public bool $showInactive = false;
public string $sortBy = 'name_ar';
public string $sortDir = 'asc';
public function mount(): void
{
if (!request()->has('sortBy')) {
$this->sortBy = 'name_ar';
}
if (!request()->has('sortDir')) {
$this->sortDir = 'asc';
}
}
public function updatedSearch(): void
{
......
......@@ -22,8 +22,6 @@ class EnrollmentList extends Component
{
use WithPagination, WithSorting, UsesBranchScope, AppliesRoleScope;
public string $sortBy = 'enrollment_date';
protected string $scopePermission = 'enrollments.list';
#[Url]
......@@ -38,6 +36,13 @@ class EnrollmentList extends Component
public string $groupFilter = '';
public string $paymentFilter = '';
public function mount(): void
{
if (!request()->has('sortBy')) {
$this->sortBy = 'enrollment_date';
}
}
public function updatedSearch(): void
{
$this->resetPage();
......
......@@ -15,9 +15,6 @@ class EvaluationCriteriaList extends Component
{
use WithPagination, WithSorting;
public string $sortBy = 'sort_order';
public string $sortDir = 'asc';
public string $search = '';
// Form fields for create/edit
......@@ -34,6 +31,12 @@ class EvaluationCriteriaList extends Component
public function mount(): void
{
$this->authorize('evaluations.manage');
if (!request()->has('sortBy')) {
$this->sortBy = 'sort_order';
}
if (!request()->has('sortDir')) {
$this->sortDir = 'asc';
}
}
public function updatedSearch(): void
......
......@@ -22,9 +22,6 @@ class EvaluationList extends Component
protected string $scopePermission = 'evaluations.list';
public string $sortBy = 'evaluation_date';
public string $sortDir = 'desc';
#[Url]
public string $search = '';
......@@ -40,6 +37,12 @@ class EvaluationList extends Component
public function mount(): void
{
$this->authorize('evaluations.list');
if (!request()->has('sortBy')) {
$this->sortBy = 'evaluation_date';
}
if (!request()->has('sortDir')) {
$this->sortDir = 'desc';
}
}
public function updatedSearch(): void
......
......@@ -19,8 +19,6 @@ class ExpenseList extends Component
{
use WithPagination, UsesBranchScope, WithSorting;
public string $sortBy = 'expense_date';
#[Url]
public string $search = '';
......@@ -37,6 +35,13 @@ class ExpenseList extends Component
public ?string $cancellingExpenseUuid = null;
public string $cancellationReason = '';
public function mount(): void
{
if (!request()->has('sortBy')) {
$this->sortBy = 'expense_date';
}
}
public function updatedSearch(): void
{
$this->resetPage();
......
......@@ -17,9 +17,6 @@ class FacilityRentList extends Component
{
use WithPagination, UsesBranchScope, WithSorting;
public string $sortBy = 'payment_date';
public string $sortDir = 'desc';
#[Url]
public string $search = '';
......@@ -29,6 +26,16 @@ class FacilityRentList extends Component
#[Url]
public string $period_filter = '';
public function mount(): void
{
if (!request()->has('sortBy')) {
$this->sortBy = 'payment_date';
}
if (!request()->has('sortDir')) {
$this->sortDir = 'desc';
}
}
public function updatedSearch(): void
{
$this->resetPage();
......
......@@ -6,6 +6,9 @@
use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\InvoiceItem;
use App\Domain\Financial\Models\PaymentPlan;
use App\Domain\Inventory\Models\Product;
use App\Domain\Participant\Models\Participant;
use App\Domain\Scheduling\Models\Assignment;
use App\Domain\Training\Enums\SessionStatus;
......@@ -119,6 +122,74 @@ public function render()
$notPaidParticipantIds = array_unique(array_merge($unpaidParticipantIds, $overdueEnrollmentParticipantIds));
// Payment summary: paid, unpaid, free
$freePlayerIds = $activeEnrollments
->filter(fn ($e) => $e->participant?->is_free)
->pluck('participant_id')
->toArray();
$freeCount = count($freePlayerIds);
$nonFreeParticipantIds = array_diff($participantIds, $freePlayerIds);
$paidCount = count(array_diff($nonFreeParticipantIds, $notPaidParticipantIds));
$unpaidCount = count(array_intersect($nonFreeParticipantIds, $notPaidParticipantIds));
// Essential products: who bought / didn't buy each one
$essentialProducts = Product::where('is_essential', true)
->where('is_active', true)
->orderBy('sort_order')
->get(['id', 'name_ar', 'name']);
$essentialProductStats = [];
if ($essentialProducts->isNotEmpty() && $participantIds) {
$productClass = Product::class;
foreach ($essentialProducts as $product) {
$buyerIds = InvoiceItem::where('itemable_type', $productClass)
->where('itemable_id', $product->id)
->whereHas('invoice', function ($q) use ($participantIds) {
$q->where('billable_type', Participant::class)
->whereIn('billable_id', $participantIds)
->whereNull('deleted_at')
->whereNotIn('status', [InvoiceStatus::Cancelled]);
})
->join('invoices', 'invoice_items.invoice_id', '=', 'invoices.id')
->pluck('invoices.billable_id')
->unique()
->toArray();
$essentialProductStats[] = [
'product' => $product,
'bought_count' => count($buyerIds),
'not_bought_count' => count($participantIds) - count($buyerIds),
];
}
}
// Per-participant installment info (active payment plans)
$participantInstallments = [];
if ($participantIds) {
$plansWithItems = PaymentPlan::whereIn('status', ['active', 'partial'])
->whereHas('invoice', function ($q) use ($participantIds) {
$q->where('billable_type', Participant::class)
->whereIn('billable_id', $participantIds)
->whereNull('deleted_at');
})
->with(['invoice.items.itemable', 'invoice'])
->get();
foreach ($plansWithItems as $plan) {
$pid = $plan->invoice->billable_id;
$itemName = $plan->invoice->items->first()?->itemable?->name_ar
?? $plan->invoice->items->first()?->description
?? __('اشتراك');
$participantInstallments[$pid][] = [
'name' => $itemName,
'paid' => $plan->paid_installments,
'remaining' => $plan->total_installments - $plan->paid_installments,
'total' => $plan->total_installments,
];
}
}
// All enrollments count (any status)
$totalEnrollments = Enrollment::where('training_group_id', $this->group->id)
->whereNotIn('status', ['cancelled'])
......@@ -206,6 +277,11 @@ public function render()
return view('livewire.groups.group-show', [
'activeEnrollments' => $activeEnrollments,
'notPaidParticipantIds' => $notPaidParticipantIds,
'paidCount' => $paidCount,
'unpaidCount' => $unpaidCount,
'freeCount' => $freeCount,
'essentialProductStats' => $essentialProductStats,
'participantInstallments' => $participantInstallments,
'totalEnrollments' => $totalEnrollments,
'recentSessions' => $recentSessions,
'upcomingSessions' => $upcomingSessions,
......
......@@ -20,8 +20,15 @@ class WarehouseList extends Component
#[Url]
public string $search = '';
public string $sortBy = 'name_ar';
public string $sortDir = 'asc';
public function mount(): void
{
if (!request()->has('sortBy')) {
$this->sortBy = 'name_ar';
}
if (!request()->has('sortDir')) {
$this->sortDir = 'asc';
}
}
public function updatedSearch(): void
{
......
......@@ -17,9 +17,6 @@ class PricingRuleList extends Component
{
use WithPagination, WithSorting;
public string $sortBy = 'priority';
public string $sortDir = 'asc';
#[Url]
public string $search = '';
......@@ -32,6 +29,12 @@ class PricingRuleList extends Component
public function mount(): void
{
$this->authorize('pricing.list');
if (!request()->has('sortBy')) {
$this->sortBy = 'priority';
}
if (!request()->has('sortDir')) {
$this->sortDir = 'asc';
}
}
public function updatedSearch(): void
......
......@@ -15,11 +15,18 @@ class RoleList extends Component
{
use WithSorting;
public string $sortBy = 'level';
public string $sortDir = 'desc';
public ?int $confirmingDeleteId = null;
public function mount(): void
{
if (!request()->has('sortBy')) {
$this->sortBy = 'level';
}
if (!request()->has('sortDir')) {
$this->sortDir = 'desc';
}
}
public function confirmDelete(int $roleId): void
{
$this->confirmingDeleteId = $roleId;
......
......@@ -16,14 +16,21 @@ class UserList extends Component
{
use WithPagination, WithSorting;
public string $sortBy = 'name_ar';
public string $sortDir = 'asc';
#[Url]
public string $search = '';
public string $role = '';
public bool $showInactive = false;
public function mount(): void
{
if (!request()->has('sortBy')) {
$this->sortBy = 'name_ar';
}
if (!request()->has('sortDir')) {
$this->sortDir = 'asc';
}
}
public function updatedSearch(): void { $this->resetPage(); }
public function updatedRole(): void { $this->resetPage(); }
public function updatedShowInactive(): void { $this->resetPage(); }
......
......@@ -19,8 +19,6 @@ class WalletList extends Component
{
use WithPagination, UsesBranchScope, AppliesRoleScope, WithSorting;
public string $sortBy = 'balance';
protected string $scopePermission = 'wallets.list';
#[Url]
......@@ -29,6 +27,13 @@ class WalletList extends Component
#[Url]
public string $activeFilter = '';
public function mount(): void
{
if (!request()->has('sortBy')) {
$this->sortBy = 'balance';
}
}
public function updatedSearch(): void
{
$this->resetPage();
......
......@@ -91,47 +91,47 @@ class="px-4 py-2 text-gray-600 hover:text-gray-800 text-sm">
</div>
</div>
{{-- Capacity % --}}
{{-- Paid Count --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-{{ $capacityPercent >= 90 ? 'red' : ($capacityPercent >= 70 ? 'amber' : 'green') }}-50 flex items-center justify-center">
<svg class="w-5 h-5 text-{{ $capacityPercent >= 90 ? 'red' : ($capacityPercent >= 70 ? 'amber' : 'green') }}-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"/>
<div class="w-10 h-10 rounded-lg bg-green-50 flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="text-xs text-gray-500">{{ __('نسبة الامتلاء') }}</p>
<p class="text-lg font-bold text-gray-800">{{ $capacityPercent }}%</p>
<p class="text-xs text-gray-500">{{ __('دفعوا الاشتراك') }}</p>
<p class="text-lg font-bold text-green-700">{{ $paidCount }}</p>
</div>
</div>
</div>
{{-- Total Sessions --}}
{{-- Unpaid Count --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-purple-50 flex items-center justify-center">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
<div class="w-10 h-10 rounded-lg bg-red-50 flex items-center justify-center">
<svg class="w-5 h-5 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="text-xs text-gray-500">{{ __('إجمالي الحصص') }}</p>
<p class="text-lg font-bold text-gray-800">{{ $totalSessions }}</p>
<p class="text-xs text-gray-500">{{ __('لم يدفعوا') }}</p>
<p class="text-lg font-bold text-red-700">{{ $unpaidCount }}</p>
</div>
</div>
</div>
{{-- Completed Sessions --}}
{{-- Free Players --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-green-50 flex items-center justify-center">
<svg class="w-5 h-5 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
<div class="w-10 h-10 rounded-lg bg-violet-50 flex items-center justify-center">
<svg class="w-5 h-5 text-violet-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8c-1.657 0-3 .895-3 2s1.343 2 3 2 3 .895 3 2-1.343 2-3 2m0-8c1.11 0 2.08.402 2.599 1M12 8V7m0 1v8m0 0v1m0-1c-1.11 0-2.08-.402-2.599-1M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
<div>
<p class="text-xs text-gray-500">{{ __('حصص مكتملة') }}</p>
<p class="text-lg font-bold text-gray-800">{{ $completedSessions }}</p>
<p class="text-xs text-gray-500">{{ __('لاعبون مجانيون') }}</p>
<p class="text-lg font-bold text-violet-700">{{ $freeCount }}</p>
</div>
</div>
</div>
......@@ -167,6 +167,29 @@ class="px-4 py-2 text-gray-600 hover:text-gray-800 text-sm">
</div>
</div>
{{-- Essential Products Stats --}}
@if(!empty($essentialProductStats))
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-{{ min(count($essentialProductStats), 6) }} gap-3 sm:gap-4 mb-4 sm:mb-6">
@foreach($essentialProductStats as $stat)
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4">
<p class="text-xs font-medium text-gray-700 mb-2 truncate">{{ $stat['product']->name_ar ?? $stat['product']->name }}</p>
<div class="flex items-center gap-3">
<div class="flex-1">
<div class="flex items-center justify-between text-xs mb-1">
<span class="text-green-600 font-medium">{{ __('اشتروا') }}: {{ $stat['bought_count'] }}</span>
<span class="text-red-600 font-medium">{{ __('لم يشتروا') }}: {{ $stat['not_bought_count'] }}</span>
</div>
@php $buyPercent = $activeEnrollments->count() > 0 ? round(($stat['bought_count'] / $activeEnrollments->count()) * 100) : 0; @endphp
<div class="w-full bg-red-100 rounded-full h-2">
<div class="h-2 rounded-full bg-green-500" style="width: {{ $buyPercent }}%"></div>
</div>
</div>
</div>
</div>
@endforeach
</div>
@endif
{{-- Tabs --}}
<div x-data="{ activeTab: @entangle('activeTab') }">
<div class="border-b border-gray-200 mb-6">
......@@ -395,6 +418,7 @@ class="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 text-sm
<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">{{ __('أقساط') }}</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>
</tr>
......@@ -434,7 +458,11 @@ class="text-sm font-medium text-blue-600 hover:text-blue-800 hover:underline">
{{ $enrollment->enrollment_date?->format('Y-m-d') ?? '—' }}
</td>
<td class="px-4 py-3 text-center">
@if($isUnpaid)
@if($enrollment->participant?->is_free)
<span class="px-2 py-0.5 text-xs bg-violet-100 text-violet-700 rounded-full">
{{ __('مجاني') }}
</span>
@elseif($isUnpaid)
<span class="px-2 py-0.5 text-xs bg-red-100 text-red-700 font-bold rounded-full">
{{ __('لم يدفع') }}
</span>
......@@ -444,6 +472,26 @@ class="text-sm font-medium text-blue-600 hover:text-blue-800 hover:underline">
</span>
@endif
</td>
<td class="px-4 py-3">
@if(!empty($participantInstallments[$enrollment->participant_id]))
<div class="space-y-1">
@foreach($participantInstallments[$enrollment->participant_id] as $inst)
<div class="text-xs">
<span class="font-medium text-gray-700">{{ Str::limit($inst['name'], 20) }}</span>
<span class="text-gray-400 mx-1">|</span>
<span class="text-green-600">{{ $inst['paid'] }}</span>
<span class="text-gray-400">/</span>
<span class="text-gray-600">{{ $inst['total'] }}</span>
@if($inst['remaining'] > 0)
<span class="text-red-500 ms-1">({{ __('متبقي') }} {{ $inst['remaining'] }})</span>
@endif
</div>
@endforeach
</div>
@else
<span class="text-gray-400 text-xs"></span>
@endif
</td>
<td class="px-4 py-3 text-center">
<span class="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">
{{ __('نشط') }}
......
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