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 ...@@ -14,7 +14,7 @@ public function handle(PaymentReceived $event): void
try { try {
$payment = $event->payment; $payment = $event->payment;
if ($payment->method !== 'cash') { if ($payment->method !== \App\Domain\Financial\Enums\PaymentMethod::Cash) {
return; return;
} }
......
...@@ -2,8 +2,12 @@ ...@@ -2,8 +2,12 @@
namespace App\Domain\Financial\Services; namespace App\Domain\Financial\Services;
use App\Domain\Financial\Enums\PaymentMethod;
use App\Domain\Financial\Enums\PaymentStatus; 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\Payment;
use App\Domain\Financial\Models\Transaction;
use App\Domain\Shared\Exceptions\DomainException; use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Training\Models\Enrollment; use App\Domain\Training\Models\Enrollment;
use App\Models\User; use App\Models\User;
...@@ -12,6 +16,7 @@ class RefundService ...@@ -12,6 +16,7 @@ class RefundService
{ {
public function __construct( public function __construct(
private readonly InvoiceService $invoiceService, private readonly InvoiceService $invoiceService,
private readonly CashSessionService $cashSessionService,
) {} ) {}
/** /**
...@@ -116,9 +121,35 @@ public function processRefunds(array $paymentUuids, string $reason, User $actor) ...@@ -116,9 +121,35 @@ public function processRefunds(array $paymentUuids, string $reason, User $actor)
'created_by' => $actor->id, '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]); $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 // Reverse the invoice balance
if ($payment->invoice_id) { if ($payment->invoice_id) {
$this->invoiceService->updatePaidAmount( $this->invoiceService->updatePaidAmount(
......
...@@ -4,15 +4,633 @@ ...@@ -4,15 +4,633 @@
use App\Domain\Attendance\Enums\AttendanceStatus; use App\Domain\Attendance\Enums\AttendanceStatus;
use App\Domain\Attendance\Models\AttendanceRecord; use App\Domain\Attendance\Models\AttendanceRecord;
use App\Domain\Financial\Models\CashSession;
use App\Domain\Financial\Models\Installment;
use App\Domain\Financial\Models\Invoice; use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment; use App\Domain\Financial\Models\Payment;
use App\Domain\Financial\Models\Wallet;
use App\Domain\HR\Models\Trainer;
use App\Domain\Inventory\Models\InventoryMovement;
use App\Domain\Inventory\Models\Product;
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use App\Domain\POS\Models\POSTransaction; use App\Domain\POS\Models\POSTransaction;
use App\Domain\Training\Models\Enrollment; use App\Domain\Training\Models\Enrollment;
use App\Domain\Training\Models\TrainingGroup;
use App\Domain\Training\Models\TrainingProgram;
use App\Domain\Training\Models\TrainingSession;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
class ReportService class ReportService
{ {
// ─── FINANCIAL REPORTS ────────────────────────────────────────
public function dailyRevenue(string $from, string $to, ?int $branchId = null): Collection
{
return Payment::where('status', 'confirmed')
->whereBetween('payment_date', [$from, $to])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->select(
DB::raw("DATE(payment_date) as date"),
DB::raw("SUM(amount) as total"),
DB::raw("COUNT(*) as count"),
'method'
)
->groupBy('date', 'method')
->orderBy('date')
->get();
}
public function outstandingBalances(string $from, string $to, ?int $branchId = null): Collection
{
return Invoice::with(['billable.person'])
->whereIn('status', ['sent', 'partially_paid', 'overdue'])
->where('due_amount', '>', 0)
->when($branchId, fn ($q) => $q->whereHasMorph('billable', [Participant::class], fn ($p) => $p->where('branch_id', $branchId)))
->when($from, fn ($q) => $q->where('created_at', '>=', $from))
->when($to, fn ($q) => $q->where('created_at', '<=', $to . ' 23:59:59'))
->orderByDesc('due_amount')
->get()
->map(fn ($inv) => [
'invoice_number' => $inv->number,
'participant' => $inv->billable?->person?->name_ar ?? $inv->contact_name ?? '',
'phone' => $inv->billable?->person?->phone ?? $inv->contact_phone ?? '',
'total' => $inv->total_amount,
'paid' => $inv->paid_amount,
'due' => $inv->due_amount,
'status' => $inv->status?->value ?? $inv->status,
'due_date' => $inv->due_date?->format('Y-m-d') ?? '',
'days_overdue' => $inv->due_date && $inv->due_date->isPast() ? (int) $inv->due_date->diffInDays(now()) : 0,
]);
}
public function paymentMethodBreakdown(string $from, string $to, ?int $branchId = null): Collection
{
return Payment::where('status', 'confirmed')
->whereBetween('payment_date', [$from, $to])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->select('method', DB::raw('SUM(amount) as total'), DB::raw('COUNT(*) as count'))
->groupBy('method')
->orderByDesc('total')
->get();
}
public function refundReport(string $from, string $to, ?int $branchId = null): Collection
{
return Payment::with(['invoice.billable.person', 'creator'])
->where('status', 'refunded')
->whereBetween('payment_date', [$from, $to])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->orderByDesc('payment_date')
->get()
->map(fn ($p) => [
'date' => $p->payment_date?->format('Y-m-d'),
'participant' => $p->invoice?->billable?->person?->name_ar ?? '',
'amount' => $p->amount,
'method' => $p->method?->value ?? '',
'invoice' => $p->invoice?->number ?? '',
'processed_by' => $p->creator?->name ?? '',
'reference' => $p->reference ?? '',
]);
}
public function installmentsDue(string $from, string $to, ?int $branchId = null): Collection
{
return Installment::with(['paymentPlan.invoice.billable.person'])
->where('status', '!=', 'paid')
->whereBetween('due_date', [$from, $to])
->when($branchId, fn ($q) => $q->whereHas('paymentPlan.invoice.billable', fn ($p) => $p->where('branch_id', $branchId)))
->orderBy('due_date')
->get()
->map(fn ($inst) => [
'participant' => $inst->paymentPlan?->invoice?->billable?->person?->name_ar ?? '',
'phone' => $inst->paymentPlan?->invoice?->billable?->person?->phone ?? '',
'amount' => $inst->amount,
'paid' => $inst->paid_amount ?? 0,
'due_date' => $inst->due_date?->format('Y-m-d'),
'status' => $inst->status,
'invoice' => $inst->paymentPlan?->invoice?->number ?? '',
]);
}
public function cashSessionSummary(string $from, string $to, ?int $branchId = null): Collection
{
return CashSession::with(['user', 'branch'])
->whereBetween('opened_at', [$from, $to . ' 23:59:59'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->orderByDesc('opened_at')
->get()
->map(fn ($cs) => [
'user' => $cs->user?->name ?? '',
'branch' => $cs->branch?->name_ar ?? '',
'opened_at' => $cs->opened_at?->format('Y-m-d H:i'),
'closed_at' => $cs->closed_at?->format('Y-m-d H:i') ?? '—',
'opening_balance' => $cs->opening_balance,
'cash_in' => $cs->cash_in,
'cash_out' => $cs->cash_out,
'expected_balance' => $cs->opening_balance + $cs->cash_in - $cs->cash_out,
'actual_balance' => $cs->closing_balance,
'variance' => ($cs->closing_balance ?? 0) - ($cs->opening_balance + $cs->cash_in - $cs->cash_out),
'status' => $cs->status ?? ($cs->closed_at ? 'closed' : 'open'),
]);
}
// ─── PARTICIPANT REPORTS ─────────────────────────────────────
public function newRegistrations(string $from, string $to, ?int $branchId = null): Collection
{
return Participant::with(['person', 'branch', 'primaryActivity'])
->whereBetween('registration_date', [$from, $to])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->orderByDesc('registration_date')
->get()
->map(fn ($p) => [
'name' => $p->person?->name_ar ?? '',
'phone' => $p->person?->phone ?? '',
'age' => $p->person?->date_of_birth ? $p->person->date_of_birth->age : '',
'gender' => $p->person?->gender ?? '',
'activity' => $p->primaryActivity?->name_ar ?? '',
'branch' => $p->branch?->name_ar ?? '',
'source' => $p->registration_source?->value ?? $p->registration_source ?? '',
'date' => $p->registration_date?->format('Y-m-d'),
'status' => $p->status?->value ?? $p->status,
]);
}
public function participantsByStatus(?int $branchId = null): Collection
{
return Participant::when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->select('status', DB::raw('COUNT(*) as count'))
->groupBy('status')
->orderByDesc('count')
->get();
}
public function participantsByAge(?int $branchId = null): Collection
{
return Participant::with('person')
->where('status', 'active')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->get()
->map(fn ($p) => $p->person?->date_of_birth ? $p->person->date_of_birth->age : null)
->filter()
->groupBy(function ($age) {
if ($age < 6) return '0-5';
if ($age < 10) return '6-9';
if ($age < 14) return '10-13';
if ($age < 18) return '14-17';
return '18+';
})
->map(fn ($group, $range) => ['range' => $range, 'count' => $group->count()])
->sortBy('range')
->values();
}
public function participantsByGender(?int $branchId = null): Collection
{
return Participant::join('people', 'participants.person_id', '=', 'people.id')
->where('participants.status', 'active')
->when($branchId, fn ($q) => $q->where('participants.branch_id', $branchId))
->select('people.gender', DB::raw('COUNT(*) as count'))
->groupBy('people.gender')
->get();
}
public function expiredMemberships(string $from, string $to, ?int $branchId = null): Collection
{
return Participant::with(['person', 'branch'])
->where('membership_type', 'member')
->whereBetween('membership_expires_at', [$from, $to])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->orderBy('membership_expires_at')
->get()
->map(fn ($p) => [
'name' => $p->person?->name_ar ?? '',
'phone' => $p->person?->phone ?? '',
'membership_id' => $p->membership_id ?? '',
'expires_at' => $p->membership_expires_at?->format('Y-m-d'),
'status' => $p->status?->value ?? $p->status,
'branch' => $p->branch?->name_ar ?? '',
'days_until' => $p->membership_expires_at?->diffInDays(now(), false),
]);
}
public function frozenAndSuspended(?int $branchId = null): Collection
{
return Participant::with(['person', 'branch'])
->whereIn('status', ['frozen', 'suspended'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->orderBy('status_changed_at')
->get()
->map(fn ($p) => [
'name' => $p->person?->name_ar ?? '',
'phone' => $p->person?->phone ?? '',
'status' => $p->status?->value ?? $p->status,
'reason' => $p->status_reason ?? '',
'since' => $p->status_changed_at?->format('Y-m-d') ?? '',
'branch' => $p->branch?->name_ar ?? '',
'balance' => $p->outstanding_balance ?? 0,
]);
}
// ─── ATTENDANCE REPORTS ──────────────────────────────────────
public function attendanceByGroup(string $from, string $to, ?int $branchId = null): Collection
{
return TrainingGroup::with('program')
->where('status', 'active')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->get()
->map(function ($group) use ($from, $to) {
$records = AttendanceRecord::whereHas('session', fn ($q) => $q->where('training_group_id', $group->id))
->whereBetween('created_at', [$from, $to . ' 23:59:59']);
$total = (clone $records)->count();
$excluded = (clone $records)->whereIn('status', [AttendanceStatus::Cancelled, AttendanceStatus::Exempt])->count();
$positive = (clone $records)->whereIn('status', [AttendanceStatus::Present, AttendanceStatus::Late, AttendanceStatus::Partial])->count();
$denominator = $total - $excluded;
return [
'group' => $group->name_ar,
'program' => $group->program?->name_ar ?? '',
'total_records' => $total,
'present' => $positive,
'absent' => (clone $records)->where('status', AttendanceStatus::Absent)->count(),
'rate' => $denominator > 0 ? round(($positive / $denominator) * 100, 1) : 0,
];
})
->sortBy('rate');
}
public function absenteeReport(string $from, string $to, ?int $branchId = null): Collection
{
return AttendanceRecord::with(['subject', 'session.group'])
->whereIn('status', [AttendanceStatus::Absent, AttendanceStatus::NoShow])
->whereBetween('created_at', [$from, $to . ' 23:59:59'])
->when($branchId, fn ($q) => $q->whereHas('session.group', fn ($g) => $g->where('branch_id', $branchId)))
->get()
->groupBy('subject_id')
->map(function ($records) {
$first = $records->first();
$person = null;
if ($first->subject_type === 'App\\Domain\\Participant\\Models\\Participant') {
$person = $first->subject?->person;
}
return [
'name' => $person?->name_ar ?? '',
'phone' => $person?->phone ?? '',
'absent_count' => $records->count(),
'groups' => $records->pluck('session.group.name_ar')->unique()->filter()->implode(', '),
'last_absence' => $records->max('created_at')?->format('Y-m-d') ?? '',
];
})
->sortByDesc('absent_count')
->values();
}
public function trainerAttendance(string $from, string $to, ?int $branchId = null): Collection
{
return Trainer::with(['person'])
->where('status', 'active')
->get()
->map(function ($trainer) use ($from, $to, $branchId) {
$records = AttendanceRecord::where('subject_type', 'App\\Domain\\HR\\Models\\Trainer')
->where('subject_id', $trainer->id)
->whereBetween('created_at', [$from, $to . ' 23:59:59'])
->when($branchId, fn ($q) => $q->whereHas('session.group', fn ($g) => $g->where('branch_id', $branchId)));
$total = (clone $records)->count();
$present = (clone $records)->whereIn('status', [AttendanceStatus::Present, AttendanceStatus::Late])->count();
return [
'name' => $trainer->person?->name_ar ?? '',
'phone' => $trainer->person?->phone ?? '',
'total_sessions' => $total,
'attended' => $present,
'absent' => (clone $records)->where('status', AttendanceStatus::Absent)->count(),
'rate' => $total > 0 ? round(($present / $total) * 100, 1) : 0,
];
})
->filter(fn ($t) => $t['total_sessions'] > 0)
->sortBy('rate')
->values();
}
// ─── ENROLLMENT REPORTS ──────────────────────────────────────
public function enrollmentsByProgram(string $from, string $to, ?int $branchId = null): Collection
{
return TrainingProgram::withCount(['enrollments' => function ($q) use ($from, $to, $branchId) {
$q->whereBetween('enrollment_date', [$from, $to])
->when($branchId, fn ($q2) => $q2->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)));
}])
->having('enrollments_count', '>', 0)
->orderByDesc('enrollments_count')
->get()
->map(fn ($prog) => [
'program' => $prog->name_ar,
'enrollments' => $prog->enrollments_count,
'status' => $prog->status ?? 'active',
]);
}
public function groupCapacity(?int $branchId = null): Collection
{
return TrainingGroup::with(['program', 'branch'])
->where('status', 'active')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->get()
->map(fn ($g) => [
'group' => $g->name_ar,
'program' => $g->program?->name_ar ?? '',
'branch' => $g->branch?->name_ar ?? '',
'current' => $g->current_count ?? 0,
'max' => $g->max_capacity ?? 0,
'fill_rate' => $g->max_capacity > 0 ? round(($g->current_count / $g->max_capacity) * 100, 1) : 0,
'available' => max(0, ($g->max_capacity ?? 0) - ($g->current_count ?? 0)),
])
->sortByDesc('fill_rate')
->values();
}
public function cancellationsReport(string $from, string $to, ?int $branchId = null): Collection
{
return Enrollment::with(['participant.person', 'group.program'])
->where('status', 'cancelled')
->whereBetween('updated_at', [$from, $to . ' 23:59:59'])
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->orderByDesc('updated_at')
->get()
->map(fn ($e) => [
'participant' => $e->participant?->person?->name_ar ?? '',
'phone' => $e->participant?->person?->phone ?? '',
'program' => $e->group?->program?->name_ar ?? '',
'group' => $e->group?->name_ar ?? '',
'enrolled_at' => $e->enrollment_date?->format('Y-m-d') ?? '',
'cancelled_at' => $e->updated_at?->format('Y-m-d'),
'reason' => $e->cancellation_reason ?? '',
]);
}
public function retentionReport(string $from, string $to, ?int $branchId = null): Collection
{
$active = Enrollment::where('status', 'active')
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->whereBetween('enrollment_date', [$from, $to])
->count();
$cancelled = Enrollment::where('status', 'cancelled')
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->whereBetween('enrollment_date', [$from, $to])
->count();
$completed = Enrollment::where('status', 'completed')
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->whereBetween('enrollment_date', [$from, $to])
->count();
$total = $active + $cancelled + $completed;
return collect([
['metric' => 'إجمالي التسجيلات', 'value' => $total],
['metric' => 'نشط', 'value' => $active],
['metric' => 'مكتمل', 'value' => $completed],
['metric' => 'ملغي', 'value' => $cancelled],
['metric' => 'نسبة الاستمرارية', 'value' => $total > 0 ? round((($active + $completed) / $total) * 100, 1) . '%' : '0%'],
]);
}
// ─── HR & TRAINER REPORTS ────────────────────────────────────
public function trainerWorkload(string $from, string $to, ?int $branchId = null): Collection
{
return Trainer::with(['person'])
->where('status', 'active')
->get()
->map(function ($trainer) use ($from, $to, $branchId) {
$sessions = TrainingSession::whereHas('group.assignments', fn ($q) => $q->where('trainer_id', $trainer->id))
->whereBetween('date', [$from, $to])
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)));
return [
'name' => $trainer->person?->name_ar ?? '',
'phone' => $trainer->person?->phone ?? '',
'total_sessions' => (clone $sessions)->count(),
'completed' => (clone $sessions)->where('status', 'completed')->count(),
'cancelled' => (clone $sessions)->where('status', 'cancelled')->count(),
'groups_count' => $trainer->assignments()->where('status', 'active')->distinct('training_group_id')->count('training_group_id'),
];
})
->filter(fn ($t) => $t['total_sessions'] > 0)
->sortByDesc('total_sessions')
->values();
}
// ─── INVENTORY REPORTS ───────────────────────────────────────
public function lowStockReport(?int $branchId = null): Collection
{
return Product::with(['category'])
->where('track_inventory', true)
->where('is_active', true)
->get()
->map(function ($product) {
$level = $product->inventoryLevels()->first();
$onHand = $level?->quantity_on_hand ?? 0;
$reorder = $product->reorder_point ?? 0;
return [
'product' => $product->name_ar ?? $product->name ?? '',
'sku' => $product->sku ?? '',
'category' => $product->category?->name_ar ?? '',
'on_hand' => $onHand,
'reorder_point' => $reorder,
'status' => $onHand <= 0 ? 'نفد' : ($onHand <= $reorder ? 'منخفض' : 'متوفر'),
];
})
->filter(fn ($p) => $p['on_hand'] <= $p['reorder_point'])
->sortBy('on_hand')
->values();
}
public function inventoryMovementReport(string $from, string $to, ?int $branchId = null): Collection
{
return InventoryMovement::with(['product', 'warehouse'])
->whereBetween('created_at', [$from, $to . ' 23:59:59'])
->when($branchId, fn ($q) => $q->whereHas('warehouse', fn ($w) => $w->where('branch_id', $branchId)))
->orderByDesc('created_at')
->limit(500)
->get()
->map(fn ($m) => [
'date' => $m->created_at?->format('Y-m-d'),
'product' => $m->product?->name_ar ?? '',
'warehouse' => $m->warehouse?->name_ar ?? '',
'type' => $m->movement_type ?? '',
'direction' => $m->direction ?? '',
'quantity' => $m->quantity,
'before' => $m->quantity_before ?? 0,
'after' => $m->quantity_after ?? 0,
'reference' => $m->reference ?? '',
]);
}
public function productSalesReport(string $from, string $to, ?int $branchId = null): Collection
{
return DB::table('pos_transaction_items')
->join('pos_transactions', 'pos_transactions.id', '=', 'pos_transaction_items.pos_transaction_id')
->join('products', 'products.id', '=', 'pos_transaction_items.product_id')
->whereBetween('pos_transactions.processed_at', [$from, $to . ' 23:59:59'])
->when($branchId, fn ($q) => $q->where('pos_transactions.branch_id', $branchId))
->whereNotNull('pos_transaction_items.product_id')
->select(
'products.name_ar as product',
'products.sku',
DB::raw('SUM(pos_transaction_items.quantity) as units_sold'),
DB::raw('SUM(pos_transaction_items.line_total) as revenue')
)
->groupBy('products.id', 'products.name_ar', 'products.sku')
->orderByDesc('revenue')
->get();
}
// ─── POS REPORTS ─────────────────────────────────────────────
public function posTransactionReport(string $from, string $to, ?int $branchId = null): Collection
{
return POSTransaction::with(['user', 'branch'])
->whereBetween('processed_at', [$from, $to . ' 23:59:59'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->orderByDesc('processed_at')
->limit(500)
->get()
->map(fn ($t) => [
'date' => $t->processed_at?->format('Y-m-d H:i'),
'number' => $t->transaction_number ?? '',
'total' => $t->total_amount,
'discount' => $t->discount_amount ?? 0,
'items_count' => $t->items_count ?? 0,
'payment_method' => $t->payment_method?->value ?? $t->payment_method ?? '',
'cashier' => $t->user?->name ?? '',
'branch' => $t->branch?->name_ar ?? '',
]);
}
public function posDailySummary(string $from, string $to, ?int $branchId = null): Collection
{
return POSTransaction::whereBetween('processed_at', [$from, $to . ' 23:59:59'])
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->select(
DB::raw("DATE(processed_at) as date"),
DB::raw('SUM(total_amount) as total'),
DB::raw('SUM(discount_amount) as discounts'),
DB::raw('COUNT(*) as transactions')
)
->groupBy('date')
->orderBy('date')
->get();
}
// ─── OPERATIONAL REPORTS ─────────────────────────────────────
public function sessionCompletionReport(string $from, string $to, ?int $branchId = null): Collection
{
return TrainingSession::with(['group.program', 'group.branch'])
->whereBetween('date', [$from, $to])
->when($branchId, fn ($q) => $q->whereHas('group', fn ($g) => $g->where('branch_id', $branchId)))
->select('status', DB::raw('COUNT(*) as count'))
->groupBy('status')
->get();
}
public function branchComparison(string $from, string $to): Collection
{
return DB::table('branches')
->where('branches.is_active', true)
->leftJoin('participants', function ($join) {
$join->on('branches.id', '=', 'participants.branch_id')
->where('participants.status', '=', 'active');
})
->select(
'branches.id',
'branches.name_ar as branch',
DB::raw('COUNT(DISTINCT participants.id) as active_participants')
)
->groupBy('branches.id', 'branches.name_ar')
->get()
->map(function ($branch) use ($from, $to) {
$revenue = Payment::where('status', 'confirmed')
->where('branch_id', $branch->id)
->whereBetween('payment_date', [$from, $to])
->sum('amount');
$enrollments = Enrollment::whereHas('group', fn ($q) => $q->where('branch_id', $branch->id))
->whereBetween('enrollment_date', [$from, $to])
->count();
return [
'branch' => $branch->branch,
'active_participants' => $branch->active_participants,
'revenue' => $revenue,
'new_enrollments' => $enrollments,
];
});
}
public function walletBalances(?int $branchId = null): Collection
{
return Wallet::with(['participant.person'])
->where('status', 'active')
->where('balance', '>', 0)
->when($branchId, fn ($q) => $q->whereHas('participant', fn ($p) => $p->where('branch_id', $branchId)))
->orderByDesc('balance')
->get()
->map(fn ($w) => [
'participant' => $w->participant?->person?->name_ar ?? '',
'phone' => $w->participant?->person?->phone ?? '',
'balance' => $w->balance,
'frozen' => $w->frozen_amount ?? 0,
'available' => $w->balance - ($w->frozen_amount ?? 0),
'last_transaction' => $w->updated_at?->format('Y-m-d'),
]);
}
public function revenueByActivity(string $from, string $to, ?int $branchId = null): Collection
{
return DB::table('invoice_items')
->join('invoices', 'invoices.id', '=', 'invoice_items.invoice_id')
->join('training_programs', 'training_programs.id', '=', 'invoice_items.itemable_id')
->join('activities', 'activities.id', '=', 'training_programs.activity_id')
->where('invoice_items.itemable_type', 'App\\Domain\\Training\\Models\\TrainingProgram')
->whereIn('invoices.status', ['paid', 'partially_paid'])
->whereBetween('invoices.created_at', [$from, $to . ' 23:59:59'])
->when($branchId, fn ($q) => $q->whereHasMorph('invoices.billable', [Participant::class], fn ($p) => $p->where('branch_id', $branchId)))
->select(
'activities.name_ar as activity',
DB::raw('SUM(invoice_items.line_total) as revenue'),
DB::raw('COUNT(DISTINCT invoices.id) as invoices_count')
)
->groupBy('activities.id', 'activities.name_ar')
->orderByDesc('revenue')
->get();
}
public function overdueInvoicesAging(?int $branchId = null): Collection
{
return Invoice::with(['billable.person'])
->where('status', 'overdue')
->when($branchId, fn ($q) => $q->whereHasMorph('billable', [Participant::class], fn ($p) => $p->where('branch_id', $branchId)))
->orderBy('due_date')
->get()
->map(function ($inv) {
$days = $inv->due_date ? (int) $inv->due_date->diffInDays(now()) : 0;
return [
'invoice' => $inv->number,
'participant' => $inv->billable?->person?->name_ar ?? $inv->contact_name ?? '',
'phone' => $inv->billable?->person?->phone ?? $inv->contact_phone ?? '',
'total' => $inv->total_amount,
'due' => $inv->due_amount,
'due_date' => $inv->due_date?->format('Y-m-d') ?? '',
'days_overdue' => $days,
'aging_bucket' => $days <= 30 ? '1-30 يوم' : ($days <= 60 ? '31-60 يوم' : ($days <= 90 ? '61-90 يوم' : '90+ يوم')),
];
});
}
// ─── LEGACY (kept for old reports page) ─────────────────────
public function financialSummary(string $from, string $to, ?int $branchId = null): array public function financialSummary(string $from, string $to, ?int $branchId = null): array
{ {
return [ return [
...@@ -79,7 +697,7 @@ public function enrollmentReport(string $from, string $to, ?int $branchId = null ...@@ -79,7 +697,7 @@ public function enrollmentReport(string $from, string $to, ?int $branchId = null
]; ];
} }
public function participantList(array $filters = []): \Illuminate\Support\Collection public function participantList(array $filters = []): Collection
{ {
$query = Participant::with(['person', 'enrollments.group']) $query = Participant::with(['person', 'enrollments.group'])
->when($filters['status'] ?? null, fn ($q, $s) => $q->where('status', $s)) ->when($filters['status'] ?? null, fn ($q, $s) => $q->where('status', $s))
......
...@@ -8,7 +8,11 @@ ...@@ -8,7 +8,11 @@
{ {
public function getActiveBranchId(): ?int 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 public function getActiveBranchIdOrFail(): int
...@@ -32,6 +36,6 @@ public function getActiveBranchIdOrFail(): int ...@@ -32,6 +36,6 @@ public function getActiveBranchIdOrFail(): int
public function isAllBranches(): bool 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 @@ ...@@ -5,7 +5,9 @@
use App\Domain\Financial\Models\Invoice; use App\Domain\Financial\Models\Invoice;
use App\Domain\Financial\Models\Payment; use App\Domain\Financial\Models\Payment;
use App\Domain\Participant\Models\Participant; use App\Domain\Participant\Models\Participant;
use App\Domain\Shared\Services\ReportService;
use App\Domain\Training\Models\Enrollment; use App\Domain\Training\Models\Enrollment;
use App\Livewire\Reports\ReportViewer;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Gate;
use Symfony\Component\HttpFoundation\StreamedResponse; use Symfony\Component\HttpFoundation\StreamedResponse;
...@@ -146,6 +148,60 @@ public function enrollments(Request $request): 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 private function streamCsv(string $filename, array $headers, $query, callable $rowMapper): StreamedResponse
{ {
$date = now()->format('Y-m-d'); $date = now()->format('Y-m-d');
......
...@@ -7,33 +7,34 @@ ...@@ -7,33 +7,34 @@
class BranchSwitcher extends Component class BranchSwitcher extends Component
{ {
public ?int $activeBranchId = null; public string $selectedBranch = 'all';
public function mount(): void public function mount(): void
{ {
$this->activeBranchId = session('active_branch_id', auth()->user()->branch_id); if (session()->has('active_branch_id')) {
$value = session('active_branch_id');
if (!$this->activeBranchId) { $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(); $first = Branch::where('is_active', true)->first();
if ($first) { $branchId = $first?->id;
$this->activeBranchId = $first->id;
session(['active_branch_id' => $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') { if ($value === 'all') {
session(['active_branch_id' => null]); session(['active_branch_id' => null]);
$this->activeBranchId = null;
} else { } else {
$branchId = (int) $value; session(['active_branch_id' => (int) $value]);
session(['active_branch_id' => $branchId]);
$this->activeBranchId = $branchId;
} }
$this->dispatch('branch-switched', branchId: $this->activeBranchId); $this->dispatch('branch-switched');
$this->redirect(request()->header('Referer', '/'), navigate: true); $this->redirect(request()->header('Referer', '/'), navigate: true);
} }
......
...@@ -48,17 +48,25 @@ class ParticipantForm extends Component ...@@ -48,17 +48,25 @@ class ParticipantForm extends Component
public ?string $weight_kg = null; public ?string $weight_kg = null;
public string $notes = ''; public string $notes = '';
// Guardian phone (editable in edit mode)
public string $guardian_phone = '';
public function mount(?Participant $participant = null): void public function mount(?Participant $participant = null): void
{ {
$this->branch_id = session('active_branch_id', auth()->user()->branch_id); $this->branch_id = session('active_branch_id', auth()->user()->branch_id);
if ($participant && $participant->exists) { if ($participant && $participant->exists) {
$this->participant = $participant->load('person'); $this->participant = $participant->load(['person', 'primaryGuardian.person']);
$this->editing = true; $this->editing = true;
$this->person_id = $participant->person_id; $this->person_id = $participant->person_id;
$this->name_ar = $participant->person->name_ar ?? ''; $this->name_ar = $participant->person->name_ar ?? '';
$this->name = $participant->person->name ?? ''; $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->branch_id = $participant->branch_id;
$this->registration_source = $participant->registration_source->value ?? $participant->registration_source; $this->registration_source = $participant->registration_source->value ?? $participant->registration_source;
...@@ -77,6 +85,11 @@ public function mount(?Participant $participant = null): void ...@@ -77,6 +85,11 @@ public function mount(?Participant $participant = null): void
$this->height_cm = $participant->height_cm; $this->height_cm = $participant->height_cm;
$this->weight_kg = $participant->weight_kg; $this->weight_kg = $participant->weight_kg;
$this->notes = $participant->notes ?? ''; $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 ...@@ -103,7 +116,16 @@ public function rules(): array
'notes' => 'nullable|string', '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_ar'] = 'required|string|max:255';
$rules['name'] = 'required|string|max:255'; $rules['name'] = 'required|string|max:255';
$rules['gender'] = 'required|in:male,female'; $rules['gender'] = 'required|in:male,female';
...@@ -163,6 +185,24 @@ public function save(ParticipantService $service): void ...@@ -163,6 +185,24 @@ public function save(ParticipantService $service): void
try { try {
if ($this->editing) { 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, [ $service->update($this->participant, [
'primary_activity_id' => $this->primary_activity_id, 'primary_activity_id' => $this->primary_activity_id,
'primary_guardian_id' => $this->primary_guardian_id, 'primary_guardian_id' => $this->primary_guardian_id,
......
...@@ -60,10 +60,12 @@ public function render() ...@@ -60,10 +60,12 @@ public function render()
$search = $this->search; $search = $this->search;
$q->where(function ($q2) use ($search) { $q->where(function ($q2) use ($search) {
$q2->where('participant_number', 'ilike', "%{$search}%") $q2->where('participant_number', 'ilike', "%{$search}%")
->orWhere('membership_id', 'ilike', "%{$search}%")
->orWhereHas('person', function ($pq) use ($search) { ->orWhereHas('person', function ($pq) use ($search) {
$pq->where('name_ar', 'ilike', "%{$search}%") $pq->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%") ->orWhere('name', 'ilike', "%{$search}%")
->orWhere('phone', 'like', "%{$search}%"); ->orWhere('phone', 'like', "%{$search}%")
->orWhere('national_id', 'like', "%{$search}%");
}); });
}); });
}) })
......
<?php
namespace App\Livewire\Reports;
use App\Domain\Shared\Services\ReportService;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('عرض التقرير')]
class ReportViewer extends Component
{
use UsesBranchScope;
#[Url]
public string $report = '';
#[Url]
public string $from = '';
#[Url]
public string $to = '';
public function mount(string $report = ''): void
{
$this->authorize('reports.view');
$this->report = $report ?: request('report', '');
$this->from = request('from', now()->startOfMonth()->format('Y-m-d'));
$this->to = request('to', now()->format('Y-m-d'));
if (!$this->report || !array_key_exists($this->report, $this->getReportConfig())) {
abort(404);
}
}
public function getReportConfig(): array
{
return [
'daily_revenue' => [
'name' => 'الإيرادات اليومية',
'method' => 'dailyRevenue',
'headers' => ['التاريخ', 'طريقة الدفع', 'المبلغ', 'عدد العمليات'],
'columns' => ['date', 'method', 'total', 'count'],
'money_cols' => ['total'],
'uses_dates' => true,
],
'outstanding_balances' => [
'name' => 'الأرصدة المعلقة',
'method' => 'outstandingBalances',
'headers' => ['رقم الفاتورة', 'المشترك', 'الهاتف', 'الإجمالي', 'المدفوع', 'المستحق', 'الحالة', 'تاريخ الاستحقاق', 'أيام التأخير'],
'columns' => ['invoice_number', 'participant', 'phone', 'total', 'paid', 'due', 'status', 'due_date', 'days_overdue'],
'money_cols' => ['total', 'paid', 'due'],
'uses_dates' => true,
],
'payment_methods' => [
'name' => 'طرق الدفع',
'method' => 'paymentMethodBreakdown',
'headers' => ['طريقة الدفع', 'المبلغ', 'عدد العمليات'],
'columns' => ['method', 'total', 'count'],
'money_cols' => ['total'],
'uses_dates' => true,
],
'refunds' => [
'name' => 'المرتجعات',
'method' => 'refundReport',
'headers' => ['التاريخ', 'المشترك', 'المبلغ', 'الطريقة', 'الفاتورة', 'بواسطة', 'المرجع'],
'columns' => ['date', 'participant', 'amount', 'method', 'invoice', 'processed_by', 'reference'],
'money_cols' => ['amount'],
'uses_dates' => true,
],
'installments_due' => [
'name' => 'الأقساط المستحقة',
'method' => 'installmentsDue',
'headers' => ['المشترك', 'الهاتف', 'المبلغ', 'المدفوع', 'تاريخ الاستحقاق', 'الحالة', 'الفاتورة'],
'columns' => ['participant', 'phone', 'amount', 'paid', 'due_date', 'status', 'invoice'],
'money_cols' => ['amount', 'paid'],
'uses_dates' => true,
],
'cash_sessions' => [
'name' => 'ملخص الورديات',
'method' => 'cashSessionSummary',
'headers' => ['الموظف', 'الفرع', 'الفتح', 'الإغلاق', 'رصيد الافتتاح', 'الوارد', 'الصادر', 'المتوقع', 'الفعلي', 'الفرق', 'الحالة'],
'columns' => ['user', 'branch', 'opened_at', 'closed_at', 'opening_balance', 'cash_in', 'cash_out', 'expected_balance', 'actual_balance', 'variance', 'status'],
'money_cols' => ['opening_balance', 'cash_in', 'cash_out', 'expected_balance', 'actual_balance', 'variance'],
'uses_dates' => true,
],
'overdue_aging' => [
'name' => 'تقادم الفواتير',
'method' => 'overdueInvoicesAging',
'headers' => ['الفاتورة', 'المشترك', 'الهاتف', 'الإجمالي', 'المستحق', 'تاريخ الاستحقاق', 'أيام التأخير', 'الفئة'],
'columns' => ['invoice', 'participant', 'phone', 'total', 'due', 'due_date', 'days_overdue', 'aging_bucket'],
'money_cols' => ['total', 'due'],
'uses_dates' => false,
],
'revenue_by_activity' => [
'name' => 'الإيرادات حسب النشاط',
'method' => 'revenueByActivity',
'headers' => ['النشاط', 'الإيرادات', 'عدد الفواتير'],
'columns' => ['activity', 'revenue', 'invoices_count'],
'money_cols' => ['revenue'],
'uses_dates' => true,
],
'new_registrations' => [
'name' => 'التسجيلات الجديدة',
'method' => 'newRegistrations',
'headers' => ['الاسم', 'الهاتف', 'العمر', 'الجنس', 'النشاط', 'الفرع', 'المصدر', 'التاريخ', 'الحالة'],
'columns' => ['name', 'phone', 'age', 'gender', 'activity', 'branch', 'source', 'date', 'status'],
'money_cols' => [],
'uses_dates' => true,
],
'by_status' => [
'name' => 'المشتركون حسب الحالة',
'method' => 'participantsByStatus',
'headers' => ['الحالة', 'العدد'],
'columns' => ['status', 'count'],
'money_cols' => [],
'uses_dates' => false,
],
'by_age' => [
'name' => 'المشتركون حسب الفئة العمرية',
'method' => 'participantsByAge',
'headers' => ['الفئة العمرية', 'العدد'],
'columns' => ['range', 'count'],
'money_cols' => [],
'uses_dates' => false,
],
'by_gender' => [
'name' => 'المشتركون حسب الجنس',
'method' => 'participantsByGender',
'headers' => ['الجنس', 'العدد'],
'columns' => ['gender', 'count'],
'money_cols' => [],
'uses_dates' => false,
],
'expired_memberships' => [
'name' => 'العضويات المنتهية',
'method' => 'expiredMemberships',
'headers' => ['الاسم', 'الهاتف', 'رقم العضوية', 'تاريخ الانتهاء', 'الحالة', 'الفرع', 'الأيام'],
'columns' => ['name', 'phone', 'membership_id', 'expires_at', 'status', 'branch', 'days_until'],
'money_cols' => [],
'uses_dates' => true,
],
'frozen_suspended' => [
'name' => 'المجمدون والموقوفون',
'method' => 'frozenAndSuspended',
'headers' => ['الاسم', 'الهاتف', 'الحالة', 'السبب', 'منذ', 'الفرع', 'الرصيد المعلق'],
'columns' => ['name', 'phone', 'status', 'reason', 'since', 'branch', 'balance'],
'money_cols' => ['balance'],
'uses_dates' => false,
],
'attendance_by_group' => [
'name' => 'الحضور حسب المجموعة',
'method' => 'attendanceByGroup',
'headers' => ['المجموعة', 'البرنامج', 'إجمالي السجلات', 'حاضر', 'غائب', 'النسبة %'],
'columns' => ['group', 'program', 'total_records', 'present', 'absent', 'rate'],
'money_cols' => [],
'uses_dates' => true,
],
'absentees' => [
'name' => 'أكثر الغائبين',
'method' => 'absenteeReport',
'headers' => ['الاسم', 'الهاتف', 'مرات الغياب', 'المجموعات', 'آخر غياب'],
'columns' => ['name', 'phone', 'absent_count', 'groups', 'last_absence'],
'money_cols' => [],
'uses_dates' => true,
],
'trainer_attendance' => [
'name' => 'حضور المدربين',
'method' => 'trainerAttendance',
'headers' => ['المدرب', 'الهاتف', 'إجمالي الحصص', 'حضر', 'غاب', 'النسبة %'],
'columns' => ['name', 'phone', 'total_sessions', 'attended', 'absent', 'rate'],
'money_cols' => [],
'uses_dates' => true,
],
'enrollments_by_program' => [
'name' => 'التسجيلات حسب البرنامج',
'method' => 'enrollmentsByProgram',
'headers' => ['البرنامج', 'عدد المسجلين', 'الحالة'],
'columns' => ['program', 'enrollments', 'status'],
'money_cols' => [],
'uses_dates' => true,
],
'group_capacity' => [
'name' => 'نسبة امتلاء المجموعات',
'method' => 'groupCapacity',
'headers' => ['المجموعة', 'البرنامج', 'الفرع', 'الحالي', 'الأقصى', 'النسبة %', 'المتاح'],
'columns' => ['group', 'program', 'branch', 'current', 'max', 'fill_rate', 'available'],
'money_cols' => [],
'uses_dates' => false,
],
'cancellations' => [
'name' => 'الإلغاءات',
'method' => 'cancellationsReport',
'headers' => ['المشترك', 'الهاتف', 'البرنامج', 'المجموعة', 'تاريخ التسجيل', 'تاريخ الإلغاء', 'السبب'],
'columns' => ['participant', 'phone', 'program', 'group', 'enrolled_at', 'cancelled_at', 'reason'],
'money_cols' => [],
'uses_dates' => true,
],
'retention' => [
'name' => 'الاستمرارية',
'method' => 'retentionReport',
'headers' => ['المؤشر', 'القيمة'],
'columns' => ['metric', 'value'],
'money_cols' => [],
'uses_dates' => true,
],
'session_completion' => [
'name' => 'الحصص المنجزة',
'method' => 'sessionCompletionReport',
'headers' => ['الحالة', 'العدد'],
'columns' => ['status', 'count'],
'money_cols' => [],
'uses_dates' => true,
],
'branch_comparison' => [
'name' => 'مقارنة الفروع',
'method' => 'branchComparison',
'headers' => ['الفرع', 'مشتركون نشطون', 'الإيرادات', 'تسجيلات جديدة'],
'columns' => ['branch', 'active_participants', 'revenue', 'new_enrollments'],
'money_cols' => ['revenue'],
'uses_dates' => true,
],
'trainer_workload' => [
'name' => 'عبء المدربين',
'method' => 'trainerWorkload',
'headers' => ['المدرب', 'الهاتف', 'إجمالي الحصص', 'مكتملة', 'ملغاة', 'عدد المجموعات'],
'columns' => ['name', 'phone', 'total_sessions', 'completed', 'cancelled', 'groups_count'],
'money_cols' => [],
'uses_dates' => true,
],
'wallet_balances' => [
'name' => 'أرصدة المحافظ',
'method' => 'walletBalances',
'headers' => ['المشترك', 'الهاتف', 'الرصيد', 'المجمد', 'المتاح', 'آخر حركة'],
'columns' => ['participant', 'phone', 'balance', 'frozen', 'available', 'last_transaction'],
'money_cols' => ['balance', 'frozen', 'available'],
'uses_dates' => false,
],
'low_stock' => [
'name' => 'المنتجات المنخفضة',
'method' => 'lowStockReport',
'headers' => ['المنتج', 'SKU', 'التصنيف', 'الكمية', 'حد إعادة الطلب', 'الحالة'],
'columns' => ['product', 'sku', 'category', 'on_hand', 'reorder_point', 'status'],
'money_cols' => [],
'uses_dates' => false,
],
'inventory_movements' => [
'name' => 'حركات المخزون',
'method' => 'inventoryMovementReport',
'headers' => ['التاريخ', 'المنتج', 'المستودع', 'النوع', 'الاتجاه', 'الكمية', 'قبل', 'بعد', 'المرجع'],
'columns' => ['date', 'product', 'warehouse', 'type', 'direction', 'quantity', 'before', 'after', 'reference'],
'money_cols' => [],
'uses_dates' => true,
],
'product_sales' => [
'name' => 'مبيعات المنتجات',
'method' => 'productSalesReport',
'headers' => ['المنتج', 'SKU', 'الكمية المباعة', 'الإيرادات'],
'columns' => ['product', 'sku', 'units_sold', 'revenue'],
'money_cols' => ['revenue'],
'uses_dates' => true,
],
'pos_transactions' => [
'name' => 'عمليات نقاط البيع',
'method' => 'posTransactionReport',
'headers' => ['التاريخ', 'الرقم', 'الإجمالي', 'الخصم', 'عدد الأصناف', 'طريقة الدفع', 'الكاشير', 'الفرع'],
'columns' => ['date', 'number', 'total', 'discount', 'items_count', 'payment_method', 'cashier', 'branch'],
'money_cols' => ['total', 'discount'],
'uses_dates' => true,
],
'pos_daily_summary' => [
'name' => 'ملخص POS اليومي',
'method' => 'posDailySummary',
'headers' => ['التاريخ', 'الإجمالي', 'الخصومات', 'عدد العمليات'],
'columns' => ['date', 'total', 'discounts', 'transactions'],
'money_cols' => ['total', 'discounts'],
'uses_dates' => true,
],
];
}
public function render(ReportService $reportService)
{
$config = $this->getReportConfig()[$this->report];
$branchId = $this->isAllBranches() ? null : $this->getActiveBranchId();
$method = $config['method'];
$usesDates = $config['uses_dates'] ?? true;
if ($usesDates) {
$data = $reportService->$method($this->from, $this->to, $branchId);
} else {
$data = $reportService->$method($branchId);
}
return view('livewire.reports.report-viewer', [
'config' => $config,
'data' => $data,
'exportUrl' => route('export.report', ['report' => $this->report, 'from' => $this->from, 'to' => $this->to]),
]);
}
}
<?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 @@ ...@@ -96,7 +96,7 @@
]], ]],
['section' => 'الإدارة', 'items' => [ ['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' => 'users.list', 'icon' => 'users', 'permission' => 'users.list'],
['label' => 'الأدوار', 'route' => 'roles.list', 'icon' => 'shield-check', 'permission' => 'roles.list'], ['label' => 'الأدوار', 'route' => 'roles.list', 'icon' => 'shield-check', 'permission' => 'roles.list'],
['label' => 'الفروع', 'route' => 'branches.list', 'icon' => 'building-office', 'permission' => 'branches.list'], ['label' => 'الفروع', 'route' => 'branches.list', 'icon' => 'building-office', 'permission' => 'branches.list'],
......
<div class="relative"> <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"> 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> <option value="all">{{ __('كل الفروع') }}</option>
@foreach($branches as $branch) @foreach($branches as $branch)
......
...@@ -302,7 +302,7 @@ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"> ...@@ -302,7 +302,7 @@ class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('نموذج التعويض') }} <span class="text-red-500">*</span></label> <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> <option value="">{{ __('اختر...') }}</option>
@foreach($compensationModels as $model) @foreach($compensationModels as $model)
<option value="{{ $model->value }}">{{ $model->label() }}</option> <option value="{{ $model->value }}">{{ $model->label() }}</option>
......
...@@ -18,8 +18,60 @@ class="text-gray-600 hover:text-gray-800 text-sm"> ...@@ -18,8 +18,60 @@ class="text-gray-600 hover:text-gray-800 text-sm">
<form wire:submit="save" class="space-y-6"> <form wire:submit="save" class="space-y-6">
{{-- Section 1: Person Info (only for new participants without existing person) --}} {{-- Section 1: Person Info --}}
@if(!$editing && !$person_id) @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"> <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> <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 ...@@ -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> </div>
</div> </div>
@elseif(!$editing && $person_id) @else
{{-- Show selected person info --}} {{-- Create mode: existing person selected --}}
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6"> <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"> <div class="flex items-center justify-between mb-2">
<h2 class="text-base sm:text-lg font-semibold text-gray-800">{{ __('الشخص المحدد') }}</h2> <h2 class="text-base sm:text-lg font-semibold text-gray-800">{{ __('الشخص المحدد') }}</h2>
...@@ -239,6 +291,7 @@ class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 ...@@ -239,6 +291,7 @@ 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"> <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> <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> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ __('ولي الأمر الأساسي') }}</label> <label class="block text-sm font-medium text-gray-700 mb-1">{{ __('ولي الأمر الأساسي') }}</label>
<select wire:model="primary_guardian_id" <select wire:model="primary_guardian_id"
...@@ -252,6 +305,16 @@ class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2 ...@@ -252,6 +305,16 @@ class="w-full px-4 py-2.5 text-sm border border-gray-300 rounded-lg focus:ring-2
@endforeach @endforeach
</select> </select>
</div> </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> </div>
{{-- Section 6: Notes --}} {{-- Section 6: Notes --}}
......
...@@ -30,7 +30,7 @@ class="inline-flex items-center gap-2 px-3 sm:px-4 py-2 bg-blue-600 text-white r ...@@ -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="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"> <div class="col-span-2 md:col-span-1 lg:col-span-2">
<input type="text" wire:model.live.debounce.300ms="search" <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"> 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> </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"> <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 ...@@ -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-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> <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 ...@@ -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 @endif
</td> </td>
<td class="px-4 py-3 text-gray-600">{{ $participant->primaryActivity?->name_ar ?? '—' }}</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"> <td class="px-4 py-3 text-center">
@php @php
$statusValue = $participant->status->value ?? $participant->status; $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 ...@@ -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> </tr>
@empty @empty
<tr> <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"> <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"> <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"/> <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 ...@@ -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> </span>
</div> </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) @if($participant->primaryActivity?->name_ar)
<span class="truncate">{{ $participant->primaryActivity->name_ar }}</span> <span class="truncate">{{ $participant->primaryActivity->name_ar }}</span>
@endif @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) @if($participant->registration_date)
<span dir="ltr" class="whitespace-nowrap">{{ $participant->registration_date->format('Y-m-d') }}</span> <span dir="ltr" class="whitespace-nowrap">{{ $participant->registration_date->format('Y-m-d') }}</span>
@endif @endif
......
...@@ -233,7 +233,7 @@ class="pb-3 border-b-2 text-sm font-medium transition whitespace-nowrap"> ...@@ -233,7 +233,7 @@ class="pb-3 border-b-2 text-sm font-medium transition whitespace-nowrap">
<dl class="space-y-3"> <dl class="space-y-3">
<div class="flex justify-between"> <div class="flex justify-between">
<dt class="text-sm text-gray-500">{{ __('الجنس') }}</dt> <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>
<div class="flex justify-between"> <div class="flex justify-between">
<dt class="text-sm text-gray-500">{{ __('تاريخ الميلاد') }}</dt> <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 @@ ...@@ -420,7 +420,11 @@
->middleware('permission:notifications.manage'); ->middleware('permission:notifications.manage');
// Reports // 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'); ->middleware('permission:reports.view');
Route::get('/reports/financial', \App\Livewire\Reports\FinancialReport::class)->name('reports.financial') Route::get('/reports/financial', \App\Livewire\Reports\FinancialReport::class)->name('reports.financial')
->middleware('permission:reports.view'); ->middleware('permission:reports.view');
...@@ -462,6 +466,8 @@ ...@@ -462,6 +466,8 @@
->middleware('permission:super_admin.access'); ->middleware('permission:super_admin.access');
// Exports // 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']) Route::get('/export/participants', [\App\Http\Controllers\ExportController::class, 'participants'])
->name('export.participants')->middleware('permission:participants.list'); ->name('export.participants')->middleware('permission:participants.list');
Route::get('/export/payments', [\App\Http\Controllers\ExportController::class, 'payments']) 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