Commit ea44efee authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add product revenue analysis widget to dashboard

Pro-rata allocation from invoice_items where itemable_type = Product.
Shows total collected from products, % of total revenue, units sold,
and per-product breakdown with actual collections (not invoice totals).
Handles bundled invoices and installment payments correctly.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 805bfc36
<?php
namespace App\Livewire\Dashboard;
use App\Domain\Financial\Models\Payment;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
class ProductRevenueWidget 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;
$productType = 'App\\Domain\\Inventory\\Models\\Product';
// Current period product revenue (pro-rata)
$totalProductRevenue = $this->getProductRevenue($startDate, null, $academyId, $productType);
// Previous period
$previousProductRevenue = $this->getProductRevenue($previousStart, $startDate, $academyId, $productType);
$change = $previousProductRevenue > 0
? round(($totalProductRevenue - $previousProductRevenue) / $previousProductRevenue * 100, 1)
: ($totalProductRevenue > 0 ? 100 : 0);
// Revenue per product
$byProduct = $this->getRevenueByProduct($startDate, $academyId, $productType);
// Total confirmed payments this period
$totalRevenue = Payment::where('status', 'confirmed')
->where('created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('academy_id', $academyId))
->sum('amount');
$productPercent = $totalRevenue > 0
? round($totalProductRevenue / $totalRevenue * 100, 1)
: 0;
// Total units sold this period
$unitsSold = DB::table('invoice_items')
->join('invoices', 'invoice_items.invoice_id', '=', 'invoices.id')
->join('payments', 'payments.invoice_id', '=', 'invoices.id')
->where('invoice_items.itemable_type', $productType)
->where('payments.status', 'confirmed')
->where('payments.created_at', '>=', $startDate)
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->sum('invoice_items.quantity');
return view('livewire.dashboard.product-revenue-widget', [
'totalProductRevenue' => $totalProductRevenue,
'change' => $change,
'byProduct' => $byProduct,
'productPercent' => $productPercent,
'totalRevenue' => $totalRevenue,
'unitsSold' => $unitsSold,
]);
}
private function getProductRevenue($from, $to, $academyId, string $productType): int
{
$result = DB::table('payments')
->join('invoices', 'payments.invoice_id', '=', 'invoices.id')
->joinSub(
DB::table('invoice_items')
->where('itemable_type', $productType)
->select('invoice_id', DB::raw('SUM(total_amount) as product_total'))
->groupBy('invoice_id'),
'prod_items',
'prod_items.invoice_id', '=', 'invoices.id'
)
->where('payments.status', 'confirmed')
->where('invoices.total_amount', '>', 0)
->where('payments.created_at', '>=', $from)
->when($to, fn ($q) => $q->where('payments.created_at', '<', $to))
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->select(
DB::raw('SUM(payments.amount * prod_items.product_total / invoices.total_amount) as product_revenue')
)
->value('product_revenue');
return (int) ($result ?? 0);
}
private function getRevenueByProduct($from, $academyId, string $productType)
{
return DB::table('payments')
->join('invoices', 'payments.invoice_id', '=', 'invoices.id')
->join('invoice_items', function ($j) use ($productType) {
$j->on('invoice_items.invoice_id', '=', 'invoices.id')
->where('invoice_items.itemable_type', '=', $productType);
})
->join('products', 'invoice_items.itemable_id', '=', 'products.id')
->where('payments.status', 'confirmed')
->where('invoices.total_amount', '>', 0)
->where('payments.created_at', '>=', $from)
->when($academyId, fn ($q) => $q->where('payments.academy_id', $academyId))
->select(
'products.id as product_id',
'products.name_ar as product_name',
DB::raw('SUM(payments.amount * invoice_items.total_amount / invoices.total_amount) as total'),
DB::raw('SUM(invoice_items.quantity) as units_sold'),
DB::raw('COUNT(DISTINCT invoices.id) as invoice_count'),
)
->groupBy('products.id', 'products.name_ar')
->orderByDesc('total')
->limit(15)
->get();
}
}
...@@ -191,6 +191,13 @@ ...@@ -191,6 +191,13 @@
</div> </div>
@endcan @endcan
<!-- Row 2.6: Product Revenue Analysis -->
@can('invoices.list')
<div class="mb-6">
<livewire:dashboard.product-revenue-widget />
</div>
@endcan
<!-- Row 2.6: Financial Widgets --> <!-- 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">
......
<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>
<!-- Stats Row -->
<div class="grid grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<!-- Total Product Revenue -->
<div class="bg-gradient-to-br from-orange-50 to-amber-50 rounded-xl border border-orange-200 p-4">
<p class="text-xs text-orange-600 font-medium mb-1">{{ __('إجمالي تحصيلات المنتجات') }}</p>
<p class="text-xl font-bold text-orange-700" dir="ltr">{{ number_format($totalProductRevenue / 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>
<!-- Product % 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">{{ $productPercent }}%</p>
<p class="text-xs text-gray-500 mt-1" dir="ltr">
{{ __('من') }} {{ number_format($totalRevenue / 100, 0) }} {{ __('ج.م') }}
</p>
</div>
<!-- Units Sold -->
<div class="bg-gradient-to-br from-teal-50 to-cyan-50 rounded-xl border border-teal-200 p-4">
<p class="text-xs text-teal-600 font-medium mb-1">{{ __('وحدات مباعة') }}</p>
<p class="text-xl font-bold text-teal-700" dir="ltr">{{ number_format($unitsSold) }}</p>
<p class="text-xs text-gray-500 mt-1">{{ __('قطعة هذه الفترة') }}</p>
</div>
</div>
<!-- Products Table -->
<div>
<h4 class="text-sm font-bold text-gray-700 mb-3 flex items-center gap-2">
<svg class="w-4 h-4 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>
{{ __('تحصيلات كل منتج') }}
</h4>
@if($byProduct->isEmpty())
<div class="text-center py-6">
<p class="text-gray-400 text-sm">{{ __('لا توجد مبيعات منتجات في هذه الفترة') }}</p>
</div>
@else
<div class="space-y-2">
@php $maxTotal = $byProduct->max('total') ?: 1; @endphp
@foreach($byProduct as $product)
<div class="flex items-center gap-3 p-3 rounded-lg bg-gray-50">
<div class="shrink-0 w-9 h-9 rounded-lg {{ match($loop->index % 5) { 0 => 'bg-orange-100 text-orange-700', 1 => 'bg-blue-100 text-blue-700', 2 => 'bg-teal-100 text-teal-700', 3 => 'bg-purple-100 text-purple-700', 4 => 'bg-pink-100 text-pink-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">
<div class="flex items-center justify-between mb-1">
<p class="text-sm font-medium text-gray-800 truncate">{{ $product->product_name }}</p>
<span class="text-sm font-bold text-gray-800 whitespace-nowrap ms-2" dir="ltr">{{ number_format($product->total / 100, 0) }} {{ __('ج.م') }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-xs text-gray-500">{{ $product->units_sold }} {{ __('قطعة') }} &mdash; {{ $product->invoice_count }} {{ __('فاتورة') }}</span>
</div>
<div class="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden mt-1.5">
<div class="h-full rounded-full transition-all duration-500 {{ match($loop->index % 5) { 0 => 'bg-orange-500', 1 => 'bg-blue-500', 2 => 'bg-teal-500', 3 => 'bg-purple-500', 4 => 'bg-pink-500', default => 'bg-gray-500' } }}"
style="width: {{ round($product->total / $maxTotal * 100) }}%"></div>
</div>
</div>
</div>
@endforeach
</div>
@endif
</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