Commit fb56f022 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add 4 dashboard/financial features: compact renewals, retroactive wizard fix,...

Add 4 dashboard/financial features: compact renewals, retroactive wizard fix, club revenue, expense shortcuts

- Compact overdue renewals alert into summary card with toggle detail list
- Rewrite retroactive enrollment wizard to CREATE new participants (not search existing)
- Add external revenue import form for club lump-sum payments
- Add quick-action buttons to financial overview for expenses/revenue
- Migration adds external_revenue to expenses category CHECK constraint
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 172a4836
...@@ -14,6 +14,7 @@ ...@@ -14,6 +14,7 @@
case Medical = 'medical'; case Medical = 'medical';
case Utilities = 'utilities'; case Utilities = 'utilities';
case Other = 'other'; case Other = 'other';
case ExternalRevenue = 'external_revenue';
public function label(): string public function label(): string
{ {
...@@ -28,6 +29,7 @@ public function label(): string ...@@ -28,6 +29,7 @@ public function label(): string
self::Medical => 'طبي', self::Medical => 'طبي',
self::Utilities => 'مرافق وخدمات', self::Utilities => 'مرافق وخدمات',
self::Other => 'أخرى', self::Other => 'أخرى',
self::ExternalRevenue => 'إيراد خارجي',
}; };
} }
} }
...@@ -85,6 +85,64 @@ public function recordExpense(array $data, User $actor): Expense ...@@ -85,6 +85,64 @@ public function recordExpense(array $data, User $actor): Expense
}); });
} }
public function recordExternalRevenue(array $data, User $actor): Expense
{
return DB::transaction(function () use ($data, $actor) {
$academyId = app('current_academy')?->id ?? $actor->academy_id;
$expense = Expense::create([
'academy_id' => $academyId,
'branch_id' => $data['branch_id'] ?? null,
'category' => 'external_revenue',
'amount' => $data['amount'],
'description' => $data['description'],
'recipient_name' => $data['source'] ?? null,
'payment_method' => $data['payment_method'],
'receipt_reference' => $data['reference_number'] ?? null,
'expense_date' => $data['revenue_date'],
'notes' => $data['notes'] ?? null,
'created_by' => $actor->id,
]);
$revenueAccount = FinancialAccount::where('academy_id', $academyId)
->where('code', '4060')
->first();
if (!$revenueAccount) {
throw new DomainException('حساب الإيرادات الخارجية غير موجود — يرجى إعداد شجرة الحسابات');
}
$cashAccountCode = match ($data['payment_method']) {
'bank_transfer', 'cheque' => '1010',
default => '1000',
};
$cashAccount = FinancialAccount::where('academy_id', $academyId)
->where('code', $cashAccountCode)
->first();
if (!$cashAccount) {
throw new DomainException('حساب النقدية/البنك غير موجود — يرجى إعداد شجرة الحسابات');
}
Transaction::create([
'academy_id' => $academyId,
'debit_account_id' => $cashAccount->id,
'credit_account_id' => $revenueAccount->id,
'reference_type' => get_class($expense),
'reference_id' => $expense->id,
'amount' => $data['amount'],
'currency' => 'EGP',
'type' => TransactionType::PaymentReceived,
'description' => $data['description'],
'transaction_date' => $data['revenue_date'],
'created_by' => $actor->id,
]);
return $expense;
});
}
private function createExpenseTransaction( private function createExpenseTransaction(
int $academyId, int $academyId,
string $expenseAccountCode, string $expenseAccountCode,
......
...@@ -12,18 +12,17 @@ ...@@ -12,18 +12,17 @@
class OverdueRenewalsAlert extends Component class OverdueRenewalsAlert extends Component
{ {
public bool $expanded = false; public bool $showList = false;
public function toggleExpanded(): void public function toggleList(): void
{ {
$this->expanded = !$this->expanded; $this->showList = !$this->showList;
} }
public function render() public function render()
{ {
$today = now()->toDateString(); $today = now()->toDateString();
// 1. Participants with unpaid renewal invoices (already billed, not yet paid)
$unpaidInvoices = Invoice::whereIn('status', [InvoiceStatus::Sent, InvoiceStatus::PartiallyPaid, InvoiceStatus::Overdue]) $unpaidInvoices = Invoice::whereIn('status', [InvoiceStatus::Sent, InvoiceStatus::PartiallyPaid, InvoiceStatus::Overdue])
->where('due_amount', '>', 0) ->where('due_amount', '>', 0)
->where('billable_type', Participant::class) ->where('billable_type', Participant::class)
...@@ -32,7 +31,6 @@ public function render() ...@@ -32,7 +31,6 @@ public function render()
->orderBy('due_date') ->orderBy('due_date')
->get(); ->get();
// 2. Also find enrollments overdue but NO invoice generated yet
$overdueEnrollments = Enrollment::where('status', EnrollmentStatus::Active) $overdueEnrollments = Enrollment::where('status', EnrollmentStatus::Active)
->whereNotNull('next_billing_date') ->whereNotNull('next_billing_date')
->where('next_billing_date', '<=', $today) ->where('next_billing_date', '<=', $today)
...@@ -54,46 +52,50 @@ public function render() ...@@ -54,46 +52,50 @@ public function render()
->exists(); ->exists();
}); });
// Build unified list $invoiceCount = $unpaidInvoices->count();
$enrollmentCount = $overdueEnrollments->count();
$totalOverdue = $invoiceCount + $enrollmentCount;
$totalAmount = $unpaidInvoices->sum('due_amount');
$items = collect(); $items = collect();
if ($this->showList) {
foreach ($unpaidInvoices as $invoice) {
$participant = $invoice->billable;
if (!$participant) {
continue;
}
$items->push([
'participant_name' => $participant->person?->name_ar ?? $invoice->contact_name ?? '-',
'participant_phone' => $participant->person?->phone ?? null,
'participant_uuid' => $participant->uuid,
'amount' => $invoice->due_amount,
'program_name' => $this->extractProgramName($invoice->notes),
'days_overdue' => $invoice->due_date?->isPast() ? (int) now()->diffInDays($invoice->due_date) : 0,
'type' => 'invoice',
]);
}
foreach ($unpaidInvoices as $invoice) { foreach ($overdueEnrollments as $enrollment) {
$participant = $invoice->billable; $items->push([
if (!$participant) { 'participant_name' => $enrollment->participant?->person?->name_ar ?? '-',
continue; 'participant_phone' => $enrollment->participant?->person?->phone ?? null,
'participant_uuid' => $enrollment->participant?->uuid,
'amount' => null,
'program_name' => $enrollment->program?->name_ar ?? '-',
'days_overdue' => (int) now()->diffInDays($enrollment->next_billing_date),
'type' => 'no_invoice',
]);
} }
$items->push([
'participant_name' => $participant->person?->name_ar ?? $invoice->contact_name ?? '-',
'participant_phone' => $participant->person?->phone ?? null,
'participant_uuid' => $participant->uuid,
'amount' => $invoice->due_amount,
'program_name' => $this->extractProgramName($invoice->notes),
'days_overdue' => $invoice->due_date?->isPast() ? (int) now()->diffInDays($invoice->due_date) : 0,
'type' => 'invoice',
]);
}
foreach ($overdueEnrollments as $enrollment) { $items = $items->sortByDesc('days_overdue')->values();
$items->push([
'participant_name' => $enrollment->participant?->person?->name_ar ?? '-',
'participant_phone' => $enrollment->participant?->person?->phone ?? null,
'participant_uuid' => $enrollment->participant?->uuid,
'amount' => null,
'program_name' => $enrollment->program?->name_ar ?? '-',
'days_overdue' => (int) now()->diffInDays($enrollment->next_billing_date),
'type' => 'no_invoice',
]);
} }
$items = $items->sortByDesc('days_overdue')->values();
$totalOverdue = $items->count();
$displayList = $this->expanded ? $items : $items->take(15);
$totalAmount = $unpaidInvoices->sum('due_amount');
return view('livewire.dashboard.overdue-renewals-alert', [ return view('livewire.dashboard.overdue-renewals-alert', [
'items' => $displayList, 'items' => $items,
'totalOverdue' => $totalOverdue, 'totalOverdue' => $totalOverdue,
'totalAmount' => $totalAmount, 'totalAmount' => $totalAmount,
'invoiceCount' => $invoiceCount,
'enrollmentCount' => $enrollmentCount,
]); ]);
} }
......
<?php
namespace App\Livewire\Financial;
use App\Domain\Financial\Services\ExpenseService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('تسجيل إيراد خارجي')]
class ExternalRevenueForm extends Component
{
use UsesBranchScope;
public string $source = '';
public string $amount_display = '';
public string $description = '';
public string $payment_method = 'cash';
public string $reference_number = '';
public ?string $revenue_date = null;
public string $notes = '';
public function mount(): void
{
$this->authorize('expenses.create');
$this->revenue_date = now()->toDateString();
}
public function rules(): array
{
return [
'source' => 'required|string|max:255',
'amount_display' => 'required|numeric|min:0.01',
'description' => 'required|string|max:500',
'payment_method' => 'required|in:cash,card,bank_transfer,wallet,online,cheque,other',
'reference_number' => 'nullable|string|max:100',
'revenue_date' => 'required|date',
'notes' => 'nullable|string',
];
}
public function messages(): array
{
return [
'source.required' => 'مصدر الإيراد مطلوب',
'amount_display.required' => 'المبلغ مطلوب',
'amount_display.min' => 'المبلغ يجب أن يكون أكبر من صفر',
'description.required' => 'وصف الإيراد مطلوب',
'payment_method.required' => 'اختر طريقة الاستلام',
'revenue_date.required' => 'تاريخ الإيراد مطلوب',
];
}
public function save(ExpenseService $service): void
{
$this->validate();
try {
$service->recordExternalRevenue([
'branch_id' => $this->getActiveBranchId(),
'source' => $this->source,
'amount' => (int) round((float) $this->amount_display * 100),
'description' => $this->description,
'payment_method' => $this->payment_method,
'reference_number' => $this->reference_number ?: null,
'revenue_date' => $this->revenue_date,
'notes' => $this->notes ?: null,
], auth()->user());
session()->flash('success', __('تم تسجيل الإيراد الخارجي بنجاح'));
$this->redirect(route('financial.overview'), navigate: true);
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function render()
{
return view('livewire.financial.external-revenue-form');
}
}
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement("ALTER TABLE expenses DROP CONSTRAINT IF EXISTS expenses_category_check");
DB::statement("ALTER TABLE expenses ADD CONSTRAINT expenses_category_check CHECK (category IN ('maintenance', 'supplies', 'transport', 'food_beverage', 'sports_equipment', 'printing', 'cleaning', 'medical', 'utilities', 'other', 'external_revenue'))");
}
public function down(): void
{
DB::statement("ALTER TABLE expenses DROP CONSTRAINT IF EXISTS expenses_category_check");
DB::statement("ALTER TABLE expenses ADD CONSTRAINT expenses_category_check CHECK (category IN ('maintenance', 'supplies', 'transport', 'food_beverage', 'sports_equipment', 'printing', 'cleaning', 'medical', 'utilities', 'other'))");
}
};
...@@ -31,6 +31,7 @@ public function run(): void ...@@ -31,6 +31,7 @@ public function run(): void
['code' => '4030', 'name' => 'Facility Rental', 'name_ar' => 'إيجار الملاعب', 'type' => 'revenue', 'category' => 'operating', 'is_system' => true], ['code' => '4030', 'name' => 'Facility Rental', 'name_ar' => 'إيجار الملاعب', 'type' => 'revenue', 'category' => 'operating', 'is_system' => true],
['code' => '4040', 'name' => 'Private Sessions', 'name_ar' => 'الحصص الخاصة', 'type' => 'revenue', 'category' => 'operating', 'is_system' => true], ['code' => '4040', 'name' => 'Private Sessions', 'name_ar' => 'الحصص الخاصة', 'type' => 'revenue', 'category' => 'operating', 'is_system' => true],
['code' => '4050', 'name' => 'Tournament Fees', 'name_ar' => 'رسوم البطولات', 'type' => 'revenue', 'category' => 'operating', 'is_system' => true], ['code' => '4050', 'name' => 'Tournament Fees', 'name_ar' => 'رسوم البطولات', 'type' => 'revenue', 'category' => 'operating', 'is_system' => true],
['code' => '4060', 'name' => 'External Revenue', 'name_ar' => 'إيرادات خارجية', 'type' => 'revenue', 'category' => 'operating', 'is_system' => true],
// Expenses // Expenses
['code' => '5000', 'name' => 'Trainer Salaries', 'name_ar' => 'رواتب المدربين', 'type' => 'expense', 'category' => 'operating', 'is_system' => true], ['code' => '5000', 'name' => 'Trainer Salaries', 'name_ar' => 'رواتب المدربين', 'type' => 'expense', 'category' => 'operating', 'is_system' => true],
......
<div>
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 sm:mb-6">
<h1 class="text-xl sm:text-2xl font-bold text-gray-800">{{ __('تسجيل إيراد خارجي') }}</h1>
<a href="{{ route('financial.overview') }}" wire:navigate
class="inline-flex items-center gap-1 text-sm text-gray-600 hover:text-gray-800">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 17l-5-5m0 0l5-5m-5 5h12"/></svg>
{{ __('النظرة المالية') }}
</a>
</div>
@if(session('error'))
<div class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{{ session('error') }}</div>
@endif
<form wire:submit="save" class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
{{-- Source --}}
<div>
<label class="block text-sm text-gray-600 mb-1">{{ __('مصدر الإيراد') }} <span class="text-red-500">*</span></label>
<input type="text" wire:model="source"
class="w-full rounded-lg border-gray-300 text-sm py-2.5"
placeholder="{{ __('مثال: النادي الأهلي، شركة راعية') }}">
@error('source') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
{{-- Amount --}}
<div>
<label class="block text-sm text-gray-600 mb-1">{{ __('المبلغ (ج.م)') }} <span class="text-red-500">*</span></label>
<input type="number" wire:model="amount_display" dir="ltr" step="0.01" min="0"
class="w-full rounded-lg border-gray-300 text-sm py-2.5" placeholder="0.00">
@error('amount_display') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
{{-- Description --}}
<div class="md:col-span-2">
<label class="block text-sm text-gray-600 mb-1">{{ __('الوصف') }} <span class="text-red-500">*</span></label>
<input type="text" wire:model="description"
class="w-full rounded-lg border-gray-300 text-sm py-2.5"
placeholder="{{ __('مثال: دفعة النادي عن شهر يوليو') }}">
@error('description') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
{{-- Payment Method --}}
<div class="md:col-span-2">
<label class="block text-sm text-gray-600 mb-2">{{ __('طريقة الاستلام') }} <span class="text-red-500">*</span></label>
<div class="grid grid-cols-2 sm:grid-cols-5 gap-2">
<label class="relative cursor-pointer">
<input type="radio" wire:model="payment_method" value="cash" class="peer sr-only">
<div class="peer-checked:ring-2 peer-checked:ring-green-500 peer-checked:border-green-500 peer-checked:bg-green-50 flex flex-col items-center gap-1.5 p-3 rounded-xl border-2 border-gray-200 hover:border-green-300 transition-all">
<svg class="w-7 h-7 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 9V7a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2m2 4h10a2 2 0 002-2v-6a2 2 0 00-2-2H9a2 2 0 00-2 2v6a2 2 0 002 2zm7-5a2 2 0 11-4 0 2 2 0 014 0z"/>
</svg>
<span class="text-xs font-medium text-gray-700">{{ __('كاش') }}</span>
</div>
</label>
<label class="relative cursor-pointer">
<input type="radio" wire:model="payment_method" value="bank_transfer" class="peer sr-only">
<div class="peer-checked:ring-2 peer-checked:ring-purple-500 peer-checked:border-purple-500 peer-checked:bg-purple-50 flex flex-col items-center gap-1.5 p-3 rounded-xl border-2 border-gray-200 hover:border-purple-300 transition-all">
<svg class="w-7 h-7 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 14v3m4-3v3m4-3v3M3 21h18M3 10h18M3 7l9-4 9 4M4 10h16v11H4V10z"/>
</svg>
<span class="text-xs font-medium text-gray-700">{{ __('تحويل بنكي') }}</span>
</div>
</label>
<label class="relative cursor-pointer">
<input type="radio" wire:model="payment_method" value="cheque" class="peer sr-only">
<div class="peer-checked:ring-2 peer-checked:ring-amber-500 peer-checked:border-amber-500 peer-checked:bg-amber-50 flex flex-col items-center gap-1.5 p-3 rounded-xl border-2 border-gray-200 hover:border-amber-300 transition-all">
<svg class="w-7 h-7 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<span class="text-xs font-medium text-gray-700">{{ __('شيك') }}</span>
</div>
</label>
<label class="relative cursor-pointer">
<input type="radio" wire:model="payment_method" value="other" class="peer sr-only">
<div class="peer-checked:ring-2 peer-checked:ring-gray-500 peer-checked:border-gray-500 peer-checked:bg-gray-50 flex flex-col items-center gap-1.5 p-3 rounded-xl border-2 border-gray-200 hover:border-gray-300 transition-all">
<svg class="w-7 h-7 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h.01M12 12h.01M19 12h.01M6 12a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0zm7 0a1 1 0 11-2 0 1 1 0 012 0z"/>
</svg>
<span class="text-xs font-medium text-gray-700">{{ __('أخرى') }}</span>
</div>
</label>
</div>
@error('payment_method') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
{{-- Reference Number --}}
<div>
<label class="block text-sm text-gray-600 mb-1">{{ __('رقم المرجع / الإيصال') }}</label>
<input type="text" wire:model="reference_number" dir="ltr"
class="w-full rounded-lg border-gray-300 text-sm py-2.5">
@error('reference_number') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
{{-- Date --}}
<div>
<label class="block text-sm text-gray-600 mb-1">{{ __('تاريخ الإيراد') }} <span class="text-red-500">*</span></label>
<input type="date" wire:model="revenue_date" dir="ltr"
class="w-full rounded-lg border-gray-300 text-sm py-2.5">
@error('revenue_date') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
{{-- Notes --}}
<div class="md:col-span-2">
<label class="block text-sm text-gray-600 mb-1">{{ __('ملاحظات') }}</label>
<textarea wire:model="notes" rows="2"
class="w-full rounded-lg border-gray-300 text-sm py-2.5"
placeholder="{{ __('أي تفاصيل إضافية...') }}"></textarea>
</div>
</div>
{{-- Submit --}}
<div class="mt-6 flex justify-end">
<button type="submit" wire:loading.attr="disabled" wire:target="save"
class="inline-flex items-center gap-2 px-6 py-2.5 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition text-sm font-medium disabled:opacity-50">
<span wire:loading.remove wire:target="save">{{ __('تسجيل الإيراد') }}</span>
<span wire:loading wire:target="save">{{ __('جارٍ الحفظ...') }}</span>
</button>
</div>
</form>
</div>
...@@ -23,6 +23,26 @@ ...@@ -23,6 +23,26 @@
</div> </div>
</div> </div>
{{-- Quick Actions --}}
@can('expenses.create')
<div class="flex flex-wrap gap-2 mb-4">
<a href="{{ route('expenses.create') }}" wire:navigate
class="inline-flex items-center gap-1.5 px-3 py-2 bg-red-50 border border-red-200 text-red-700 rounded-lg hover:bg-red-100 text-xs font-medium transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
{{ __('تسجيل مصروف') }}
</a>
<a href="{{ route('revenue.external.create') }}" wire:navigate
class="inline-flex items-center gap-1.5 px-3 py-2 bg-emerald-50 border border-emerald-200 text-emerald-700 rounded-lg hover:bg-emerald-100 text-xs font-medium transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
{{ __('تسجيل إيراد خارجي') }}
</a>
</div>
@endcan
{{-- Loading overlay --}} {{-- Loading overlay --}}
<div wire:loading.class="opacity-50 pointer-events-none" class="transition-opacity"> <div wire:loading.class="opacity-50 pointer-events-none" class="transition-opacity">
......
...@@ -253,6 +253,8 @@ ...@@ -253,6 +253,8 @@
->middleware('permission:facilities.update'); ->middleware('permission:facilities.update');
Route::get('/expenses/rent/create', \App\Livewire\Financial\FacilityRentForm::class)->name('expenses.rent.create') Route::get('/expenses/rent/create', \App\Livewire\Financial\FacilityRentForm::class)->name('expenses.rent.create')
->middleware('permission:facilities.update'); ->middleware('permission:facilities.update');
Route::get('/revenue/external', \App\Livewire\Financial\ExternalRevenueForm::class)->name('revenue.external.create')
->middleware('permission:expenses.create');
// Invoices // Invoices
Route::get('/invoices', InvoiceList::class)->name('invoices.list') Route::get('/invoices', InvoiceList::class)->name('invoices.list')
......
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