Commit ca341bc6 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add payment method comparison summary panel to invoices list

Adds a live summary panel above the invoices table showing total payments
broken down by method for a configurable date range (default: this month).
Includes two big comparison cards (selected method vs all others), a full
horizontal mini-bar breakdown for every method, and preset range shortcuts.
Co-Authored-By: 's avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 85874f1c
......@@ -3,7 +3,9 @@
namespace App\Livewire\Invoices;
use App\Domain\Financial\Enums\InvoiceStatus;
use App\Domain\Financial\Enums\PaymentMethod;
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Traits\UsesBranchScope;
use App\Livewire\Concerns\AppliesRoleScope;
......@@ -27,14 +29,48 @@ class InvoiceList extends Component
#[Url]
public string $status = '';
public function updatedSearch(): void
// Payment summary panel
#[Url]
public string $summaryFrom = '';
#[Url]
public string $summaryTo = '';
#[Url]
public string $primaryMethod = 'cash';
public function mount(): void
{
$this->authorize('invoices.list');
if ($this->summaryFrom === '') {
$this->summaryFrom = now()->startOfMonth()->format('Y-m-d');
}
if ($this->summaryTo === '') {
$this->summaryTo = now()->endOfMonth()->format('Y-m-d');
}
}
public function setSummaryPreset(string $preset): void
{
$this->resetPage();
match ($preset) {
'today' => [$this->summaryFrom, $this->summaryTo] = [now()->toDateString(), now()->toDateString()],
'this_week' => [$this->summaryFrom, $this->summaryTo] = [now()->startOfWeek()->toDateString(), now()->endOfWeek()->toDateString()],
'this_month' => [$this->summaryFrom, $this->summaryTo] = [now()->startOfMonth()->toDateString(), now()->endOfMonth()->toDateString()],
'last_month' => [$this->summaryFrom, $this->summaryTo] = [now()->subMonthNoOverflow()->startOfMonth()->toDateString(), now()->subMonthNoOverflow()->endOfMonth()->toDateString()],
'this_year' => [$this->summaryFrom, $this->summaryTo] = [now()->startOfYear()->toDateString(), now()->endOfYear()->toDateString()],
default => null,
};
}
public function updatedStatus(): void
public function updatedSearch(): void { $this->resetPage(); }
public function updatedStatus(): void { $this->resetPage(); }
private function buildSummaryQuery(): \Illuminate\Database\Eloquent\Builder
{
$this->resetPage();
return Payment::query()
->whereBetween('payment_date', [$this->summaryFrom, $this->summaryTo])
->where('status', 'confirmed')
->where('direction', 'inbound');
}
public function render()
......@@ -55,11 +91,45 @@ public function render()
$this->applyRoleScope($query);
// Payment breakdown by method for summary panel
$breakdownRaw = $this->buildSummaryQuery()
->selectRaw('method, SUM(amount) as total, COUNT(*) as count')
->groupBy('method')
->get()
->keyBy('method');
$allMethods = PaymentMethod::cases();
$breakdown = collect($allMethods)->map(function (PaymentMethod $m) use ($breakdownRaw) {
$row = $breakdownRaw->get($m->value);
return [
'value' => $m->value,
'label' => $m->label(),
'total' => $row?->total ?? 0,
'count' => $row?->count ?? 0,
];
});
$primaryTotal = $breakdown->firstWhere('value', $this->primaryMethod)['total'] ?? 0;
$primaryCount = $breakdown->firstWhere('value', $this->primaryMethod)['count'] ?? 0;
$otherTotal = $breakdown->where('value', '!=', $this->primaryMethod)->sum('total');
$otherCount = $breakdown->where('value', '!=', $this->primaryMethod)->sum('count');
$grandTotal = $primaryTotal + $otherTotal;
return view('livewire.invoices.invoice-list', [
'invoices' => $query->paginate(20),
'statusOptions' => collect(InvoiceStatus::cases())->mapWithKeys(
fn (InvoiceStatus $s) => [$s->value => $s->label()]
)->toArray(),
'methodOptions' => collect($allMethods)->mapWithKeys(
fn (PaymentMethod $m) => [$m->value => $m->label()]
)->toArray(),
'breakdown' => $breakdown,
'primaryTotal' => $primaryTotal,
'primaryCount' => $primaryCount,
'otherTotal' => $otherTotal,
'otherCount' => $otherCount,
'grandTotal' => $grandTotal,
]);
}
}
......@@ -17,6 +17,148 @@ class="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-l
</div>
</div>
{{-- Payment Summary Panel --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
{{-- Controls row --}}
<div class="flex flex-wrap items-center gap-2 mb-4">
<span class="text-sm font-semibold text-gray-700">{{ __('ملخص المدفوعات') }}</span>
{{-- Date presets --}}
@php
$presets = ['today' => 'اليوم', 'this_week' => 'هذا الأسبوع', 'this_month' => 'هذا الشهر', 'last_month' => 'الشهر الماضي', 'this_year' => 'هذا العام'];
$presetStarts = [
'today' => now()->toDateString(),
'this_week' => now()->startOfWeek()->toDateString(),
'this_month' => now()->startOfMonth()->toDateString(),
'last_month' => now()->subMonthNoOverflow()->startOfMonth()->toDateString(),
'this_year' => now()->startOfYear()->toDateString(),
];
@endphp
@foreach($presets as $preset => $presetLabel)
@php $active = ($summaryFrom === $presetStarts[$preset]); @endphp
<button type="button" wire:click="setSummaryPreset('{{ $preset }}')"
class="px-2.5 py-1 text-xs rounded-full border transition-colors {{ $active ? 'bg-blue-600 text-white border-blue-600' : 'bg-gray-50 text-gray-600 border-gray-200 hover:border-blue-300 hover:text-blue-600' }}">
{{ $presetLabel }}
</button>
@endforeach
{{-- Custom range --}}
<input type="date" wire:model.live="summaryFrom" dir="ltr"
class="text-xs border border-gray-300 rounded-lg px-2 py-1 focus:ring-2 focus:ring-blue-400 focus:border-blue-400">
<span class="text-gray-400 text-xs"></span>
<input type="date" wire:model.live="summaryTo" dir="ltr"
class="text-xs border border-gray-300 rounded-lg px-2 py-1 focus:ring-2 focus:ring-blue-400 focus:border-blue-400">
{{-- Method selector --}}
<div class="flex items-center gap-1.5 me-auto">
<span class="text-xs text-gray-500">{{ __('مقارنة:') }}</span>
<select wire:model.live="primaryMethod"
class="text-xs border border-gray-300 rounded-lg px-2 py-1 focus:ring-2 focus:ring-blue-400 focus:border-blue-400 bg-white">
@foreach($methodOptions as $val => $lbl)
<option value="{{ $val }}">{{ $lbl }}</option>
@endforeach
</select>
<span class="text-xs text-gray-500">{{ __('مقابل الباقي') }}</span>
</div>
</div>
{{-- Comparison cards --}}
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-4" wire:loading.class="opacity-50">
{{-- Primary method card --}}
@php
$methodColors = [
'cash' => ['bg' => 'bg-green-50', 'border' => 'border-green-200', 'text' => 'text-green-700', 'amount' => 'text-green-800', 'badge' => 'bg-green-100 text-green-700'],
'card' => ['bg' => 'bg-blue-50', 'border' => 'border-blue-200', 'text' => 'text-blue-700', 'amount' => 'text-blue-800', 'badge' => 'bg-blue-100 text-blue-700'],
'bank_transfer' => ['bg' => 'bg-indigo-50', 'border' => 'border-indigo-200', 'text' => 'text-indigo-700', 'amount' => 'text-indigo-800', 'badge' => 'bg-indigo-100 text-indigo-700'],
'wallet' => ['bg' => 'bg-purple-50', 'border' => 'border-purple-200', 'text' => 'text-purple-700', 'amount' => 'text-purple-800', 'badge' => 'bg-purple-100 text-purple-700'],
'online' => ['bg' => 'bg-cyan-50', 'border' => 'border-cyan-200', 'text' => 'text-cyan-700', 'amount' => 'text-cyan-800', 'badge' => 'bg-cyan-100 text-cyan-700'],
'cheque' => ['bg' => 'bg-amber-50', 'border' => 'border-amber-200', 'text' => 'text-amber-700', 'amount' => 'text-amber-800', 'badge' => 'bg-amber-100 text-amber-700'],
'other' => ['bg' => 'bg-gray-50', 'border' => 'border-gray-200', 'text' => 'text-gray-600', 'amount' => 'text-gray-700', 'badge' => 'bg-gray-100 text-gray-600'],
];
$pc = $methodColors[$primaryMethod] ?? $methodColors['other'];
$primaryLabel = $methodOptions[$primaryMethod] ?? $primaryMethod;
$primaryPct = $grandTotal > 0 ? round($primaryTotal / $grandTotal * 100) : 0;
$otherPct = 100 - $primaryPct;
@endphp
<div class="rounded-xl border {{ $pc['border'] }} {{ $pc['bg'] }} p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold {{ $pc['text'] }}">{{ $primaryLabel }}</span>
<span class="text-xs px-2 py-0.5 rounded-full {{ $pc['badge'] }}">{{ $primaryPct }}%</span>
</div>
<p class="text-2xl font-bold {{ $pc['amount'] }}" dir="ltr">
{{ number_format($primaryTotal / 100, 2) }}
<span class="text-base font-medium">{{ __('ج.م') }}</span>
</p>
<p class="text-xs {{ $pc['text'] }} mt-1 opacity-75">{{ $primaryCount }} {{ __('مدفوعة') }}</p>
</div>
{{-- Divider / vs --}}
<div class="hidden sm:flex flex-col items-center justify-center">
<div class="w-px flex-1 bg-gray-200"></div>
<span class="text-xs font-bold text-gray-400 py-2 px-3 bg-white border border-gray-200 rounded-full">VS</span>
<div class="w-px flex-1 bg-gray-200"></div>
</div>
{{-- Other methods card --}}
<div class="rounded-xl border border-gray-200 bg-gray-50 p-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-semibold text-gray-600">{{ __('باقي الطرق') }}</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-200 text-gray-600">{{ $otherPct }}%</span>
</div>
<p class="text-2xl font-bold text-gray-700" dir="ltr">
{{ number_format($otherTotal / 100, 2) }}
<span class="text-base font-medium">{{ __('ج.م') }}</span>
</p>
<p class="text-xs text-gray-500 mt-1">{{ $otherCount }} {{ __('مدفوعة') }}</p>
</div>
</div>
{{-- Full breakdown bar --}}
@php
$maxBreakdown = $breakdown->max('total') ?: 1;
@endphp
<div class="border-t border-gray-100 pt-3" wire:loading.class="opacity-50">
<p class="text-xs font-medium text-gray-500 mb-2">{{ __('تفصيل كل الطرق') }}</p>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-2">
@foreach($breakdown->sortByDesc('total') as $method)
@if($method['total'] > 0)
@php
$mc = $methodColors[$method['value']] ?? $methodColors['other'];
$barPct = round($method['total'] / $maxBreakdown * 100);
$barColors = [
'cash' => '#16a34a', 'card' => '#2563eb', 'bank_transfer' => '#4f46e5',
'wallet' => '#9333ea', 'online' => '#0891b2', 'cheque' => '#d97706', 'other' => '#6b7280',
];
$barColor = $barColors[$method['value']] ?? '#6b7280';
@endphp
<div>
<div class="flex items-center justify-between text-xs mb-0.5">
<span class="font-medium {{ $mc['text'] }}">{{ $method['label'] }}</span>
<span class="text-gray-500" dir="ltr">{{ number_format($method['total'] / 100, 2) }}</span>
</div>
<div class="h-1.5 rounded-full bg-gray-100 overflow-hidden">
<div class="h-full rounded-full" style="width: {{ $barPct }}%; background-color: {{ $barColor }};"></div>
</div>
<p class="text-xs text-gray-400 mt-0.5">{{ $method['count'] }} {{ __('مدفوعة') }}</p>
</div>
@endif
@endforeach
@if($breakdown->every(fn($m) => $m['total'] === 0))
<p class="col-span-4 text-xs text-gray-400 text-center py-2">{{ __('لا توجد مدفوعات في هذه الفترة') }}</p>
@endif
</div>
</div>
{{-- Grand total --}}
<div class="flex items-center justify-between mt-3 pt-3 border-t border-gray-100">
<span class="text-sm text-gray-500">{{ __('إجمالي المدفوعات') }}</span>
<span class="text-base font-bold text-gray-800" dir="ltr">
{{ number_format($grandTotal / 100, 2) }} {{ __('ج.م') }}
</span>
</div>
</div>
{{-- Flash Messages --}}
@if(session('success'))
<div class="mb-4 p-4 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">
......
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