Commit dd3a396a authored by Fares's avatar Fares

feat(swimming): add dedicated swimming section with dashboard and registration/assignment wizards

Adds /sa/swimming/* routes with a swimming-specific dashboard, player registration wizard
(with mandatory medical certificate upload), and group assignment wizard that sends fees
to the SA treasury. Players are stored in existing sa_players table and reuse EnrollmentService.
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent ecb53aca
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Controllers\Swimming;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\SportsActivity\Services\EnrollmentService;
use App\Modules\SportsActivity\SaConstants;
class SwimmingAssignmentController extends Controller
{
public function list(Request $request): Response
{
$db = App::getInstance()->db();
$discipline = $db->selectOne(
"SELECT id FROM sa_disciplines WHERE code = 'SWIMMING' AND is_archived = 0"
);
if (!$discipline) {
return $this->redirect('/sa')->withError('لم يتم العثور على نشاط السباحة');
}
$disciplineId = (int) $discipline['id'];
$players = $db->select("
SELECT pl.id, pl.full_name_ar, pl.date_of_birth, pl.gender, pl.phone,
pl.medical_status, pl.photo_path, pl.player_type, pl.created_at,
(SELECT MAX(doc.created_at) FROM sa_player_documents doc
WHERE doc.player_id = pl.id AND doc.document_type = 'medical_cert') as medical_upload_date
FROM sa_players pl
WHERE pl.is_archived = 0
AND EXISTS (
SELECT 1 FROM sa_player_documents doc
WHERE doc.player_id = pl.id AND doc.document_type = 'medical_cert'
)
AND NOT EXISTS (
SELECT 1 FROM sa_group_players gp
INNER JOIN sa_groups g ON g.id = gp.group_id
INNER JOIN sa_programs p ON p.id = g.program_id
WHERE p.discipline_id = ? AND gp.player_id = pl.id AND gp.status IN ('active', 'pending_payment')
)
ORDER BY pl.created_at DESC
", [$disciplineId]);
return $this->view('SportsActivity.Views.swimming.assign', [
'players' => $players,
'player' => null,
'groups' => [],
'mode' => 'list',
'disciplineId' => $disciplineId,
]);
}
public function index(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$discipline = $db->selectOne(
"SELECT id FROM sa_disciplines WHERE code = 'SWIMMING' AND is_archived = 0"
);
if (!$discipline) {
return $this->redirect('/sa')->withError('لم يتم العثور على نشاط السباحة');
}
$disciplineId = (int) $discipline['id'];
$isRegistrationId = (bool) $db->selectOne(
"SELECT 1 FROM sa_registrations WHERE id = ?", [(int) $id]
);
if ($isRegistrationId) {
$registration = $db->selectOne(
"SELECT r.*, p.id as player_id, p.full_name_ar, p.date_of_birth, p.gender,
p.phone, p.photo_path, p.player_type, p.medical_status
FROM sa_registrations r
INNER JOIN sa_players p ON p.id = r.player_id
WHERE r.id = ?",
[(int) $id]
);
if (!$registration) {
return $this->redirect('/sa/swimming/assign')->withError('التسجيل غير موجود');
}
$player = $registration;
$playerId = (int) $registration['player_id'];
} else {
$player = $db->selectOne(
"SELECT *, id as player_id FROM sa_players WHERE id = ? AND is_archived = 0",
[(int) $id]
);
if (!$player) {
return $this->redirect('/sa/swimming/assign')->withError('اللاعب غير موجود');
}
$playerId = (int) $id;
}
$hasMedical = (bool) $db->selectOne(
"SELECT 1 FROM sa_player_documents WHERE player_id = ? AND document_type = 'medical_cert'",
[$playerId]
);
if (!$hasMedical) {
return $this->redirect('/sa/swimming/register')->withError('يجب رفع الشهادة الطبية أولاً');
}
$groups = $db->select("
SELECT g.id, g.name_ar, g.current_count, g.schedule_summary, g.min_age, g.max_age,
p.name_ar as program_name, p.max_capacity, p.monthly_fee_member, p.monthly_fee_nonmember,
p.age_from, p.age_to,
c.full_name_ar as coach_name
FROM sa_groups g
INNER JOIN sa_programs p ON p.id = g.program_id
LEFT JOIN sa_coaches c ON c.id = g.coach_id
WHERE p.discipline_id = ? AND g.status = 'active' AND g.is_archived = 0
ORDER BY p.name_ar ASC, g.name_ar ASC
", [$disciplineId]);
$playerAge = null;
if (!empty($player['date_of_birth'])) {
$playerAge = (int) date_diff(
date_create($player['date_of_birth']),
date_create()
)->y;
}
return $this->view('SportsActivity.Views.swimming.assign', [
'players' => [],
'player' => $player,
'playerId' => $playerId,
'playerAge' => $playerAge,
'groups' => $groups,
'mode' => 'assign',
'disciplineId' => $disciplineId,
]);
}
public function assign(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$groupId = (int) $request->post('group_id', 0);
if ($groupId <= 0) {
return $this->json(['success' => false, 'error' => 'اختر مجموعة']);
}
$isRegistrationId = (bool) $db->selectOne(
"SELECT 1 FROM sa_registrations WHERE id = ?", [(int) $id]
);
if ($isRegistrationId) {
$registration = $db->selectOne(
"SELECT player_id FROM sa_registrations WHERE id = ?", [(int) $id]
);
$playerId = (int) $registration['player_id'];
} else {
$playerId = (int) $id;
}
$result = EnrollmentService::enroll($groupId, $playerId);
if (!$result['success']) {
return $this->json($result);
}
if ($isRegistrationId) {
$db->update('sa_registrations', [
'group_id' => $groupId,
'status' => SaConstants::REG_PENDING_PAYMENT,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $id]);
}
return $this->json([
'success' => true,
'enrollment_id' => $result['enrollment_id'],
'request_id' => $result['request_id'],
'request_number' => $result['request_number'],
'fee' => $result['fee'],
'redirect' => '/sa/swimming/assign/' . $id . '?done=1',
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Controllers\Swimming;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
class SwimmingDashboardController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
$monthStart = date('Y-m-01');
$monthEnd = date('Y-m-t');
$discipline = $db->selectOne(
"SELECT id FROM sa_disciplines WHERE code = 'SWIMMING' AND is_archived = 0"
);
if (!$discipline) {
return $this->redirect('/sa')->withError('لم يتم العثور على نشاط السباحة');
}
$disciplineId = (int) $discipline['id'];
$counts = $db->selectOne("
SELECT
(SELECT COUNT(DISTINCT gp.player_id) FROM sa_group_players gp
INNER JOIN sa_groups g ON g.id = gp.group_id
INNER JOIN sa_programs p ON p.id = g.program_id
WHERE p.discipline_id = ? AND gp.status = 'active') as active_players,
(SELECT COUNT(*) FROM sa_groups g
INNER JOIN sa_programs p ON p.id = g.program_id
WHERE p.discipline_id = ? AND g.status = 'active' AND g.is_archived = 0) as active_groups,
(SELECT COUNT(DISTINCT gp.player_id) FROM sa_group_players gp
INNER JOIN sa_groups g ON g.id = gp.group_id
INNER JOIN sa_programs p ON p.id = g.program_id
WHERE p.discipline_id = ? AND gp.status = 'pending_payment') as pending_payment,
(SELECT COALESCE(SUM(pr.amount), 0) FROM payment_requests pr
INNER JOIN sa_group_players gp ON gp.payment_request_id = pr.id
INNER JOIN sa_groups g ON g.id = gp.group_id
INNER JOIN sa_programs p ON p.id = g.program_id
WHERE p.discipline_id = ? AND pr.status = 'completed'
AND pr.completed_at BETWEEN ? AND ?) as revenue_this_month
", [$disciplineId, $disciplineId, $disciplineId, $disciplineId, $monthStart . ' 00:00:00', $monthEnd . ' 23:59:59']);
$groups = $db->select("
SELECT g.id, g.name_ar, g.current_count, g.status, g.schedule_summary,
p.name_ar as program_name, p.max_capacity, p.monthly_fee_member, p.monthly_fee_nonmember,
c.full_name_ar as coach_name,
(SELECT COUNT(*) FROM sa_group_players gp WHERE gp.group_id = g.id AND gp.status = 'active') as active_count,
(SELECT COUNT(*) FROM sa_group_players gp WHERE gp.group_id = g.id AND gp.status = 'pending_payment') as pending_count
FROM sa_groups g
INNER JOIN sa_programs p ON p.id = g.program_id
LEFT JOIN sa_coaches c ON c.id = g.coach_id
WHERE p.discipline_id = ? AND g.is_archived = 0
ORDER BY g.status ASC, g.name_ar ASC
", [$disciplineId]);
$recentRegistrations = $db->select("
SELECT r.id, r.registration_number, r.status, r.created_at,
pl.full_name_ar, pl.photo_path, pl.medical_status,
g.name_ar as group_name
FROM sa_registrations r
INNER JOIN sa_players pl ON pl.id = r.player_id
LEFT JOIN sa_groups g ON g.id = r.group_id
LEFT JOIN sa_programs p ON p.id = g.program_id
WHERE r.status != 'cancelled'
AND (p.discipline_id = ? OR EXISTS (
SELECT 1 FROM sa_group_players gp2
INNER JOIN sa_groups g2 ON g2.id = gp2.group_id
INNER JOIN sa_programs p3 ON p3.id = g2.program_id
WHERE p3.discipline_id = ? AND gp2.player_id = r.player_id
))
ORDER BY r.created_at DESC
LIMIT 10
", [$disciplineId, $disciplineId]);
$unassignedPlayers = $db->select("
SELECT pl.id, pl.full_name_ar, pl.date_of_birth, pl.medical_status, pl.created_at
FROM sa_players pl
WHERE pl.is_archived = 0
AND pl.medical_status IN ('fit', 'conditional', 'pending')
AND NOT EXISTS (
SELECT 1 FROM sa_group_players gp
INNER JOIN sa_groups g ON g.id = gp.group_id
INNER JOIN sa_programs p ON p.id = g.program_id
WHERE p.discipline_id = ? AND gp.player_id = pl.id AND gp.status IN ('active', 'pending_payment')
)
AND EXISTS (
SELECT 1 FROM sa_player_documents doc
WHERE doc.player_id = pl.id AND doc.document_type = 'medical_cert'
)
ORDER BY pl.created_at DESC
LIMIT 10
", [$disciplineId]);
$programs = $db->select("
SELECT p.id, p.name_ar, p.monthly_fee_member, p.monthly_fee_nonmember, p.max_capacity,
COUNT(DISTINCT g.id) as group_count,
COALESCE(SUM(g.current_count), 0) as total_enrolled
FROM sa_programs p
LEFT JOIN sa_groups g ON g.program_id = p.id AND g.is_archived = 0 AND g.status = 'active'
WHERE p.discipline_id = ? AND p.is_archived = 0
GROUP BY p.id, p.name_ar, p.monthly_fee_member, p.monthly_fee_nonmember, p.max_capacity
ORDER BY p.name_ar
", [$disciplineId]);
return $this->view('SportsActivity.Views.swimming.dashboard', [
'counts' => $counts,
'groups' => $groups,
'recentRegistrations' => $recentRegistrations,
'unassignedPlayers' => $unassignedPlayers,
'programs' => $programs,
'disciplineId' => $disciplineId,
]);
}
}
......@@ -292,6 +292,17 @@ return [
['POST', '/api/sa/subscriptions/pause', 'SportsActivity\Controllers\Api\SubscriptionPreviewApiController@pause', ['auth', 'csrf'], 'sa.subscription.generate'],
['POST', '/api/sa/subscriptions/unpause', 'SportsActivity\Controllers\Api\SubscriptionPreviewApiController@unpause', ['auth', 'csrf'], 'sa.subscription.generate'],
// ─── Swimming Section ───────────────────────────────────────────────────────
['GET', '/sa/swimming', 'SportsActivity\Controllers\Swimming\SwimmingDashboardController@index', ['auth'], 'sa.swimming.dashboard'],
['GET', '/sa/swimming/register', 'SportsActivity\Controllers\Swimming\SwimmingRegistrationController@index', ['auth'], 'sa.swimming.register'],
['POST', '/sa/swimming/register/lookup', 'SportsActivity\Controllers\Swimming\SwimmingRegistrationController@lookup', ['auth', 'csrf'], 'sa.swimming.register'],
['GET', '/sa/swimming/register/{id:\d+}', 'SportsActivity\Controllers\Swimming\SwimmingRegistrationController@step', ['auth'], 'sa.swimming.register'],
['POST', '/sa/swimming/register/{id:\d+}/medical', 'SportsActivity\Controllers\Swimming\SwimmingRegistrationController@saveMedical', ['auth', 'csrf'], 'sa.swimming.register'],
['POST', '/sa/swimming/register/{id:\d+}/photo', 'SportsActivity\Controllers\Swimming\SwimmingRegistrationController@savePhoto', ['auth', 'csrf'], 'sa.swimming.register'],
['GET', '/sa/swimming/assign', 'SportsActivity\Controllers\Swimming\SwimmingAssignmentController@list', ['auth'], 'sa.swimming.assign'],
['GET', '/sa/swimming/assign/{id:\d+}', 'SportsActivity\Controllers\Swimming\SwimmingAssignmentController@index', ['auth'], 'sa.swimming.assign'],
['POST', '/sa/swimming/assign/{id:\d+}/group', 'SportsActivity\Controllers\Swimming\SwimmingAssignmentController@assign', ['auth', 'csrf'], 'sa.swimming.assign'],
// ─── Academy Pricing ────────────────────────────────────────────────────────
['GET', '/sa/academy-pricing', 'SportsActivity\Controllers\AcademyPricingController@index', ['auth'], 'sa.pricing.view'],
['GET', '/sa/academy-pricing/academies', 'SportsActivity\Controllers\AcademyPricingController@academies', ['auth'], 'sa.pricing.view'],
......
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تم التسجيل بنجاح<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="max-width:600px;margin:40px auto;text-align:center;padding:40px;">
<div style="width:80px;height:80px;border-radius:50%;background:#ECFDF5;display:flex;align-items:center;justify-content:center;margin:0 auto 20px;">
<i data-lucide="check-circle-2" style="width:40px;height:40px;color:#059669;"></i>
</div>
<h2 style="font-size:20px;font-weight:700;color:#1A1A2E;margin-bottom:8px;">تم تسجيل وتعيين اللاعب بنجاح</h2>
<p style="font-size:14px;color:#6B7280;margin-bottom:24px;">تم إرسال طلب الدفع إلى خزينة الأنشطة الرياضية. سيتم تفعيل اللاعب في المجموعة بمجرد تأكيد الدفع.</p>
<?php if (isset($player) && $player): ?>
<div style="background:#F9FAFB;border-radius:8px;padding:16px;margin-bottom:24px;text-align:right;">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:13px;">
<div><span style="color:#6B7280;">اللاعب:</span> <strong><?= e($player['full_name_ar'] ?? '') ?></strong></div>
<div><span style="color:#6B7280;">المجموعة:</span> <strong><?= e($group['name_ar'] ?? '') ?></strong></div>
<div><span style="color:#6B7280;">البرنامج:</span> <strong><?= e($program['name_ar'] ?? '') ?></strong></div>
<div><span style="color:#6B7280;">المبلغ:</span> <strong style="color:#059669;"><?= isset($fee) ? number_format((float) $fee) : '—' ?> ج.م</strong></div>
</div>
</div>
<?php endif; ?>
<div style="display:flex;gap:10px;justify-content:center;flex-wrap:wrap;">
<a href="/sa/swimming" class="btn btn-primary"><i data-lucide="layout-dashboard" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i>لوحة التحكم</a>
<a href="/sa/swimming/register" class="btn btn-outline"><i data-lucide="user-plus" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i>تسجيل لاعب آخر</a>
<a href="/sa/players" class="btn btn-outline"><i data-lucide="users" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i>عرض اللاعبين</a>
</div>
</div>
<?php $__template->endSection(); ?>
This diff is collapsed.
This diff is collapsed.
......@@ -40,6 +40,10 @@ MenuRegistry::register('sports_activity', [
['label_ar' => 'اللوكرات', 'label_en' => 'Lockers', 'route' => '/sa/lockers', 'permission' => 'sa.locker.view', 'order' => 20],
['label_ar' => 'إيجارات اللوكرات', 'label_en' => 'Locker Rentals', 'route' => '/sa/locker-rentals', 'permission' => 'sa.locker_rental.view','order' => 21],
['label_ar' => 'تذاكر السباحة', 'label_en' => 'Pool Tickets', 'route' => '/sa/pool-tickets', 'permission' => 'sa.pool_ticket.view', 'order' => 22],
['label_ar' => '── السباحة ──', 'label_en' => '── Swimming ──', 'route' => '#', 'permission' => 'sa.swimming.dashboard','order' => 30],
['label_ar' => 'لوحة تحكم السباحة','label_en' => 'Swimming Dashboard','route' => '/sa/swimming', 'permission' => 'sa.swimming.dashboard','order' => 31],
['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],
],
]);
......@@ -99,6 +103,9 @@ PermissionRegistry::register('sports_activity', [
'sa.pool_ticket.view' => ['ar' => 'عرض تذاكر حمام السباحة', 'en' => 'View Pool Tickets'],
'sa.pool_ticket.issue' => ['ar' => 'إصدار تذكرة سباحة', 'en' => 'Issue Pool Ticket'],
'sa.pool_ticket.manage' => ['ar' => 'إدارة تذاكر السباحة', 'en' => 'Manage Pool Tickets'],
'sa.swimming.dashboard' => ['ar' => 'لوحة تحكم السباحة', 'en' => 'Swimming Dashboard'],
'sa.swimming.register' => ['ar' => 'تسجيل لاعب سباحة', 'en' => 'Register Swimming Player'],
'sa.swimming.assign' => ['ar' => 'تعيين لاعب في مجموعة سباحة','en' => 'Assign Swimming Player to Group'],
]);
// ─── 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