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

Add full reports section (30 reports) + fix refunds, branch switching, participant editing

Reports: 30 business reports in 6 categories (financial, participants, attendance,
enrollments, operations, inventory) with CSV export. New ReportsHub grid page and
generic ReportViewer with date/branch filters.

Fixes: refund now creates double-entry transaction + deducts from cash session,
branch switcher properly persists "all branches" selection, trainer wizard
compensation step reactive, gender display on edit, participant edit supports
national_id and guardian phone, participant list shows membership column + searches
by national_id/membership_id.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 1b16cf85
......@@ -14,7 +14,7 @@ public function handle(PaymentReceived $event): void
try {
$payment = $event->payment;
if ($payment->method !== 'cash') {
if ($payment->method !== \App\Domain\Financial\Enums\PaymentMethod::Cash) {
return;
}
......
......@@ -2,8 +2,12 @@
namespace App\Domain\Financial\Services;
use App\Domain\Financial\Enums\PaymentMethod;
use App\Domain\Financial\Enums\PaymentStatus;
use App\Domain\Financial\Enums\TransactionType;
use App\Domain\Financial\Models\CashSession;
use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Models\Transaction;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Enrollment;
use App\Models\User;
......@@ -12,6 +16,7 @@ class RefundService
{
public function __construct(
private readonly InvoiceService $invoiceService,
private readonly CashSessionService $cashSessionService,
) {}
/**
......@@ -116,9 +121,35 @@ public function processRefunds(array $paymentUuids, string $reason, User $actor)
'created_by' => $actor->id,
]);
// Mark original payment as refunded (this was missing from PaymentService::refund())
// Mark original payment as refunded
$payment->update(['status' => PaymentStatus::Refunded]);
// Create double-entry transaction for the refund
Transaction::create([
'academy_id' => $refundPayment->academy_id,
'debit_account_id' => 2, // Accounts Receivable (reversing the original credit)
'credit_account_id' => 1, // Cash/Bank (money going out)
'payment_id' => $refundPayment->id,
'invoice_id' => $refundPayment->invoice_id,
'amount' => $refundPayment->amount,
'currency' => $refundPayment->currency ?? 'EGP',
'type' => TransactionType::Refund,
'description' => "استرداد: {$payment->reference}",
'transaction_date' => now()->toDateString(),
'created_by' => $actor->id,
]);
// Deduct from cash session if this was a cash payment
if ($payment->method === PaymentMethod::Cash) {
$cashSession = CashSession::where('user_id', $actor->id)
->where('status', 'open')
->first();
if ($cashSession) {
$this->cashSessionService->recordCashOut($cashSession, $payment->amount);
}
}
// Reverse the invoice balance
if ($payment->invoice_id) {
$this->invoiceService->updatePaidAmount(
......
......@@ -8,7 +8,11 @@
{
public function getActiveBranchId(): ?int
{
return session('active_branch_id', auth()->user()->branch_id);
if (!session()->has('active_branch_id')) {
return auth()->user()->branch_id;
}
return session('active_branch_id');
}
public function getActiveBranchIdOrFail(): int
......@@ -32,6 +36,6 @@ public function getActiveBranchIdOrFail(): int
public function isAllBranches(): bool
{
return session('active_branch_id') === null && !session()->has('active_branch_id');
return session()->has('active_branch_id') && session('active_branch_id') === null;
}
}
......@@ -5,7 +5,9 @@
use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment;
use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Services\ReportService;
use App\Domain\Training\Models\Enrollment;
use App\Livewire\Reports\ReportViewer;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\StreamedResponse;
......@@ -146,6 +148,60 @@ public function enrollments(Request $request): StreamedResponse
});
}
public function report(Request $request, ReportService $reportService): StreamedResponse
{
Gate::authorize('reports.view');
$reportKey = $request->get('report');
$viewer = new ReportViewer();
$configs = $viewer->getReportConfig();
if (!$reportKey || !isset($configs[$reportKey])) {
abort(404);
}
$config = $configs[$reportKey];
$method = $config['method'];
$usesDates = $config['uses_dates'] ?? true;
$branchId = session()->has('active_branch_id') ? session('active_branch_id') : auth()->user()->branch_id;
$from = $request->get('from', now()->startOfMonth()->toDateString());
$to = $request->get('to', now()->toDateString());
if ($usesDates) {
$data = $reportService->$method($from, $to, $branchId);
} else {
$data = $reportService->$method($branchId);
}
$moneyCols = $config['money_cols'] ?? [];
$columns = $config['columns'];
$date = now()->format('Y-m-d');
return response()->streamDownload(function () use ($config, $data, $columns, $moneyCols) {
$handle = fopen('php://output', 'w');
fprintf($handle, chr(0xEF) . chr(0xBB) . chr(0xBF));
fputcsv($handle, $config['headers']);
foreach ($data as $row) {
$csvRow = [];
foreach ($columns as $col) {
$value = is_array($row) ? ($row[$col] ?? '') : ($row->$col ?? '');
if (in_array($col, $moneyCols)) {
$value = number_format($value / 100, 2);
}
$csvRow[] = $value;
}
fputcsv($handle, $csvRow);
}
fclose($handle);
}, "{$reportKey}-{$date}.csv", [
'Content-Type' => 'text/csv; charset=UTF-8',
]);
}
private function streamCsv(string $filename, array $headers, $query, callable $rowMapper): StreamedResponse
{
$date = now()->format('Y-m-d');
......
......@@ -7,33 +7,34 @@
class BranchSwitcher extends Component
{
public ?int $activeBranchId = null;
public string $selectedBranch = 'all';
public function mount(): void
{
$this->activeBranchId = session('active_branch_id', auth()->user()->branch_id);
if (!$this->activeBranchId) {
$first = Branch::where('is_active', true)->first();
if ($first) {
$this->activeBranchId = $first->id;
session(['active_branch_id' => $first->id]);
if (session()->has('active_branch_id')) {
$value = session('active_branch_id');
$this->selectedBranch = $value === null ? 'all' : (string) $value;
} else {
// First visit — default to user's assigned branch or first active
$branchId = auth()->user()->branch_id;
if (!$branchId) {
$first = Branch::where('is_active', true)->first();
$branchId = $first?->id;
}
$this->selectedBranch = $branchId ? (string) $branchId : 'all';
session(['active_branch_id' => $branchId]);
}
}
public function updatedActiveBranchId($value): void
public function updatedSelectedBranch($value): void
{
if ($value === 'all') {
session(['active_branch_id' => null]);
$this->activeBranchId = null;
} else {
$branchId = (int) $value;
session(['active_branch_id' => $branchId]);
$this->activeBranchId = $branchId;
session(['active_branch_id' => (int) $value]);
}
$this->dispatch('branch-switched', branchId: $this->activeBranchId);
$this->dispatch('branch-switched');
$this->redirect(request()->header('Referer', '/'), navigate: true);
}
......
......@@ -48,17 +48,25 @@ class ParticipantForm extends Component
public ?string $weight_kg = null;
public string $notes = '';
// Guardian phone (editable in edit mode)
public string $guardian_phone = '';
public function mount(?Participant $participant = null): void
{
$this->branch_id = session('active_branch_id', auth()->user()->branch_id);
if ($participant && $participant->exists) {
$this->participant = $participant->load('person');
$this->participant = $participant->load(['person', 'primaryGuardian.person']);
$this->editing = true;
$this->person_id = $participant->person_id;
$this->name_ar = $participant->person->name_ar ?? '';
$this->name = $participant->person->name ?? '';
$this->gender = $participant->person->gender ?? 'male';
$this->phone = $participant->person->phone ?? '';
$this->email = $participant->person->email ?? '';
$this->national_id = $participant->person->national_id ?? '';
$this->date_of_birth = $participant->person->date_of_birth?->format('Y-m-d');
$this->branch_id = $participant->branch_id;
$this->registration_source = $participant->registration_source->value ?? $participant->registration_source;
......@@ -77,6 +85,11 @@ public function mount(?Participant $participant = null): void
$this->height_cm = $participant->height_cm;
$this->weight_kg = $participant->weight_kg;
$this->notes = $participant->notes ?? '';
// Load guardian phone for editing
if ($participant->primaryGuardian?->person) {
$this->guardian_phone = $participant->primaryGuardian->person->phone ?? '';
}
}
}
......@@ -103,7 +116,16 @@ public function rules(): array
'notes' => 'nullable|string',
];
if (!$this->editing && !$this->person_id) {
if ($this->editing) {
$rules['name_ar'] = 'required|string|max:255';
$rules['name'] = 'nullable|string|max:255';
$rules['gender'] = 'required|in:male,female';
$rules['phone'] = 'nullable|string|max:20';
$rules['email'] = 'nullable|email|max:255';
$rules['national_id'] = 'nullable|string|max:14';
$rules['date_of_birth'] = 'nullable|date|before:today';
$rules['guardian_phone'] = 'nullable|string|max:20';
} elseif (!$this->person_id) {
$rules['name_ar'] = 'required|string|max:255';
$rules['name'] = 'required|string|max:255';
$rules['gender'] = 'required|in:male,female';
......@@ -163,6 +185,24 @@ public function save(ParticipantService $service): void
try {
if ($this->editing) {
// Update person data
$this->participant->person->update([
'name_ar' => $this->name_ar,
'name' => $this->name ?: null,
'gender' => $this->gender,
'phone' => $this->phone ?: null,
'email' => $this->email ?: null,
'national_id' => $this->national_id ?: null,
'date_of_birth' => $this->date_of_birth ?: null,
]);
// Update guardian phone if guardian exists
if ($this->participant->primaryGuardian?->person && $this->guardian_phone !== '') {
$this->participant->primaryGuardian->person->update([
'phone' => $this->guardian_phone ?: null,
]);
}
$service->update($this->participant, [
'primary_activity_id' => $this->primary_activity_id,
'primary_guardian_id' => $this->primary_guardian_id,
......
......@@ -60,10 +60,12 @@ public function render()
$search = $this->search;
$q->where(function ($q2) use ($search) {
$q2->where('participant_number', 'ilike', "%{$search}%")
->orWhere('membership_id', 'ilike', "%{$search}%")
->orWhereHas('person', function ($pq) use ($search) {
$pq->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%");
->orWhere('phone', 'like', "%{$search}%")
->orWhere('national_id', 'like', "%{$search}%");
});
});
})
......
This diff is collapsed.
<?php
namespace App\Livewire\Reports;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('التقارير')]
class ReportsHub extends Component
{
public function mount(): void
{
$this->authorize('reports.view');
}
public function getReportsProperty(): array
{
return [
'financial' => [
'label' => 'مالي',
'icon' => 'banknotes',
'color' => 'emerald',
'reports' => [
['key' => 'daily_revenue', 'name' => 'الإيرادات اليومية', 'desc' => 'المبالغ المحصلة يومياً مع طريقة الدفع'],
['key' => 'outstanding_balances', 'name' => 'الأرصدة المعلقة', 'desc' => 'فواتير غير مسددة بالكامل مع بيانات التواصل'],
['key' => 'payment_methods', 'name' => 'طرق الدفع', 'desc' => 'توزيع المدفوعات حسب الطريقة (نقدي، بطاقة، محفظة)'],
['key' => 'refunds', 'name' => 'المرتجعات', 'desc' => 'جميع عمليات الاسترداد مع السبب والمبلغ'],
['key' => 'installments_due', 'name' => 'الأقساط المستحقة', 'desc' => 'أقساط قادمة أو متأخرة مع بيانات المشترك'],
['key' => 'cash_sessions', 'name' => 'ملخص الورديات', 'desc' => 'كل وردية نقدية مع الفرق بين المتوقع والفعلي'],
['key' => 'overdue_aging', 'name' => 'تقادم الفواتير', 'desc' => 'الفواتير المتأخرة مصنفة بالأيام (30/60/90+)'],
['key' => 'revenue_by_activity', 'name' => 'الإيرادات حسب النشاط', 'desc' => 'مقارنة إيرادات كل نشاط رياضي'],
],
],
'participants' => [
'label' => 'المشتركين',
'icon' => 'users',
'color' => 'blue',
'reports' => [
['key' => 'new_registrations', 'name' => 'التسجيلات الجديدة', 'desc' => 'المشتركون الجدد في الفترة المحددة'],
['key' => 'by_status', 'name' => 'حسب الحالة', 'desc' => 'توزيع المشتركين (نشط، مجمد، موقوف...)'],
['key' => 'by_age', 'name' => 'حسب الفئة العمرية', 'desc' => 'المشتركون النشطون مصنفين بالعمر'],
['key' => 'by_gender', 'name' => 'حسب الجنس', 'desc' => 'عدد الذكور والإناث النشطين'],
['key' => 'expired_memberships', 'name' => 'العضويات المنتهية', 'desc' => 'أعضاء تنتهي أو انتهت عضويتهم قريباً'],
['key' => 'frozen_suspended', 'name' => 'المجمدون والموقوفون', 'desc' => 'مشتركون متوقفون مع السبب والمدة'],
],
],
'attendance' => [
'label' => 'الحضور',
'icon' => 'clipboard-check',
'color' => 'amber',
'reports' => [
['key' => 'attendance_by_group', 'name' => 'الحضور حسب المجموعة', 'desc' => 'نسبة الحضور لكل مجموعة تدريبية'],
['key' => 'absentees', 'name' => 'أكثر الغائبين', 'desc' => 'المشتركون الأكثر غياباً مع عدد المرات'],
['key' => 'trainer_attendance', 'name' => 'حضور المدربين', 'desc' => 'نسبة حضور كل مدرب لحصصه'],
],
],
'enrollments' => [
'label' => 'التسجيلات',
'icon' => 'academic-cap',
'color' => 'purple',
'reports' => [
['key' => 'enrollments_by_program', 'name' => 'حسب البرنامج', 'desc' => 'عدد المسجلين في كل برنامج تدريبي'],
['key' => 'group_capacity', 'name' => 'نسبة امتلاء المجموعات', 'desc' => 'المتاح مقابل الأقصى لكل مجموعة'],
['key' => 'cancellations', 'name' => 'الإلغاءات', 'desc' => 'تسجيلات ملغاة مع السبب والتاريخ'],
['key' => 'retention', 'name' => 'الاستمرارية', 'desc' => 'نسبة من استمر مقابل من ألغى'],
],
],
'operations' => [
'label' => 'العمليات',
'icon' => 'cog',
'color' => 'slate',
'reports' => [
['key' => 'session_completion', 'name' => 'الحصص المنجزة', 'desc' => 'حالات الحصص (مكتملة، ملغاة، مجدولة)'],
['key' => 'branch_comparison', 'name' => 'مقارنة الفروع', 'desc' => 'مشتركون وإيرادات وتسجيلات لكل فرع'],
['key' => 'trainer_workload', 'name' => 'عبء المدربين', 'desc' => 'عدد الحصص والمجموعات لكل مدرب'],
['key' => 'wallet_balances', 'name' => 'أرصدة المحافظ', 'desc' => 'المشتركون أصحاب أرصدة محفظة إيجابية'],
],
],
'inventory' => [
'label' => 'المخزون',
'icon' => 'cube',
'color' => 'orange',
'reports' => [
['key' => 'low_stock', 'name' => 'المنتجات المنخفضة', 'desc' => 'منتجات وصلت أو تحت حد إعادة الطلب'],
['key' => 'inventory_movements', 'name' => 'حركات المخزون', 'desc' => 'كل الحركات الواردة والصادرة'],
['key' => 'product_sales', 'name' => 'مبيعات المنتجات', 'desc' => 'أكثر المنتجات مبيعاً بالكمية والإيراد'],
['key' => 'pos_transactions', 'name' => 'عمليات نقاط البيع', 'desc' => 'تفاصيل كل عملية بيع'],
['key' => 'pos_daily_summary', 'name' => 'ملخص POS اليومي', 'desc' => 'إجمالي المبيعات والخصومات يومياً'],
],
],
];
}
public function render()
{
return view('livewire.reports.reports-hub', [
'categories' => $this->reports,
]);
}
}
......@@ -96,7 +96,7 @@
]],
['section' => 'الإدارة', 'items' => [
['label' => 'التقارير', 'route' => 'reports.view', 'icon' => 'chart-bar', 'permission' => 'reports.view'],
['label' => 'التقارير', 'route' => 'reports.hub', 'icon' => 'chart-bar', 'permission' => 'reports.view'],
['label' => 'المستخدمين', 'route' => 'users.list', 'icon' => 'users', 'permission' => 'users.list'],
['label' => 'الأدوار', 'route' => 'roles.list', 'icon' => 'shield-check', 'permission' => 'roles.list'],
['label' => 'الفروع', 'route' => 'branches.list', 'icon' => 'building-office', 'permission' => 'branches.list'],
......
<div class="relative">
<select wire:model.live="activeBranchId"
<select wire:model.live="selectedBranch"
class="appearance-none bg-gray-50 border border-gray-200 rounded-lg px-3 py-1.5 pe-8 text-sm font-medium text-gray-700 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 cursor-pointer">
<option value="all">{{ __('كل الفروع') }}</option>
@foreach($branches as $branch)
......
......@@ -302,7 +302,7 @@ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('نموذج التعويض') }} <span class="text-red-500">*</span></label>
<select wire:model="compensationModel" class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
<select wire:model.live="compensationModel" class="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
<option value="">{{ __('اختر...') }}</option>
@foreach($compensationModels as $model)
<option value="{{ $model->value }}">{{ $model->label() }}</option>
......
......@@ -18,8 +18,60 @@ class="text-gray-600 hover:text-gray-800 text-sm">
<form wire:submit="save" class="space-y-6">
{{-- Section 1: Person Info (only for new participants without existing person) --}}
@if(!$editing && !$person_id)
{{-- Section 1: Person Info --}}
@if($editing)
{{-- Edit mode: show person fields for editing --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h2 class="text-base sm:text-lg font-semibold text-gray-800 mb-4 border-b border-gray-100 pb-2">{{ __('البيانات الشخصية') }}</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الاسم بالعربية') }} <span class="text-red-500">*</span></label>
<input type="text" wire:model="name_ar"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('name_ar') border-red-500 @enderror">
@error('name_ar') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الاسم بالإنجليزية') }}</label>
<input type="text" wire:model="name" dir="ltr"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('name') border-red-500 @enderror">
@error('name') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الرقم القومي') }}</label>
<input type="text" wire:model="national_id" dir="ltr"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('تاريخ الميلاد') }}</label>
<input type="date" wire:model="date_of_birth" dir="ltr"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('date_of_birth') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الجنس') }} <span class="text-red-500">*</span></label>
<select wire:model="gender"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 @error('gender') border-red-500 @enderror">
<option value="male">{{ __('ذكر') }}</option>
<option value="female">{{ __('أنثى') }}</option>
</select>
@error('gender') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('الهاتف') }}</label>
<input type="text" wire:model="phone" dir="ltr"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('البريد الإلكتروني') }}</label>
<input type="email" wire:model="email" dir="ltr"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('email') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
</div>
</div>
@elseif(!$person_id)
{{-- Create mode: new person --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h2 class="text-base sm:text-lg font-semibold text-gray-800 mb-4 border-b border-gray-100 pb-2">{{ __('البيانات الشخصية') }}</h2>
......@@ -85,8 +137,8 @@ class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2
</div>
</div>
</div>
@elseif(!$editing && $person_id)
{{-- Show selected person info --}}
@else
{{-- Create mode: existing person selected --}}
<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-2">
<h2 class="text-base sm:text-lg font-semibold text-gray-800">{{ __('الشخص المحدد') }}</h2>
......@@ -239,18 +291,29 @@ class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<h2 class="text-base sm:text-lg font-semibold text-gray-800 mb-4 border-b border-gray-100 pb-2">{{ __('ولي الأمر') }}</h2>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('ولي الأمر الأساسي') }}</label>
<select wire:model="primary_guardian_id"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">{{ __('— بدون ولي أمر —') }}</option>
@foreach($guardians as $guardian)
<option value="{{ $guardian->id }}">
{{ $guardian->name_ar }}
@if($guardian->phone) — {{ $guardian->phone }} @endif
</option>
@endforeach
</select>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('ولي الأمر الأساسي') }}</label>
<select wire:model="primary_guardian_id"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<option value="">{{ __('— بدون ولي أمر —') }}</option>
@foreach($guardians as $guardian)
<option value="{{ $guardian->id }}">
{{ $guardian->name_ar }}
@if($guardian->phone) — {{ $guardian->phone }} @endif
</option>
@endforeach
</select>
</div>
@if($editing && $primary_guardian_id)
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('هاتف ولي الأمر') }}</label>
<input type="text" wire:model="guardian_phone" dir="ltr"
placeholder="{{ __('رقم الهاتف') }}"
class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
@error('guardian_phone') <p class="text-red-500 text-xs mt-1">{{ $message }}</p> @enderror
</div>
@endif
</div>
</div>
......
......@@ -30,7 +30,7 @@ class="inline-flex items-center gap-2 px-3 sm:px-4 py-2 bg-blue-600 text-white r
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
<div class="col-span-2 md:col-span-1 lg:col-span-2">
<input type="text" wire:model.live.debounce.300ms="search"
placeholder="{{ __('بحث بالاسم، رقم المشترك، أو الهاتف...') }}"
placeholder="{{ __('بحث بالاسم، الرقم القومي، رقم العضوية، أو الهاتف...') }}"
class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
</div>
<select wire:model.live="status" class="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-sm">
......@@ -64,6 +64,7 @@ class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 f
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('رقم المشترك') }}</th>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('الاسم') }}</th>
<th class="px-4 py-3 text-start font-medium text-gray-600">{{ __('النشاط') }}</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('العضوية') }}</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('الحالة') }}</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('تاريخ التسجيل') }}</th>
<th class="px-4 py-3 text-center font-medium text-gray-600">{{ __('إجراءات') }}</th>
......@@ -82,6 +83,21 @@ class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 f
@endif
</td>
<td class="px-4 py-3 text-gray-600">{{ $participant->primaryActivity?->name_ar ?? '—' }}</td>
<td class="px-4 py-3 text-center">
@php
$membershipValue = $participant->membership_type?->value ?? $participant->membership_type;
@endphp
@if($membershipValue === 'member')
<span class="px-2 py-0.5 text-xs bg-green-100 text-green-700 rounded-full">{{ __('عضو') }}</span>
@if($participant->membership_id)
<p class="text-xs text-gray-500 mt-0.5 font-mono" dir="ltr">{{ $participant->membership_id }}</p>
@endif
@elseif($membershipValue === 'non_member')
<span class="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded-full">{{ __('غير عضو') }}</span>
@else
<span class="text-gray-400"></span>
@endif
</td>
<td class="px-4 py-3 text-center">
@php
$statusValue = $participant->status->value ?? $participant->status;
......@@ -110,7 +126,7 @@ class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 f
</tr>
@empty
<tr>
<td colspan="6" class="px-4 py-12 text-center">
<td colspan="7" class="px-4 py-12 text-center">
<div class="flex flex-col items-center">
<svg class="w-12 h-12 text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/>
......@@ -158,10 +174,14 @@ class="w-full px-3 sm:px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 f
</span>
</div>
<div class="mt-3 flex items-center gap-4 text-xs text-gray-500">
<div class="mt-3 flex items-center flex-wrap gap-2 text-xs text-gray-500">
@if($participant->primaryActivity?->name_ar)
<span class="truncate">{{ $participant->primaryActivity->name_ar }}</span>
@endif
@php $mobileMemType = $participant->membership_type?->value ?? $participant->membership_type; @endphp
@if($mobileMemType === 'member')
<span class="px-1.5 py-0.5 bg-green-100 text-green-700 rounded text-[10px]">{{ __('عضو') }}{{ $participant->membership_id ? ' #'.$participant->membership_id : '' }}</span>
@endif
@if($participant->registration_date)
<span dir="ltr" class="whitespace-nowrap">{{ $participant->registration_date->format('Y-m-d') }}</span>
@endif
......
......@@ -233,7 +233,7 @@ class="pb-3 border-b-2 text-sm font-medium transition whitespace-nowrap">
<dl class="space-y-3">
<div class="flex justify-between">
<dt class="text-sm text-gray-500">{{ __('الجنس') }}</dt>
<dd class="text-sm text-gray-800">{{ $participant->person?->gender === 'male' ? __('ذكر') : __('أنثى') }}</dd>
<dd class="text-sm text-gray-800">{{ match($participant->person?->gender) { 'male' => __('ذكر'), 'female' => __('أنثى'), default => '—' } }}</dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">{{ __('تاريخ الميلاد') }}</dt>
......
<div>
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-3 mb-4 sm:mb-6">
<div>
<div class="flex items-center gap-2 mb-1">
<a href="{{ route('reports.hub') }}" wire:navigate class="text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.5 19.5L3 12m0 0l7.5-7.5M3 12h18"/></svg>
</a>
<h1 class="text-xl sm:text-2xl font-bold text-gray-800">{{ __($config['name']) }}</h1>
</div>
</div>
<a href="{{ $exportUrl }}"
class="inline-flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 text-sm 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 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 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>
{{ __('تصدير CSV') }}
</a>
</div>
<!-- Filters -->
@if($config['uses_dates'])
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-4">
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('من تاريخ') }}</label>
<input type="date" wire:model.live="from" dir="ltr"
class="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">{{ __('إلى تاريخ') }}</label>
<input type="date" wire:model.live="to" dir="ltr"
class="w-full rounded-lg border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 text-sm">
</div>
</div>
</div>
@endif
<!-- Results -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden" wire:loading.class="opacity-50 pointer-events-none">
@if($data instanceof \Illuminate\Support\Collection || $data instanceof \Illuminate\Database\Eloquent\Collection)
@if($data->isEmpty())
<div class="p-12 text-center">
<svg class="w-12 h-12 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m5.231 13.481L15 17.25m-4.5-15H5.625c-.621 0-1.125.504-1.125 1.125v16.5c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9zm3.75 11.625a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/></svg>
<p class="text-gray-500 text-sm">{{ __('لا توجد بيانات للفترة المحددة') }}</p>
</div>
@else
<div class="px-4 py-3 border-b border-gray-200 bg-gray-50">
<p class="text-sm text-gray-600">
{{ __('النتائج:') }} <span class="font-bold">{{ number_format($data->count()) }}</span> {{ __('سجل') }}
</p>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
@foreach($config['headers'] as $header)
<th class="px-4 py-3 text-start font-medium text-gray-600 whitespace-nowrap">{{ __($header) }}</th>
@endforeach
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
@foreach($data as $row)
<tr class="hover:bg-gray-50">
@foreach($config['columns'] as $col)
<td class="px-4 py-2.5 text-gray-700 whitespace-nowrap">
@php
$value = is_array($row) ? ($row[$col] ?? '') : ($row->$col ?? '');
$isMoney = in_array($col, $config['money_cols'] ?? []);
@endphp
@if($isMoney)
<span dir="ltr">{{ number_format($value / 100, 2) }}</span>
@else
{{ $value }}
@endif
</td>
@endforeach
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@else
<div class="p-12 text-center">
<p class="text-gray-500 text-sm">{{ __('لا توجد بيانات') }}</p>
</div>
@endif
</div>
</div>
<div>
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-800">{{ __('التقارير') }}</h1>
<p class="text-sm text-gray-500 mt-1">{{ __('30 تقرير جاهز للتصدير — اختر التقرير المطلوب') }}</p>
</div>
@foreach($categories as $catKey => $category)
<div class="mb-8">
<div class="flex items-center gap-2 mb-3">
<div class="w-8 h-8 rounded-lg bg-{{ $category['color'] }}-100 flex items-center justify-center">
@switch($category['icon'])
@case('banknotes')
<svg class="w-4 h-4 text-{{ $category['color'] }}-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.25 18.75a60.07 60.07 0 0115.797 2.101c.727.198 1.453-.342 1.453-1.096V18.75M3.75 4.5v.75A.75.75 0 013 6h-.75m0 0v-.375c0-.621.504-1.125 1.125-1.125H20.25M2.25 6v9m18-10.5v.75c0 .414.336.75.75.75h.75m-1.5-1.5h.375c.621 0 1.125.504 1.125 1.125v9.75c0 .621-.504 1.125-1.125 1.125h-.375m1.5-1.5H21a.75.75 0 00-.75.75v.75m0 0H3.75m0 0h-.375a1.125 1.125 0 01-1.125-1.125V15m1.5 1.5v-.75A.75.75 0 003 15h-.75M15 10.5a3 3 0 11-6 0 3 3 0 016 0zm3 0h.008v.008H18V10.5zm-12 0h.008v.008H6V10.5z"/></svg>
@break
@case('users')
<svg class="w-4 h-4 text-{{ $category['color'] }}-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z"/></svg>
@break
@case('clipboard-check')
<svg class="w-4 h-4 text-{{ $category['color'] }}-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/></svg>
@break
@case('academic-cap')
<svg class="w-4 h-4 text-{{ $category['color'] }}-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.26 10.147a60.436 60.436 0 00-.491 6.347A48.627 48.627 0 0112 20.904a48.627 48.627 0 018.232-4.41 60.46 60.46 0 00-.491-6.347m-15.482 0a50.57 50.57 0 00-2.658-.813A59.905 59.905 0 0112 3.493a59.902 59.902 0 0110.399 5.84c-.896.248-1.783.52-2.658.814m-15.482 0A50.697 50.697 0 0112 13.489a50.702 50.702 0 017.74-3.342M6.75 15a.75.75 0 100-1.5.75.75 0 000 1.5zm0 0v-3.675A55.378 55.378 0 0112 8.443m-7.007 11.55A5.981 5.981 0 006.75 15.75v-1.5"/></svg>
@break
@case('cog')
<svg class="w-4 h-4 text-{{ $category['color'] }}-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
@break
@case('cube')
<svg class="w-4 h-4 text-{{ $category['color'] }}-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 7.5l-9-5.25L3 7.5m18 0l-9 5.25m9-5.25v9l-9 5.25M3 7.5l9 5.25M3 7.5v9l9 5.25m0-9v9"/></svg>
@break
@endswitch
</div>
<h2 class="text-lg font-semibold text-gray-800">{{ __($category['label']) }}</h2>
<span class="text-xs text-gray-400">({{ count($category['reports']) }})</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
@foreach($category['reports'] as $report)
<a href="{{ route('reports.viewer', ['report' => $report['key']]) }}" wire:navigate
class="group block bg-white rounded-xl border border-gray-200 p-4 hover:border-{{ $category['color'] }}-300 hover:shadow-md transition-all">
<h3 class="font-medium text-gray-800 group-hover:text-{{ $category['color'] }}-700 text-sm">{{ __($report['name']) }}</h3>
<p class="text-xs text-gray-500 mt-1 line-clamp-2">{{ __($report['desc']) }}</p>
<div class="mt-3 flex items-center gap-1 text-xs text-{{ $category['color'] }}-600 opacity-0 group-hover:opacity-100 transition-opacity">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3"/></svg>
<span>{{ __('عرض التقرير') }}</span>
</div>
</a>
@endforeach
</div>
</div>
@endforeach
</div>
......@@ -420,7 +420,11 @@
->middleware('permission:notifications.manage');
// Reports
Route::get('/reports', \App\Livewire\Reports\ReportsPage::class)->name('reports.view')
Route::get('/reports', \App\Livewire\Reports\ReportsHub::class)->name('reports.hub')
->middleware('permission:reports.view');
Route::get('/reports/view', \App\Livewire\Reports\ReportViewer::class)->name('reports.viewer')
->middleware('permission:reports.view');
Route::get('/reports/legacy', \App\Livewire\Reports\ReportsPage::class)->name('reports.view')
->middleware('permission:reports.view');
Route::get('/reports/financial', \App\Livewire\Reports\FinancialReport::class)->name('reports.financial')
->middleware('permission:reports.view');
......@@ -462,6 +466,8 @@
->middleware('permission:super_admin.access');
// Exports
Route::get('/export/report', [\App\Http\Controllers\ExportController::class, 'report'])
->name('export.report')->middleware('permission:reports.view');
Route::get('/export/participants', [\App\Http\Controllers\ExportController::class, 'participants'])
->name('export.participants')->middleware('permission:participants.list');
Route::get('/export/payments', [\App\Http\Controllers\ExportController::class, 'payments'])
......
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