Commit 40f1c0c0 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(sa-reports, discounts): sports activity reports + membership discount fixes

Sports Activity Reports: player reports with filters (discipline/program/group/
player type/medical/payment status/branch) and finance reports (revenue/costs/
profit with daily/weekly/monthly/yearly/3yr/5yr/custom periods). CSV and PDF
export for both. Role-based access with 3 new permissions.

Membership Discounts: fix BillingService to include regulatory discount as bill
line item, add regulatory discount section to edit page, add FYI discount guide
to show page covering all 3 discount types (special, regulatory, board offers),
handle regulatory discount in update controller with document upload.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 1f9a209d
...@@ -420,6 +420,10 @@ class MemberController extends Controller ...@@ -420,6 +420,10 @@ class MemberController extends Controller
: null, : null,
'installInterestRate' => (float) ($instRateData['percentage'] ?? 22), 'installInterestRate' => (float) ($instRateData['percentage'] ?? 22),
'installMaxMonths' => (int) ($instMonthsData['months'] ?? 30), 'installMaxMonths' => (int) ($instMonthsData['months'] ?? 30),
'regulatoryDiscount' => $member->regulatory_discount_id
? $db->selectOne("SELECT * FROM regulatory_discounts WHERE id = ?", [(int) $member->regulatory_discount_id])
: null,
'regulatoryDiscounts' => $db->select("SELECT * FROM regulatory_discounts WHERE is_active = 1 ORDER BY article_number"),
'boardOffers' => $boardOffers, 'boardOffers' => $boardOffers,
'bestOffer' => $bestOffer, 'bestOffer' => $bestOffer,
'cashDiscount' => $cashDiscount, 'cashDiscount' => $cashDiscount,
...@@ -842,8 +846,9 @@ class MemberController extends Controller ...@@ -842,8 +846,9 @@ class MemberController extends Controller
'governorates' => $db->select("SELECT code, name_ar FROM governorates WHERE is_active = 1"), 'governorates' => $db->select("SELECT code, name_ar FROM governorates WHERE is_active = 1"),
'countries' => $db->select("SELECT nationality_ar FROM countries WHERE is_active = 1 ORDER BY name_ar"), 'countries' => $db->select("SELECT nationality_ar FROM countries WHERE is_active = 1 ORDER BY name_ar"),
'isSuperAdmin' => self::isSuperAdmin(), 'isSuperAdmin' => self::isSuperAdmin(),
'specialDiscounts' => SpecialDiscount::allActive(), 'specialDiscounts' => SpecialDiscount::allActive(),
'salesReps' => $salesReps, 'regulatoryDiscounts' => $db->select("SELECT * FROM regulatory_discounts WHERE is_active = 1 ORDER BY article_number"),
'salesReps' => $salesReps,
]); ]);
} }
...@@ -968,6 +973,41 @@ class MemberController extends Controller ...@@ -968,6 +973,41 @@ class MemberController extends Controller
} }
} }
// ── Regulatory Discount handling ──
if (array_key_exists('regulatory_discount_id', $data)) {
$regDiscountId = trim((string) ($data['regulatory_discount_id'] ?? ''));
if ($regDiscountId === '' || $regDiscountId === '0') {
$update['regulatory_discount_id'] = null;
$update['regulatory_discount_amount'] = null;
} else {
$regRule = $db->selectOne("SELECT * FROM regulatory_discounts WHERE id = ? AND is_active = 1", [(int) $regDiscountId]);
if ($regRule) {
$update['regulatory_discount_id'] = (int) $regDiscountId;
$membershipValue = $member->membership_value ?? '0.00';
$pct = $regRule['discount_percentage'] ?? '0';
$maxPct = $regRule['max_discount_percentage'] ?? null;
$effectivePct = ($maxPct !== null && bccomp((string) $pct, (string) $maxPct, 2) > 0) ? $maxPct : $pct;
$update['regulatory_discount_amount'] = bcdiv(bcmul((string) $membershipValue, (string) $effectivePct, 4), '100', 2);
if (!empty($_FILES['regulatory_discount_document']['tmp_name'])) {
$file = $_FILES['regulatory_discount_document'];
$allowedTypes = ['application/pdf', 'image/jpeg', 'image/png'];
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$mimeType = $finfo->file($file['tmp_name']);
if (in_array($mimeType, $allowedTypes) && $file['size'] <= 10485760) {
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$storedName = 'reg_discount_' . (int) $id . '_' . date('Ymd_His') . '_' . bin2hex(random_bytes(4)) . '.' . $ext;
$uploadDir = App::getInstance()->basePath() . '/storage/uploads/discounts/';
if (!is_dir($uploadDir)) mkdir($uploadDir, 0755, true);
if (move_uploaded_file($file['tmp_name'], $uploadDir . $storedName)) {
$update['regulatory_discount_document'] = 'storage/uploads/discounts/' . $storedName;
}
}
}
}
}
}
if (!empty($_FILES['photo']) && PhotoUploadService::isUploaded($_FILES['photo'])) { if (!empty($_FILES['photo']) && PhotoUploadService::isUploaded($_FILES['photo'])) {
$photoValidation = PhotoUploadService::validate($_FILES['photo']); $photoValidation = PhotoUploadService::validate($_FILES['photo']);
if ($photoValidation['valid']) { if ($photoValidation['valid']) {
......
...@@ -472,6 +472,28 @@ final class BillingService ...@@ -472,6 +472,28 @@ final class BillingService
} }
} }
// ── 2c. Regulatory Discount ──
if (!empty($member['regulatory_discount_id'])) {
$regRow = $db->selectOne(
"SELECT * FROM regulatory_discounts WHERE id = ? AND is_active = 1",
[(int) $member['regulatory_discount_id']]
);
if ($regRow) {
$regAmount = $member['regulatory_discount_amount'] ?? '0.00';
if (bccomp((string) $regAmount, '0', 2) > 0) {
$regLabel = 'خصم لائحي: ' . $regRow['name_ar'] . ' (مادة ' . $regRow['article_number'] . ' — ' . $regRow['discount_percentage'] . '%)';
$items[] = [
'type' => 'regulatory_discount',
'label' => $regLabel,
'amount' => '-' . $regAmount,
'paid' => false,
'included' => false,
'category' => 'discount',
];
}
}
}
// ── 3. Spouses ── // ── 3. Spouses ──
$spouses = []; $spouses = [];
try { try {
......
...@@ -139,6 +139,76 @@ ...@@ -139,6 +139,76 @@
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Regulatory Discount -->
<?php if (!empty($regulatoryDiscounts)): ?>
<div class="card" style="margin-bottom:20px;padding:20px;border-right:4px solid #0D7377;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:15px;">
<i data-lucide="scale" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">خصم لائحي (مواد 97 — 102، 110)</h3>
</div>
<!-- FYI Table -->
<div style="margin-bottom:15px;background:#F0FDF4;border-radius:8px;padding:10px 14px;">
<p style="font-size:11px;color:#065F46;margin:0 0 6px;font-weight:600;">الخصومات اللائحية المتاحة (للعلم):</p>
<table style="width:100%;font-size:10px;border-collapse:collapse;">
<thead>
<tr>
<th style="padding:3px 6px;text-align:right;color:#065F46;border-bottom:1px solid #D1FAE5;">المادة</th>
<th style="padding:3px 6px;text-align:right;color:#065F46;border-bottom:1px solid #D1FAE5;">الوصف</th>
<th style="padding:3px 6px;text-align:center;color:#065F46;border-bottom:1px solid #D1FAE5;">النسبة</th>
</tr>
</thead>
<tbody>
<?php foreach ($regulatoryDiscounts as $rd): ?>
<tr>
<td style="padding:2px 6px;border-bottom:1px solid #ECFDF5;">مادة <?= e($rd['article_number']) ?></td>
<td style="padding:2px 6px;border-bottom:1px solid #ECFDF5;"><?= e($rd['name_ar']) ?></td>
<td style="padding:2px 6px;border-bottom:1px solid #ECFDF5;text-align:center;font-weight:600;color:#059669;"><?= e($rd['discount_percentage']) ?>%</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;">
<div class="form-group">
<label class="form-label">الخصم اللائحي</label>
<select name="regulatory_discount_id" id="reg_discount_select" class="form-select" onchange="updateRegDiscountInfo()">
<option value="">— بدون خصم لائحي —</option>
<?php foreach ($regulatoryDiscounts as $rd): ?>
<option value="<?= (int) $rd['id'] ?>"
data-pct="<?= e($rd['discount_percentage']) ?>"
data-article="<?= e($rd['article_number']) ?>"
<?= ((int) ($member->regulatory_discount_id ?? 0)) === (int) $rd['id'] ? 'selected' : '' ?>>
مادة <?= e($rd['article_number']) ?><?= e($rd['name_ar']) ?> (<?= e($rd['discount_percentage']) ?>%)
</option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" id="reg-discount-info" style="display:<?= $member->regulatory_discount_id ? 'block' : 'none' ?>;">
<label class="form-label">قيمة الخصم</label>
<div id="reg-discount-amount" style="font-size:18px;font-weight:700;color:#0D7377;padding:8px 0;">
<?php if ($member->regulatory_discount_amount && bccomp((string) $member->regulatory_discount_amount, '0', 2) > 0): ?>
<?= money($member->regulatory_discount_amount) ?>
<?php else: ?>
<?php endif; ?>
</div>
</div>
</div>
<div class="form-group" style="margin-top:10px;">
<label class="form-label">مستند إثبات الخصم اللائحي (PDF, JPG, PNG)</label>
<input type="file" name="regulatory_discount_document" class="form-input" accept=".pdf,.jpg,.jpeg,.png" style="padding:8px;">
<?php if (!empty($member->regulatory_discount_document)): ?>
<small style="color:#059669;font-size:12px;display:block;margin-top:4px;">
<i data-lucide="file-check" style="width:12px;height:12px;vertical-align:middle;"></i>
مستند مرفق بالفعل — <a href="/<?= e($member->regulatory_discount_document) ?>" target="_blank" style="color:#0D7377;">عرض</a>
</small>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<!-- Profile Photo --> <!-- Profile Photo -->
<div class="card" style="margin-bottom:20px;padding:20px;border-right:4px solid #7C3AED;"> <div class="card" style="margin-bottom:20px;padding:20px;border-right:4px solid #7C3AED;">
<h3 style="color:#7C3AED;margin-bottom:15px;">الصورة الشخصية</h3> <h3 style="color:#7C3AED;margin-bottom:15px;">الصورة الشخصية</h3>
...@@ -179,5 +249,22 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -179,5 +249,22 @@ document.addEventListener('DOMContentLoaded', function() {
sel.addEventListener('change', updateDiscount); sel.addEventListener('change', updateDiscount);
updateDiscount(); updateDiscount();
}); });
function updateRegDiscountInfo() {
var regSel = document.getElementById('reg_discount_select');
var regInfo = document.getElementById('reg-discount-info');
var regAmount = document.getElementById('reg-discount-amount');
if (!regSel || !regInfo) return;
var opt = regSel.options[regSel.selectedIndex];
if (!opt || !opt.value) {
regInfo.style.display = 'none';
return;
}
var pct = parseFloat(opt.getAttribute('data-pct') || '0');
var membershipValue = parseFloat(<?= json_encode($member->membership_value ?? '0.00') ?>) || 0;
var amt = (membershipValue * pct / 100).toFixed(2);
regAmount.textContent = amt + ' ج.م';
regInfo.style.display = 'block';
}
</script> </script>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
\ No newline at end of file
...@@ -409,6 +409,94 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2); ...@@ -409,6 +409,94 @@ $_memberInitials = mb_substr($member->full_name_ar, 0, 2);
</div> </div>
<?php endif; ?> <?php endif; ?>
<!-- Regulatory Discount Status -->
<?php if (!empty($member->regulatory_discount_id) && !empty($regulatoryDiscount)): ?>
<div style="padding:15px 20px;border-top:1px solid #E5E7EB;">
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<span style="background:#ECFDF5;color:#065F46;padding:4px 10px;border-radius:6px;font-size:12px;font-weight:600;">خصم لائحي</span>
<span style="color:#0D7377;font-weight:600;font-size:13px;">
<?= e($regulatoryDiscount['name_ar']) ?> (مادة <?= e($regulatoryDiscount['article_number']) ?><?= e($regulatoryDiscount['discount_percentage']) ?>%)
= -<?= money($member->regulatory_discount_amount ?? '0') ?>
</span>
<?php if (!empty($member->regulatory_discount_document)): ?>
<a href="/<?= e($member->regulatory_discount_document) ?>" target="_blank" style="color:#0D7377;font-size:12px;text-decoration:underline;">عرض المستند</a>
<?php endif; ?>
</div>
</div>
<?php endif; ?>
<!-- FYI: All Available Discounts Guide -->
<?php if ($isInitialPhase): ?>
<div style="border-top:1px solid #E5E7EB;">
<div style="padding:12px 20px;cursor:pointer;display:flex;align-items:center;gap:8px;background:#F8FAFC;" onclick="var el=document.getElementById('fyi-discounts');el.style.display=el.style.display==='none'?'block':'none';this.querySelector('.fyi-arrow').textContent=el.style.display==='none'?'▼':'▲';">
<i data-lucide="info" style="width:16px;height:16px;color:#6366F1;"></i>
<span style="font-weight:600;font-size:13px;color:#6366F1;">دليل الخصومات المتاحة (للعلم)</span>
<span class="fyi-arrow" style="margin-right:auto;color:#6366F1;font-size:11px;">&#x25BC;</span>
</div>
<div id="fyi-discounts" style="display:none;padding:0 20px 20px;">
<!-- 1. Special Discounts -->
<div style="margin-bottom:16px;">
<h4 style="font-size:13px;color:#D97706;margin:12px 0 8px;border-bottom:1px solid #FEF3C7;padding-bottom:4px;">1. الخصومات الخاصة</h4>
<p style="font-size:12px;color:#6B7280;margin:0 0 6px;">خصومات نسبة أو مبلغ ثابت تُطبَّق من صفحة التعديل أو صفحة ملء الاستمارة. يمكن أن تتطلب مستند إثبات.</p>
<?php if (!empty($availableDiscounts)): ?>
<div style="font-size:12px;color:#374151;">
<strong>المتاح حالياً:</strong>
<?php foreach ($availableDiscounts as $ad): ?>
<span style="background:#FEF3C7;padding:2px 8px;border-radius:4px;margin:0 2px;"><?= e($ad['name_ar']) ?> (<?= e($ad['discount_percentage']) ?>%)</span>
<?php endforeach; ?>
</div>
<?php endif; ?>
</div>
<!-- 2. Regulatory Discounts -->
<div style="margin-bottom:16px;">
<h4 style="font-size:13px;color:#0D7377;margin:0 0 8px;border-bottom:1px solid #D1FAE5;padding-bottom:4px;">2. الخصومات اللائحية (مواد 97 — 102، 110)</h4>
<p style="font-size:12px;color:#6B7280;margin:0 0 6px;">خصومات منصوص عليها في لائحة النادي — تُطبَّق أثناء ملء الاستمارة بعد التحقق من الأهلية واعتماد المسؤول.</p>
<?php if (!empty($regulatoryDiscounts)): ?>
<table style="width:100%;font-size:11px;border-collapse:collapse;margin-top:4px;">
<thead>
<tr style="background:#F0FDF4;">
<th style="padding:5px 8px;text-align:right;color:#065F46;border:1px solid #D1FAE5;">المادة</th>
<th style="padding:5px 8px;text-align:right;color:#065F46;border:1px solid #D1FAE5;">الوصف</th>
<th style="padding:5px 8px;text-align:center;color:#065F46;border:1px solid #D1FAE5;">النسبة</th>
<th style="padding:5px 8px;text-align:right;color:#065F46;border:1px solid #D1FAE5;">الإثبات المطلوب</th>
</tr>
</thead>
<tbody>
<?php foreach ($regulatoryDiscounts as $rd): ?>
<tr>
<td style="padding:4px 8px;border:1px solid #E5E7EB;font-weight:600;">مادة <?= e($rd['article_number']) ?></td>
<td style="padding:4px 8px;border:1px solid #E5E7EB;"><?= e($rd['name_ar']) ?></td>
<td style="padding:4px 8px;border:1px solid #E5E7EB;text-align:center;font-weight:600;color:#059669;"><?= e($rd['discount_percentage']) ?>%</td>
<td style="padding:4px 8px;border:1px solid #E5E7EB;font-size:10px;color:#6B7280;"><?= e($rd['proof_requirements'] ?? 'حسب نوع الخصم') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<!-- 3. Board Offers -->
<div>
<h4 style="font-size:13px;color:#7C3AED;margin:0 0 8px;border-bottom:1px solid #EDE9FE;padding-bottom:4px;">3. عروض مجلس الإدارة</h4>
<p style="font-size:12px;color:#6B7280;margin:0 0 6px;">عروض سعرية بقرار مجلس الإدارة (خصم كاش أو شروط تقسيط). تظهر تلقائياً في قسم الدفع عندما تكون مفعّلة.</p>
<?php if (!empty($boardOffers)): ?>
<div style="font-size:12px;color:#374151;">
<strong>العروض المتاحة:</strong>
<?php foreach ($boardOffers as $bo): ?>
<span style="background:#EDE9FE;padding:2px 8px;border-radius:4px;margin:0 2px;"><?= e($bo['name_ar']) ?></span>
<?php endforeach; ?>
</div>
<?php else: ?>
<p style="font-size:12px;color:#9CA3AF;margin:0;">لا توجد عروض نشطة حالياً.</p>
<?php endif; ?>
</div>
</div>
</div>
<?php endif; ?>
<!-- Type-specific registration CTA (seasonal/foreign/sports) --> <!-- Type-specific registration CTA (seasonal/foreign/sports) -->
<?php <?php
$typeDetailExists = match ($member->membership_type ?? 'working') { $typeDetailExists = match ($member->membership_type ?? 'working') {
......
This diff is collapsed.
...@@ -383,4 +383,12 @@ return [ ...@@ -383,4 +383,12 @@ return [
['POST', '/sa/players/{pid:\d+}/groups/{gid:\d+}/pause', 'SportsActivity\Controllers\PlayerLifecycleController@pause', ['auth', 'csrf'], 'sa.enrollment.manage'], ['POST', '/sa/players/{pid:\d+}/groups/{gid:\d+}/pause', 'SportsActivity\Controllers\PlayerLifecycleController@pause', ['auth', 'csrf'], 'sa.enrollment.manage'],
['POST', '/sa/players/{pid:\d+}/groups/{gid:\d+}/resume', 'SportsActivity\Controllers\PlayerLifecycleController@resume', ['auth', 'csrf'], 'sa.enrollment.manage'], ['POST', '/sa/players/{pid:\d+}/groups/{gid:\d+}/resume', 'SportsActivity\Controllers\PlayerLifecycleController@resume', ['auth', 'csrf'], 'sa.enrollment.manage'],
['POST', '/sa/players/{pid:\d+}/groups/{gid:\d+}/extend-grace', 'SportsActivity\Controllers\PlayerLifecycleController@extendGrace', ['auth', 'csrf'], 'sa.enrollment.manage'], ['POST', '/sa/players/{pid:\d+}/groups/{gid:\d+}/extend-grace', 'SportsActivity\Controllers\PlayerLifecycleController@extendGrace', ['auth', 'csrf'], 'sa.enrollment.manage'],
// ─── Reports ──────────────────────────────────────────────────────────────────
['GET', '/sa/reports/players', 'SportsActivity\Controllers\SaReportController@playerReport', ['auth'], 'sa.report.players'],
['GET', '/sa/reports/finance', 'SportsActivity\Controllers\SaReportController@financeReport', ['auth'], 'sa.report.finance'],
['GET', '/sa/reports/players/export-csv', 'SportsActivity\Controllers\SaReportController@exportPlayerCsv', ['auth'], 'sa.report.export'],
['GET', '/sa/reports/players/export-pdf', 'SportsActivity\Controllers\SaReportController@exportPlayerPdf', ['auth'], 'sa.report.export'],
['GET', '/sa/reports/finance/export-csv', 'SportsActivity\Controllers\SaReportController@exportFinanceCsv', ['auth'], 'sa.report.export'],
['GET', '/sa/reports/finance/export-pdf', 'SportsActivity\Controllers\SaReportController@exportFinancePdf', ['auth'], 'sa.report.export'],
]; ];
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
final class SaPlayerReportService
{
public static function generate(array $filters): array
{
$db = App::getInstance()->db();
$columns = [
'player_name' => 'اسم اللاعب',
'national_id' => 'الرقم القومي',
'phone' => 'الهاتف',
'player_type' => 'النوع',
'discipline_name' => 'اللعبة',
'program_name' => 'البرنامج',
'group_name' => 'المجموعة',
'coach_name' => 'المدرب',
'enrollment_status'=> 'حالة التسجيل',
'medical_status' => 'الحالة الطبية',
'medical_expiry' => 'انتهاء الشهادة الطبية',
'card_status' => 'حالة الكارت',
'reg_fee_paid' => 'رسوم التسجيل',
'sub_status' => 'حالة الاشتراك',
'branch_name' => 'الفرع',
];
$where = ['sp.is_archived = 0'];
$params = [];
if (!empty($filters['discipline_id'])) {
$where[] = 'd.id = ?';
$params[] = (int) $filters['discipline_id'];
}
if (!empty($filters['program_id'])) {
$where[] = 'p.id = ?';
$params[] = (int) $filters['program_id'];
}
if (!empty($filters['group_id'])) {
$where[] = 'g.id = ?';
$params[] = (int) $filters['group_id'];
}
if (!empty($filters['player_id'])) {
$where[] = 'sp.id = ?';
$params[] = (int) $filters['player_id'];
}
if (!empty($filters['player_type'])) {
$where[] = 'sp.player_type = ?';
$params[] = $filters['player_type'];
}
if (!empty($filters['medical_status'])) {
$where[] = 'sp.medical_status = ?';
$params[] = $filters['medical_status'];
}
if (!empty($filters['payment_status'])) {
$where[] = 'latest_sub.payment_status = ?';
$params[] = $filters['payment_status'];
}
if (!empty($filters['branch_id'])) {
$where[] = 'sp.branch_id = ?';
$params[] = (int) $filters['branch_id'];
}
$whereClause = implode(' AND ', $where);
$sql = "
SELECT
sp.id AS player_id,
sp.full_name_ar AS player_name,
sp.national_id,
COALESCE(sp.phone, sp.guardian_phone) AS phone,
sp.player_type,
COALESCE(d.name_ar, '—') AS discipline_name,
COALESCE(p.name_ar, '—') AS program_name,
COALESCE(g.name_ar, '—') AS group_name,
COALESCE(c.full_name_ar, '—') AS coach_name,
COALESCE(gp.status, '—') AS enrollment_status,
sp.medical_status,
sp.medical_expiry_date AS medical_expiry,
sp.card_status,
sp.registration_fee_paid AS reg_fee_paid,
COALESCE(latest_sub.payment_status, '—') AS sub_status,
COALESCE(br.name_ar, '—') AS branch_name
FROM sa_players sp
LEFT JOIN sa_group_players gp ON gp.player_id = sp.id
AND gp.status NOT IN ('removed','transferred')
LEFT JOIN sa_groups g ON g.id = gp.group_id AND g.is_archived = 0
LEFT JOIN sa_programs p ON p.id = g.program_id AND p.is_archived = 0
LEFT JOIN sa_disciplines d ON d.id = p.discipline_id AND d.is_archived = 0
LEFT JOIN sa_coaches c ON c.id = g.coach_id
LEFT JOIN branches br ON br.id = sp.branch_id
LEFT JOIN (
SELECT s1.player_id, s1.group_id, s1.payment_status
FROM sa_subscriptions s1
INNER JOIN (
SELECT player_id, group_id, MAX(period_end) AS max_end
FROM sa_subscriptions
GROUP BY player_id, group_id
) s2 ON s1.player_id = s2.player_id
AND s1.group_id = s2.group_id
AND s1.period_end = s2.max_end
) latest_sub ON latest_sub.player_id = sp.id
AND latest_sub.group_id = COALESCE(g.id, 0)
WHERE {$whereClause}
ORDER BY sp.full_name_ar
";
$rows = $db->select($sql, $params);
$statusLabels = [
'pending_payment' => 'بانتظار الدفع',
'active' => 'نشط',
'paused' => 'متوقف',
'removed' => 'محذوف',
'transferred' => 'منقول',
'pending' => 'معلق',
'fit' => 'لائق',
'conditional' => 'مشروط',
'unfit' => 'غير لائق',
'expired' => 'منتهي',
'inactive' => 'غير نشط',
'active' => 'نشط',
'suspended' => 'موقوف',
'revoked' => 'ملغي',
'unpaid' => 'غير مدفوع',
'paid' => 'مدفوع',
'partial' => 'جزئي',
'exempt' => 'معفي',
'overdue' => 'متأخر',
'member' => 'عضو',
'non_member' => 'غير عضو',
];
$formatted = [];
foreach ($rows as $row) {
$formatted[] = [
'player_name' => $row['player_name'],
'national_id' => $row['national_id'] ?? '—',
'phone' => $row['phone'] ?? '—',
'player_type' => $statusLabels[$row['player_type']] ?? $row['player_type'],
'discipline_name' => $row['discipline_name'],
'program_name' => $row['program_name'],
'group_name' => $row['group_name'],
'coach_name' => $row['coach_name'],
'enrollment_status' => $statusLabels[$row['enrollment_status']] ?? $row['enrollment_status'],
'medical_status' => $statusLabels[$row['medical_status']] ?? $row['medical_status'],
'medical_expiry' => $row['medical_expiry'] ?? '—',
'card_status' => $statusLabels[$row['card_status']] ?? $row['card_status'],
'reg_fee_paid' => $row['reg_fee_paid'] ? 'نعم' : 'لا',
'sub_status' => $statusLabels[$row['sub_status']] ?? $row['sub_status'],
'branch_name' => $row['branch_name'],
];
}
return [
'columns' => $columns,
'rows' => $formatted,
'total' => count($formatted),
];
}
}
This diff is collapsed.
<?php
declare(strict_types=1);
$__template->layout('Layout.main');
$__template->section('title', 'تقارير اللاعبين');
$__template->section('content');
?>
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;">
<h1 style="font-size:22px;font-weight:700;color:#0D7377;">تقارير اللاعبين</h1>
<a href="/sa" class="btn btn-outline btn-sm">العودة للوحة التحكم</a>
</div>
<!-- Filter Card -->
<div class="card" style="margin-bottom:20px;padding:20px;border-right:4px solid #0D7377;">
<form method="GET" action="/sa/reports/players">
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:12px;">
<div class="form-group">
<label class="form-label">اللعبة</label>
<select name="discipline_id" id="filter_discipline" class="form-select" onchange="filterPrograms()">
<option value="">الكل</option>
<?php foreach ($disciplines as $d): ?>
<option value="<?= (int) $d['id'] ?>" <?= ($filters['discipline_id'] ?? '') == $d['id'] ? 'selected' : '' ?>><?= e($d['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">البرنامج</label>
<select name="program_id" id="filter_program" class="form-select" onchange="filterGroups()">
<option value="">الكل</option>
<?php foreach ($programs as $p): ?>
<option value="<?= (int) $p['id'] ?>" data-discipline="<?= (int) $p['discipline_id'] ?>" <?= ($filters['program_id'] ?? '') == $p['id'] ? 'selected' : '' ?>><?= e($p['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">المجموعة</label>
<select name="group_id" id="filter_group" class="form-select">
<option value="">الكل</option>
<?php foreach ($groups as $g): ?>
<option value="<?= (int) $g['id'] ?>" data-program="<?= (int) $g['program_id'] ?>" <?= ($filters['group_id'] ?? '') == $g['id'] ? 'selected' : '' ?>><?= e($g['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">نوع اللاعب</label>
<select name="player_type" class="form-select">
<option value="">الكل</option>
<option value="member" <?= ($filters['player_type'] ?? '') === 'member' ? 'selected' : '' ?>>عضو</option>
<option value="non_member" <?= ($filters['player_type'] ?? '') === 'non_member' ? 'selected' : '' ?>>غير عضو</option>
</select>
</div>
<div class="form-group">
<label class="form-label">الحالة الطبية</label>
<select name="medical_status" class="form-select">
<option value="">الكل</option>
<option value="pending" <?= ($filters['medical_status'] ?? '') === 'pending' ? 'selected' : '' ?>>معلق</option>
<option value="fit" <?= ($filters['medical_status'] ?? '') === 'fit' ? 'selected' : '' ?>>لائق</option>
<option value="conditional" <?= ($filters['medical_status'] ?? '') === 'conditional' ? 'selected' : '' ?>>مشروط</option>
<option value="unfit" <?= ($filters['medical_status'] ?? '') === 'unfit' ? 'selected' : '' ?>>غير لائق</option>
<option value="expired" <?= ($filters['medical_status'] ?? '') === 'expired' ? 'selected' : '' ?>>منتهي</option>
</select>
</div>
<div class="form-group">
<label class="form-label">حالة الاشتراك</label>
<select name="payment_status" class="form-select">
<option value="">الكل</option>
<option value="paid" <?= ($filters['payment_status'] ?? '') === 'paid' ? 'selected' : '' ?>>مدفوع</option>
<option value="unpaid" <?= ($filters['payment_status'] ?? '') === 'unpaid' ? 'selected' : '' ?>>غير مدفوع</option>
<option value="overdue" <?= ($filters['payment_status'] ?? '') === 'overdue' ? 'selected' : '' ?>>متأخر</option>
<option value="exempt" <?= ($filters['payment_status'] ?? '') === 'exempt' ? 'selected' : '' ?>>معفي</option>
</select>
</div>
<div class="form-group">
<label class="form-label">الفرع</label>
<select name="branch_id" class="form-select">
<option value="">الكل</option>
<?php foreach ($branches as $b): ?>
<option value="<?= (int) $b['id'] ?>" <?= ($filters['branch_id'] ?? '') == $b['id'] ? 'selected' : '' ?>><?= e($b['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" style="display:flex;align-items:end;">
<button type="submit" class="btn btn-primary" style="width:100%;">
<i data-lucide="search" style="width:14px;height:14px;display:inline-block;vertical-align:middle;margin-left:4px;"></i> عرض التقرير
</button>
</div>
</div>
</form>
</div>
<?php if ($report !== null): ?>
<!-- Results -->
<div class="card" style="padding:20px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;">
<h2 style="font-size:16px;font-weight:600;color:#374151;margin:0;">
النتائج: <?= (int) $report['total'] ?> لاعب
</h2>
<div style="display:flex;gap:8px;">
<a href="/sa/reports/players/export-csv?<?= http_build_query(array_filter($filters, fn($v) => $v !== '' && $v !== null)) ?>" class="btn btn-sm btn-outline" style="color:#059669;border-color:#059669;">
<i data-lucide="file-spreadsheet" style="width:14px;height:14px;display:inline-block;vertical-align:middle;margin-left:4px;"></i> تصدير CSV
</a>
<a href="/sa/reports/players/export-pdf?<?= http_build_query(array_filter($filters, fn($v) => $v !== '' && $v !== null)) ?>" class="btn btn-sm btn-outline" style="color:#DC2626;border-color:#DC2626;">
<i data-lucide="file-text" style="width:14px;height:14px;display:inline-block;vertical-align:middle;margin-left:4px;"></i> تصدير PDF
</a>
</div>
</div>
<?php if (empty($report['rows'])): ?>
<div style="text-align:center;padding:40px;color:#9CA3AF;">لا توجد نتائج تطابق الفلاتر المحددة.</div>
<?php else: ?>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;font-size:12px;">
<thead>
<tr style="background:#F3F4F6;">
<th style="padding:8px 10px;text-align:right;font-weight:600;color:#374151;white-space:nowrap;">#</th>
<?php foreach ($report['columns'] as $label): ?>
<th style="padding:8px 10px;text-align:right;font-weight:600;color:#374151;white-space:nowrap;"><?= e($label) ?></th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php foreach ($report['rows'] as $idx => $row): ?>
<tr style="border-bottom:1px solid #F3F4F6;<?= $idx % 2 ? 'background:#FAFAFA;' : '' ?>">
<td style="padding:6px 10px;color:#9CA3AF;"><?= $idx + 1 ?></td>
<?php foreach (array_keys($report['columns']) as $key): ?>
<td style="padding:6px 10px;white-space:nowrap;"><?= e((string) ($row[$key] ?? '—')) ?></td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endif; ?>
</div>
<?php else: ?>
<div class="card" style="padding:40px;text-align:center;color:#9CA3AF;">
<i data-lucide="filter" style="width:40px;height:40px;display:block;margin:0 auto 12px;opacity:0.5;"></i>
<p style="font-size:14px;">اختر الفلاتر ثم اضغط "عرض التقرير" لعرض النتائج.</p>
</div>
<?php endif; ?>
<script>
function filterPrograms() {
var discId = document.getElementById('filter_discipline').value;
var progSelect = document.getElementById('filter_program');
var opts = progSelect.querySelectorAll('option[data-discipline]');
opts.forEach(function(opt) {
opt.style.display = (!discId || opt.getAttribute('data-discipline') === discId) ? '' : 'none';
});
if (progSelect.selectedOptions[0] && progSelect.selectedOptions[0].style.display === 'none') {
progSelect.value = '';
}
filterGroups();
}
function filterGroups() {
var progId = document.getElementById('filter_program').value;
var grpSelect = document.getElementById('filter_group');
var opts = grpSelect.querySelectorAll('option[data-program]');
opts.forEach(function(opt) {
opt.style.display = (!progId || opt.getAttribute('data-program') === progId) ? '' : 'none';
});
if (grpSelect.selectedOptions[0] && grpSelect.selectedOptions[0].style.display === 'none') {
grpSelect.value = '';
}
}
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
filterPrograms();
});
</script>
<?php $__template->endSection(); ?>
...@@ -53,6 +53,9 @@ MenuRegistry::register('sports_activity', [ ...@@ -53,6 +53,9 @@ MenuRegistry::register('sports_activity', [
['label_ar' => 'تسجيل لاعب سباحة', 'label_en' => 'Register Swimmer','route' => '/sa/swimming/register','permission' => 'sa.swimming.register','order' => 32], ['label_ar' => 'تسجيل لاعب سباحة', 'label_en' => 'Register Swimmer','route' => '/sa/swimming/register','permission' => 'sa.swimming.register','order' => 32],
['label_ar' => 'تعيين في مجموعة', 'label_en' => 'Assign to Group', 'route' => '/sa/swimming/assign','permission' => 'sa.swimming.assign', 'order' => 33], ['label_ar' => 'تعيين في مجموعة', 'label_en' => 'Assign to Group', 'route' => '/sa/swimming/assign','permission' => 'sa.swimming.assign', 'order' => 33],
['label_ar' => 'حجوزات السباحة', 'label_en' => 'Pool Reservations','route' => '/sa/swimming/pool-reservations','permission' => 'sa.pool_reservation.view','order' => 34], ['label_ar' => 'حجوزات السباحة', 'label_en' => 'Pool Reservations','route' => '/sa/swimming/pool-reservations','permission' => 'sa.pool_reservation.view','order' => 34],
['label_ar' => '── التقارير ──','label_en' => '── Reports ──', 'route' => '#', 'permission' => 'sa.report.players', 'order' => 40],
['label_ar' => 'تقارير اللاعبين','label_en' => 'Player Reports', 'route' => '/sa/reports/players', 'permission' => 'sa.report.players', 'order' => 41],
['label_ar' => 'التقارير المالية','label_en' => 'Finance Reports', 'route' => '/sa/reports/finance', 'permission' => 'sa.report.finance', 'order' => 42],
], ],
]); ]);
...@@ -129,6 +132,9 @@ PermissionRegistry::register('sports_activity', [ ...@@ -129,6 +132,9 @@ PermissionRegistry::register('sports_activity', [
'sa.pool_reservation.view' => ['ar' => 'عرض حجوزات السباحة', 'en' => 'View Pool Reservations'], 'sa.pool_reservation.view' => ['ar' => 'عرض حجوزات السباحة', 'en' => 'View Pool Reservations'],
'sa.pool_reservation.create' => ['ar' => 'إنشاء حجز سباحة', 'en' => 'Create Pool Reservation'], 'sa.pool_reservation.create' => ['ar' => 'إنشاء حجز سباحة', 'en' => 'Create Pool Reservation'],
'sa.pool_reservation.manage' => ['ar' => 'إدارة حجوزات السباحة', 'en' => 'Manage Pool Reservations'], 'sa.pool_reservation.manage' => ['ar' => 'إدارة حجوزات السباحة', 'en' => 'Manage Pool Reservations'],
'sa.report.players' => ['ar' => 'تقارير اللاعبين', 'en' => 'Player Reports'],
'sa.report.finance' => ['ar' => 'التقارير المالية للأنشطة', 'en' => 'Sports Finance Reports'],
'sa.report.export' => ['ar' => 'تصدير تقارير الأنشطة', 'en' => 'Export Sports Reports'],
]); ]);
// ─── Event Listeners ──────────────────────────────────────────────────────── // ─── Event Listeners ────────────────────────────────────────────────────────
......
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