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

Financial overview: detailed revenue breakdown + unpaid highlight in attendance

1. Financial Overview: add subscription vs product revenue split, top programs
   by revenue, per-product totals, and 12 key financial metrics (churn rate,
   avg revenue per participant, profit margin, avg days to collect, etc.)
   All respect the existing period + branch filters.

2. Attendance views (TakeAttendance + QuickAttendance): detect participants
   with unpaid invoices or overdue renewals and highlight them in red with
   "غير مدفوع" badge so trainers can see payment status at a glance.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 2c52dcd8
...@@ -6,9 +6,13 @@ ...@@ -6,9 +6,13 @@
use App\Domain\Attendance\Models\AttendanceRecord; use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Attendance\Services\AttendanceGenerationService; use App\Domain\Attendance\Services\AttendanceGenerationService;
use App\Domain\Attendance\Services\AttendanceMarkingService; use App\Domain\Attendance\Services\AttendanceMarkingService;
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Participant\Models\Participant;
use App\Domain\Scheduling\Models\Assignment; use App\Domain\Scheduling\Models\Assignment;
use App\Domain\Shared\Traits\UsesBranchScope; use App\Domain\Shared\Traits\UsesBranchScope;
use App\Domain\Training\Enums\SessionStatus; use App\Domain\Training\Enums\SessionStatus;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup; use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingSession; use App\Domain\Training\Models\TrainingSession;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
...@@ -138,10 +142,11 @@ public function render() ...@@ -138,10 +142,11 @@ public function render()
// Load attendance records for selected session // Load attendance records for selected session
$records = collect(); $records = collect();
$summary = ['total' => 0, 'present' => 0, 'absent' => 0]; $summary = ['total' => 0, 'present' => 0, 'absent' => 0];
$notPaidParticipantIds = [];
if ($this->selectedSessionId) { if ($this->selectedSessionId) {
$records = AttendanceRecord::where('training_session_id', $this->selectedSessionId) $records = AttendanceRecord::where('training_session_id', $this->selectedSessionId)
->where('subject_type', \App\Domain\Participant\Models\Participant::class) ->where('subject_type', Participant::class)
->with('subject.person') ->with('subject.person')
->orderBy('id') ->orderBy('id')
->get(); ->get();
...@@ -151,12 +156,39 @@ public function render() ...@@ -151,12 +156,39 @@ public function render()
fn ($s) => $s === AttendanceStatus::Present->value fn ($s) => $s === AttendanceStatus::Present->value
)->count(); )->count();
$summary['absent'] = $summary['total'] - $summary['present']; $summary['absent'] = $summary['total'] - $summary['present'];
// Detect unpaid/unrenewed participants
$participantIds = $records->pluck('subject_id')->unique()->toArray();
if ($participantIds) {
$session = TrainingSession::find($this->selectedSessionId);
$unpaidInvoiceIds = Invoice::where('billable_type', Participant::class)
->whereIn('billable_id', $participantIds)
->whereIn('status', [InvoiceStatus::Sent, InvoiceStatus::Overdue, InvoiceStatus::PartiallyPaid])
->where('due_amount', '>', 0)
->whereNull('deleted_at')
->pluck('billable_id')
->unique()
->toArray();
$overdueEnrollmentIds = Enrollment::whereIn('participant_id', $participantIds)
->where('status', 'active')
->when($session, fn ($q) => $q->where('training_group_id', $session->training_group_id))
->whereNotNull('next_billing_date')
->where('next_billing_date', '<', now()->toDateString())
->pluck('participant_id')
->unique()
->toArray();
$notPaidParticipantIds = array_unique(array_merge($unpaidInvoiceIds, $overdueEnrollmentIds));
}
} }
return view('livewire.attendance.quick-attendance', [ return view('livewire.attendance.quick-attendance', [
'todaySessions' => $todaySessions, 'todaySessions' => $todaySessions,
'records' => $records, 'records' => $records,
'summary' => $summary, 'summary' => $summary,
'notPaidParticipantIds' => $notPaidParticipantIds,
]); ]);
} }
} }
...@@ -6,6 +6,10 @@ ...@@ -6,6 +6,10 @@
use App\Domain\Attendance\Models\AttendanceRecord; use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Attendance\Services\AttendanceGenerationService; use App\Domain\Attendance\Services\AttendanceGenerationService;
use App\Domain\Attendance\Services\AttendanceMarkingService; use App\Domain\Attendance\Services\AttendanceMarkingService;
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Participant\Models\Participant;
use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingSession; use App\Domain\Training\Models\TrainingSession;
use Livewire\Attributes\Layout; use Livewire\Attributes\Layout;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
...@@ -87,6 +91,36 @@ public function render() ...@@ -87,6 +91,36 @@ public function render()
} }
} }
// Detect unpaid/unrenewed participants for red highlight
$participantIds = $records
->where('subject_type', Participant::class)
->pluck('subject_id')
->unique()
->toArray();
$notPaidParticipantIds = [];
if ($participantIds) {
$unpaidInvoiceIds = Invoice::where('billable_type', Participant::class)
->whereIn('billable_id', $participantIds)
->whereIn('status', [InvoiceStatus::Sent, InvoiceStatus::Overdue, InvoiceStatus::PartiallyPaid])
->where('due_amount', '>', 0)
->whereNull('deleted_at')
->pluck('billable_id')
->unique()
->toArray();
$overdueEnrollmentIds = Enrollment::whereIn('participant_id', $participantIds)
->where('status', 'active')
->where('training_group_id', $this->session->training_group_id)
->whereNotNull('next_billing_date')
->where('next_billing_date', '<', now()->toDateString())
->pluck('participant_id')
->unique()
->toArray();
$notPaidParticipantIds = array_unique(array_merge($unpaidInvoiceIds, $overdueEnrollmentIds));
}
$summary = [ $summary = [
'total' => $records->count(), 'total' => $records->count(),
'present' => $records->where('status', AttendanceStatus::Present)->count(), 'present' => $records->where('status', AttendanceStatus::Present)->count(),
...@@ -100,6 +134,7 @@ public function render() ...@@ -100,6 +134,7 @@ public function render()
'records' => $records, 'records' => $records,
'summary' => $summary, 'summary' => $summary,
'statuses' => AttendanceStatus::cases(), 'statuses' => AttendanceStatus::cases(),
'notPaidParticipantIds' => $notPaidParticipantIds,
]); ]);
} }
} }
...@@ -101,6 +101,7 @@ class="inline-flex items-center gap-1.5 rounded-md bg-green-50 dark:bg-green-900 ...@@ -101,6 +101,7 @@ class="inline-flex items-center gap-1.5 rounded-md bg-green-50 dark:bg-green-900
@foreach ($records as $record) @foreach ($records as $record)
@php @php
$isPresent = ($marks[$record->id] ?? '') === 'present'; $isPresent = ($marks[$record->id] ?? '') === 'present';
$isUnpaid = in_array($record->subject_id, $notPaidParticipantIds);
$participantName = $record->subject?->person?->name_ar $participantName = $record->subject?->person?->name_ar
?? $record->subject?->person?->name ?? $record->subject?->person?->name
?? __('مشترك'); ?? __('مشترك');
...@@ -108,9 +109,11 @@ class="inline-flex items-center gap-1.5 rounded-md bg-green-50 dark:bg-green-900 ...@@ -108,9 +109,11 @@ class="inline-flex items-center gap-1.5 rounded-md bg-green-50 dark:bg-green-900
<li <li
wire:click="toggleAbsent({{ $record->id }})" wire:click="toggleAbsent({{ $record->id }})"
class="flex items-center justify-between px-4 py-3.5 min-h-[48px] cursor-pointer select-none transition-colors class="flex items-center justify-between px-4 py-3.5 min-h-[48px] cursor-pointer select-none transition-colors
{{ $isPresent {{ $isUnpaid
? 'hover:bg-gray-50 dark:hover:bg-gray-700/50' ? 'bg-red-50 dark:bg-red-900/20 border-s-4 border-red-500'
: 'bg-red-50 dark:bg-red-900/10 hover:bg-red-100 dark:hover:bg-red-900/20' }}" : ($isPresent
? 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
: 'bg-red-50 dark:bg-red-900/10 hover:bg-red-100 dark:hover:bg-red-900/20') }}"
> >
<div class="flex items-center gap-3"> <div class="flex items-center gap-3">
{{-- Status indicator --}} {{-- Status indicator --}}
...@@ -133,6 +136,9 @@ class="flex items-center justify-between px-4 py-3.5 min-h-[48px] cursor-pointer ...@@ -133,6 +136,9 @@ class="flex items-center justify-between px-4 py-3.5 min-h-[48px] cursor-pointer
{{-- Name --}} {{-- Name --}}
<span class="text-sm font-medium {{ $isPresent ? 'text-gray-900 dark:text-white' : 'text-red-700 dark:text-red-300 line-through' }}"> <span class="text-sm font-medium {{ $isPresent ? 'text-gray-900 dark:text-white' : 'text-red-700 dark:text-red-300 line-through' }}">
{{ $participantName }} {{ $participantName }}
@if ($isUnpaid)
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-red-600 text-white ms-1">{{ __('غير مدفوع') }}</span>
@endif
</span> </span>
</div> </div>
......
...@@ -102,11 +102,15 @@ class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-white ...@@ -102,11 +102,15 @@ class="inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium text-white
{{-- Mobile cards --}} {{-- Mobile cards --}}
<div class="md:hidden space-y-2 p-3"> <div class="md:hidden space-y-2 p-3">
@foreach($records as $record) @foreach($records as $record)
<div class="bg-white border border-gray-200 rounded-lg p-3"> @php $isUnpaid = $record->subject_type === \App\Domain\Participant\Models\Participant::class && in_array($record->subject_id, $notPaidParticipantIds); @endphp
<div class="{{ $isUnpaid ? 'bg-red-50 border-red-300' : 'bg-white border-gray-200' }} border rounded-lg p-3">
<div class="flex items-center justify-between mb-2"> <div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-900"> <span class="text-sm font-medium {{ $isUnpaid ? 'text-red-700' : 'text-gray-900' }}">
@if($record->subject_type === \App\Domain\Participant\Models\Participant::class) @if($record->subject_type === \App\Domain\Participant\Models\Participant::class)
{{ $record->subject?->person?->name_ar ?? '-' }} {{ $record->subject?->person?->name_ar ?? '-' }}
@if($isUnpaid)
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-red-600 text-white ms-1">{{ __('غير مدفوع') }}</span>
@endif
@else @else
{{ $record->subject?->name_ar ?? $record->subject?->name ?? '-' }} {{ $record->subject?->name_ar ?? $record->subject?->name ?? '-' }}
@endif @endif
...@@ -172,12 +176,16 @@ class="py-2.5 text-xs font-medium rounded border text-center ...@@ -172,12 +176,16 @@ class="py-2.5 text-xs font-medium rounded border text-center
</thead> </thead>
<tbody class="bg-white divide-y divide-gray-200"> <tbody class="bg-white divide-y divide-gray-200">
@foreach($records as $record) @foreach($records as $record)
<tr class="hover:bg-gray-50"> @php $isUnpaidRow = $record->subject_type === \App\Domain\Participant\Models\Participant::class && in_array($record->subject_id, $notPaidParticipantIds); @endphp
<tr class="{{ $isUnpaidRow ? 'bg-red-50 hover:bg-red-100' : 'hover:bg-gray-50' }}">
<!-- Subject Name --> <!-- Subject Name -->
<td class="px-4 py-3 whitespace-nowrap"> <td class="px-4 py-3 whitespace-nowrap">
<span class="text-sm font-medium text-gray-900"> <span class="text-sm font-medium {{ $isUnpaidRow ? 'text-red-700' : 'text-gray-900' }}">
@if($record->subject_type === \App\Domain\Participant\Models\Participant::class) @if($record->subject_type === \App\Domain\Participant\Models\Participant::class)
{{ $record->subject?->person?->name_ar ?? '-' }} {{ $record->subject?->person?->name_ar ?? '-' }}
@if($isUnpaidRow)
<span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-bold bg-red-600 text-white ms-1">{{ __('غير مدفوع') }}</span>
@endif
@else @else
{{ $record->subject?->name_ar ?? $record->subject?->name ?? '-' }} {{ $record->subject?->name_ar ?? $record->subject?->name ?? '-' }}
@endif @endif
......
...@@ -440,7 +440,127 @@ ...@@ -440,7 +440,127 @@
</div> </div>
@endif @endif
{{-- Row 6: Collection Rate Progress --}} {{-- Row 6: Revenue Breakdown (Subscriptions vs Products) --}}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3 sm:gap-4 mb-4 sm:mb-6">
{{-- Subscription Revenue --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm sm:text-base font-semibold text-gray-800">{{ __('إيرادات الاشتراكات') }}</h3>
<span class="text-lg font-bold text-emerald-600" dir="ltr">{{ number_format($revenueBreakdown['subscription_revenue'] / 100, 0) }} {{ __('ج.م') }}</span>
</div>
@if(count($revenueBreakdown['top_programs']) > 0)
<p class="text-xs text-gray-400 mb-3">{{ __('أعلى البرامج إيراداً') }}</p>
<div class="space-y-2.5">
@php $maxProgram = max(1, collect($revenueBreakdown['top_programs'])->max('total')); @endphp
@foreach($revenueBreakdown['top_programs'] as $program)
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-600 truncate flex-1 me-2">{{ $program->program_name }}</span>
<span class="font-medium whitespace-nowrap" dir="ltr">{{ number_format($program->total / 100, 0) }} {{ __('ج.م') }}
<span class="text-gray-400 text-xs">({{ $program->subscriber_count }} {{ __('مشترك') }})</span>
</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-1.5">
<div class="bg-emerald-500 h-1.5 rounded-full" style="width: {{ ($program->total / $maxProgram) * 100 }}%"></div>
</div>
</div>
@endforeach
</div>
@else
<div class="text-center py-6">
<p class="text-sm text-gray-500">{{ __('لا توجد بيانات اشتراكات في هذه الفترة') }}</p>
</div>
@endif
</div>
{{-- Product Revenue --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm sm:text-base font-semibold text-gray-800">{{ __('إيرادات المنتجات') }}</h3>
<span class="text-lg font-bold text-blue-600" dir="ltr">{{ number_format($revenueBreakdown['product_revenue'] / 100, 0) }} {{ __('ج.م') }}</span>
</div>
@if(count($revenueBreakdown['by_product']) > 0)
<p class="text-xs text-gray-400 mb-3">{{ __('تفصيل حسب المنتج') }}</p>
<div class="space-y-2.5">
@php $maxProduct = max(1, collect($revenueBreakdown['by_product'])->max('total')); @endphp
@foreach($revenueBreakdown['by_product'] as $product)
<div>
<div class="flex justify-between text-sm mb-1">
<span class="text-gray-600 truncate flex-1 me-2">{{ $product->product_name }}</span>
<span class="font-medium whitespace-nowrap" dir="ltr">{{ number_format($product->total / 100, 0) }} {{ __('ج.م') }}
<span class="text-gray-400 text-xs">({{ (int) $product->units_sold }} {{ __('وحدة') }})</span>
</span>
</div>
<div class="w-full bg-gray-100 rounded-full h-1.5">
<div class="bg-blue-500 h-1.5 rounded-full" style="width: {{ ($product->total / $maxProduct) * 100 }}%"></div>
</div>
</div>
@endforeach
</div>
@else
<div class="text-center py-6">
<p class="text-sm text-gray-500">{{ __('لا توجد مبيعات منتجات في هذه الفترة') }}</p>
</div>
@endif
</div>
</div>
{{-- Row 7: Key Financial Metrics --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6 mb-4 sm:mb-6">
<h3 class="text-sm sm:text-base font-semibold text-gray-800 mb-4">{{ __('مؤشرات مالية') }}</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3 sm:gap-4">
<div class="p-3 bg-gray-50 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">{{ __('مشتركين نشطين') }}</p>
<p class="text-xl font-bold text-gray-800" dir="ltr">{{ number_format($financialMetrics['active_participants']) }}</p>
</div>
<div class="p-3 bg-green-50 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">{{ __('مشتركين جدد') }}</p>
<p class="text-xl font-bold text-green-700" dir="ltr">+{{ number_format($financialMetrics['new_participants']) }}</p>
</div>
<div class="p-3 bg-blue-50 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">{{ __('اشتراكات نشطة') }}</p>
<p class="text-xl font-bold text-blue-700" dir="ltr">{{ number_format($financialMetrics['active_enrollments']) }}</p>
</div>
<div class="p-3 bg-indigo-50 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">{{ __('اشتراكات جديدة') }}</p>
<p class="text-xl font-bold text-indigo-700" dir="ltr">+{{ number_format($financialMetrics['new_enrollments']) }}</p>
</div>
<div class="p-3 bg-red-50 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">{{ __('إلغاءات') }}</p>
<p class="text-xl font-bold text-red-600" dir="ltr">{{ number_format($financialMetrics['cancelled_enrollments']) }}</p>
</div>
<div class="p-3 bg-orange-50 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">{{ __('معدل الإلغاء') }}</p>
<p class="text-xl font-bold text-orange-600" dir="ltr">{{ $financialMetrics['churn_rate'] }}%</p>
</div>
<div class="p-3 bg-emerald-50 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">{{ __('متوسط إيراد/مشترك') }}</p>
<p class="text-lg font-bold text-emerald-700" dir="ltr">{{ number_format($financialMetrics['avg_revenue_per_participant'] / 100, 0) }} {{ __('ج.م') }}</p>
</div>
<div class="p-3 bg-teal-50 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">{{ __('متوسط يومي') }}</p>
<p class="text-lg font-bold text-teal-700" dir="ltr">{{ number_format($financialMetrics['daily_avg_revenue'] / 100, 0) }} {{ __('ج.م') }}</p>
</div>
<div class="p-3 bg-amber-50 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">{{ __('إجمالي المستحق') }}</p>
<p class="text-lg font-bold text-amber-700" dir="ltr">{{ number_format($financialMetrics['total_outstanding'] / 100, 0) }} {{ __('ج.م') }}</p>
</div>
<div class="p-3 bg-violet-50 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">{{ __('متوسط الفاتورة') }}</p>
<p class="text-lg font-bold text-violet-700" dir="ltr">{{ number_format($financialMetrics['avg_invoice'] / 100, 0) }} {{ __('ج.م') }}</p>
</div>
<div class="p-3 {{ $financialMetrics['profit_margin'] >= 0 ? 'bg-emerald-50' : 'bg-red-50' }} rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">{{ __('هامش الربح') }}</p>
<p class="text-xl font-bold {{ $financialMetrics['profit_margin'] >= 0 ? 'text-emerald-700' : 'text-red-700' }}" dir="ltr">{{ $financialMetrics['profit_margin'] }}%</p>
</div>
<div class="p-3 bg-sky-50 rounded-lg text-center">
<p class="text-xs text-gray-500 mb-1">{{ __('متوسط أيام التحصيل') }}</p>
<p class="text-xl font-bold text-sky-700" dir="ltr">{{ $financialMetrics['avg_days_to_collect'] }} {{ __('يوم') }}</p>
</div>
</div>
</div>
{{-- Row 8: Collection Rate Progress --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6"> <div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h3 class="text-sm sm:text-base font-semibold text-gray-800 mb-4">{{ __('معدل تحصيل الفواتير') }}</h3> <h3 class="text-sm sm:text-base font-semibold text-gray-800 mb-4">{{ __('معدل تحصيل الفواتير') }}</h3>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4 sm:gap-6"> <div class="grid grid-cols-2 md:grid-cols-3 gap-4 sm:gap-6">
......
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