Commit 0ec2da41 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add subscription revenue analysis widget to dashboard

Full breakdown of اشتراكات revenue: total amount, % of total income,
per-activity breakdown with bar charts, top 10 programs ranked by revenue,
new enrollment count, average revenue per enrollment, and period comparison
(today/week/month/year). Uses enrollment→invoice→payment chain for accuracy.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent d8891e44
<?php
namespace App\Livewire\Dashboard;
use App\Domain\Financial\Models\Payment;
use App\Domain\Training\Models\Activity;
use App\Domain\Training\Models\Enrollment;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class SubscriptionRevenueWidget extends Component
{
public string $period = 'month';
public function render()
{
$startDate = match ($this->period) {
'today' => now()->startOfDay(),
'week' => now()->startOfWeek(),
'month' => now()->startOfMonth(),
'year' => now()->startOfYear(),
default => now()->startOfMonth(),
};
$previousStart = match ($this->period) {
'today' => now()->subDay()->startOfDay(),
'week' => now()->subWeek()->startOfWeek(),
'month' => now()->subMonth()->startOfMonth(),
'year' => now()->subYear()->startOfYear(),
default => now()->subMonth()->startOfMonth(),
};
$academyId = app('current_academy')?->id;
// Total subscription revenue (via enrollments → invoices → payments)
$totalSubscriptionRevenue = DB::table('payments')
->join('invoices', 'payments.invoice_id', '=', 'invoices.id')
->join('enrollments', 'enrollments.invoice_id', '=', 'invoices.id')
->where('payments.status', 'confirmed')
->where('payments.created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->sum('payments.amount');
// Previous period for comparison
$previousSubscriptionRevenue = DB::table('payments')
->join('invoices', 'payments.invoice_id', '=', 'invoices.id')
->join('enrollments', 'enrollments.invoice_id', '=', 'invoices.id')
->where('payments.status', 'confirmed')
->whereBetween('payments.created_at', [$previousStart, $startDate])
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->sum('payments.amount');
$change = $previousSubscriptionRevenue > 0
? round(($totalSubscriptionRevenue - $previousSubscriptionRevenue) / $previousSubscriptionRevenue * 100, 1)
: ($totalSubscriptionRevenue > 0 ? 100 : 0);
// Revenue breakdown by activity
$byActivity = DB::table('payments')
->join('invoices', 'payments.invoice_id', '=', 'invoices.id')
->join('enrollments', 'enrollments.invoice_id', '=', 'invoices.id')
->join('training_programs', 'enrollments.training_program_id', '=', 'training_programs.id')
->join('activities', 'training_programs.activity_id', '=', 'activities.id')
->where('payments.status', 'confirmed')
->where('payments.created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->select(
'activities.id as activity_id',
'activities.name_ar as activity_name',
'activities.category',
DB::raw('SUM(payments.amount) as total'),
DB::raw('COUNT(DISTINCT enrollments.id) as enrollment_count'),
)
->groupBy('activities.id', 'activities.name_ar', 'activities.category')
->orderByDesc('total')
->get();
// Top programs (across all activities)
$topPrograms = DB::table('payments')
->join('invoices', 'payments.invoice_id', '=', 'invoices.id')
->join('enrollments', 'enrollments.invoice_id', '=', 'invoices.id')
->join('training_programs', 'enrollments.training_program_id', '=', 'training_programs.id')
->join('activities', 'training_programs.activity_id', '=', 'activities.id')
->where('payments.status', 'confirmed')
->where('payments.created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->select(
'training_programs.id as program_id',
'training_programs.name_ar as program_name',
'activities.name_ar as activity_name',
DB::raw('SUM(payments.amount) as total'),
DB::raw('COUNT(DISTINCT enrollments.id) as enrollment_count'),
)
->groupBy('training_programs.id', 'training_programs.name_ar', 'activities.name_ar')
->orderByDesc('total')
->limit(10)
->get();
// New enrollments count this period
$newEnrollments = Enrollment::where('created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('academy_id', $academyId))
->count();
$previousEnrollments = Enrollment::whereBetween('created_at', [$previousStart, $startDate])
->when($academyId, fn ($q) => $q->where('academy_id', $academyId))
->count();
// Total confirmed payments this period (to compute subscription %)
$totalRevenue = Payment::where('status', 'confirmed')
->where('created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('academy_id', $academyId))
->sum('amount');
$subscriptionPercent = $totalRevenue > 0
? round($totalSubscriptionRevenue / $totalRevenue * 100, 1)
: 0;
return view('livewire.dashboard.subscription-revenue-widget', [
'totalSubscriptionRevenue' => $totalSubscriptionRevenue,
'change' => $change,
'byActivity' => $byActivity,
'topPrograms' => $topPrograms,
'newEnrollments' => $newEnrollments,
'previousEnrollments' => $previousEnrollments,
'subscriptionPercent' => $subscriptionPercent,
'totalRevenue' => $totalRevenue,
]);
}
}
...@@ -184,7 +184,14 @@ ...@@ -184,7 +184,14 @@
</div> </div>
@endif @endif
<!-- Row 2.5: Financial Widgets --> <!-- Row 2.5: Subscription Revenue Analysis -->
@can('invoices.list')
<div class="mb-6">
<livewire:dashboard.subscription-revenue-widget />
</div>
@endcan
<!-- Row 2.6: Financial Widgets -->
@can('payroll.manage') @can('payroll.manage')
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-4 mb-6">
<livewire:dashboard.trainer-dues-widget /> <livewire:dashboard.trainer-dues-widget />
......
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-5">
<!-- Header -->
<div class="flex items-center justify-between mb-5">
<div>
<h3 class="text-lg font-bold text-gray-800">{{ __('إيرادات الاشتراكات') }}</h3>
<p class="text-xs text-gray-500 mt-0.5">{{ __('الأموال المحصّلة من تسجيل اللاعبين في البرامج') }}</p>
</div>
<select wire:model.live="period" class="text-sm border-gray-300 rounded-lg py-1.5 px-3 focus:ring-blue-500 focus:border-blue-500">
<option value="today">{{ __('اليوم') }}</option>
<option value="week">{{ __('هذا الأسبوع') }}</option>
<option value="month">{{ __('هذا الشهر') }}</option>
<option value="year">{{ __('هذه السنة') }}</option>
</select>
</div>
<!-- Main Stats Row -->
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<!-- Total Subscription Revenue -->
<div class="bg-gradient-to-br from-emerald-50 to-teal-50 rounded-xl border border-emerald-200 p-4">
<p class="text-xs text-emerald-600 font-medium mb-1">{{ __('إجمالي الاشتراكات') }}</p>
<p class="text-xl font-bold text-emerald-700" dir="ltr">{{ number_format($totalSubscriptionRevenue / 100, 0) }} <span class="text-sm font-normal">{{ __('ج.م') }}</span></p>
@if($change != 0)
<div class="flex items-center gap-1 mt-1">
@if($change > 0)
<svg class="w-3.5 h-3.5 text-green-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z" clip-rule="evenodd"/></svg>
@else
<svg class="w-3.5 h-3.5 text-red-500" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>
@endif
<span class="text-xs {{ $change > 0 ? 'text-green-600' : 'text-red-600' }}">{{ abs($change) }}%</span>
</div>
@endif
</div>
<!-- Subscription % of Total Revenue -->
<div class="bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl border border-blue-200 p-4">
<p class="text-xs text-blue-600 font-medium mb-1">{{ __('نسبة من الإيراد الكلي') }}</p>
<p class="text-xl font-bold text-blue-700" dir="ltr">{{ $subscriptionPercent }}%</p>
<p class="text-xs text-gray-500 mt-1" dir="ltr">
{{ __('من') }} {{ number_format($totalRevenue / 100, 0) }} {{ __('ج.م') }}
</p>
</div>
<!-- New Enrollments -->
<div class="bg-gradient-to-br from-purple-50 to-violet-50 rounded-xl border border-purple-200 p-4">
<p class="text-xs text-purple-600 font-medium mb-1">{{ __('اشتراكات جديدة') }}</p>
<p class="text-xl font-bold text-purple-700" dir="ltr">{{ number_format($newEnrollments) }}</p>
@if($previousEnrollments > 0)
<p class="text-xs text-gray-500 mt-1">
{{ __('الفترة السابقة:') }} {{ number_format($previousEnrollments) }}
</p>
@endif
</div>
<!-- Average per Enrollment -->
<div class="bg-gradient-to-br from-amber-50 to-orange-50 rounded-xl border border-amber-200 p-4">
<p class="text-xs text-amber-600 font-medium mb-1">{{ __('متوسط الاشتراك') }}</p>
<p class="text-xl font-bold text-amber-700" dir="ltr">
@if($newEnrollments > 0)
{{ number_format($totalSubscriptionRevenue / $newEnrollments / 100, 0) }} <span class="text-sm font-normal">{{ __('ج.م') }}</span>
@else
0
@endif
</p>
<p class="text-xs text-gray-500 mt-1">{{ __('لكل اشتراك') }}</p>
</div>
</div>
<!-- Two Columns: By Activity + Top Programs -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left: Revenue by Activity -->
<div>
<h4 class="text-sm font-bold text-gray-700 mb-3 flex items-center gap-2">
<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/></svg>
{{ __('الإيرادات حسب النشاط') }}
</h4>
@if($byActivity->isEmpty())
<div class="text-center py-6">
<p class="text-gray-400 text-sm">{{ __('لا توجد اشتراكات في هذه الفترة') }}</p>
</div>
@else
<div class="space-y-3">
@php $maxTotal = $byActivity->max('total') ?: 1; @endphp
@foreach($byActivity as $activity)
<div>
<div class="flex items-center justify-between mb-1">
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full {{ match($loop->index % 6) { 0 => 'bg-emerald-500', 1 => 'bg-blue-500', 2 => 'bg-purple-500', 3 => 'bg-amber-500', 4 => 'bg-pink-500', 5 => 'bg-cyan-500', default => 'bg-gray-500' } }}"></span>
<span class="text-sm font-medium text-gray-700">{{ $activity->activity_name }}</span>
<span class="text-xs text-gray-400">({{ $activity->enrollment_count }} {{ __('اشتراك') }})</span>
</div>
<span class="text-sm font-bold text-gray-800" dir="ltr">{{ number_format($activity->total / 100, 0) }} {{ __('ج.م') }}</span>
</div>
<div class="w-full h-2.5 bg-gray-100 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500 {{ match($loop->index % 6) { 0 => 'bg-emerald-500', 1 => 'bg-blue-500', 2 => 'bg-purple-500', 3 => 'bg-amber-500', 4 => 'bg-pink-500', 5 => 'bg-cyan-500', default => 'bg-gray-500' } }}"
style="width: {{ round($activity->total / $maxTotal * 100) }}%"></div>
</div>
</div>
@endforeach
</div>
@endif
</div>
<!-- Right: Top Programs -->
<div>
<h4 class="text-sm font-bold text-gray-700 mb-3 flex items-center gap-2">
<svg class="w-4 h-4 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/></svg>
{{ __('أعلى البرامج إيراداً') }}
</h4>
@if($topPrograms->isEmpty())
<div class="text-center py-6">
<p class="text-gray-400 text-sm">{{ __('لا توجد بيانات') }}</p>
</div>
@else
<div class="space-y-2">
@foreach($topPrograms as $program)
<div class="flex items-center gap-3 p-2.5 rounded-lg bg-gray-50 hover:bg-gray-100 transition-colors">
<div class="shrink-0 w-8 h-8 rounded-lg {{ match($loop->index % 4) { 0 => 'bg-emerald-100 text-emerald-700', 1 => 'bg-blue-100 text-blue-700', 2 => 'bg-purple-100 text-purple-700', 3 => 'bg-amber-100 text-amber-700', default => 'bg-gray-100 text-gray-700' } }} flex items-center justify-center text-xs font-bold">
{{ $loop->iteration }}
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800 truncate">{{ $program->program_name }}</p>
<p class="text-xs text-gray-500">{{ $program->activity_name }} &mdash; {{ $program->enrollment_count }} {{ __('اشتراك') }}</p>
</div>
<span class="text-sm font-bold text-gray-700 whitespace-nowrap" dir="ltr">{{ number_format($program->total / 100, 0) }} {{ __('ج.م') }}</span>
</div>
@endforeach
</div>
@endif
</div>
</div>
</div>
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