Commit a5018bba authored by Mahmoud Aglan's avatar Mahmoud Aglan

Gap sportsactivity

parent d63df4a2
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\SportsActivity\Services\BookingPassService;
class BookingPassController extends Controller
{
public function passes(Request $request, string $bookingId): Response
{
$db = App::getInstance()->db();
$booking = $db->selectOne(
"SELECT b.*, fu.name_ar as unit_name, f.name_ar as facility_name
FROM sa_bookings b
INNER JOIN sa_facility_units fu ON fu.id = b.facility_unit_id
INNER JOIN sa_facilities f ON f.id = fu.facility_id
WHERE b.id = ?",
[(int) $bookingId]
);
if (!$booking) {
return $this->redirect('/sa/bookings')->withError('الحجز غير موجود');
}
$passes = BookingPassService::getBookingPasses((int) $bookingId);
return $this->view('SportsActivity.Views.bookings.passes', [
'booking' => $booking,
'passes' => $passes,
]);
}
public function usePass(Request $request): Response
{
$passNumber = trim((string) $request->post('pass_number', ''));
$usedByName = trim((string) $request->post('used_by_name', ''));
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
if ($passNumber === '') {
return $this->json(['success' => false, 'error' => 'أدخل رقم الباس']);
}
$result = BookingPassService::usePass($passNumber, $usedByName ?: null, $employeeId);
return $this->json($result);
}
public function validatePass(Request $request): Response
{
$passNumber = trim((string) $request->get('pass_number', ''));
if ($passNumber === '') {
return $this->json(['valid' => false, 'error' => 'أدخل رقم الباس']);
}
$result = BookingPassService::validatePass($passNumber);
return $this->json($result);
}
}
...@@ -101,7 +101,7 @@ class BookingWizardController extends Controller ...@@ -101,7 +101,7 @@ class BookingWizardController extends Controller
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$units = $db->select( $units = $db->select(
"SELECT fu.id, fu.code, fu.name_ar, fu.unit_type, fu.booking_mode, fu.max_capacity, "SELECT fu.id, fu.code, fu.name_ar, fu.unit_type, fu.booking_mode, fu.max_capacity, fu.expected_capacity,
f.id as facility_id, f.name_ar as facility_name, f.facility_type, f.operating_hours_json f.id as facility_id, f.name_ar as facility_name, f.facility_type, f.operating_hours_json
FROM sa_facility_units fu FROM sa_facility_units fu
JOIN sa_facilities f ON f.id = fu.facility_id JOIN sa_facilities f ON f.id = fu.facility_id
...@@ -122,12 +122,13 @@ class BookingWizardController extends Controller ...@@ -122,12 +122,13 @@ class BookingWizardController extends Controller
]; ];
} }
$grouped[$fId]['units'][] = [ $grouped[$fId]['units'][] = [
'id' => (int) $u['id'], 'id' => (int) $u['id'],
'code' => $u['code'], 'code' => $u['code'],
'name' => $u['name_ar'], 'name' => $u['name_ar'],
'unit_type' => $u['unit_type'], 'unit_type' => $u['unit_type'],
'booking_mode' => $u['booking_mode'], 'booking_mode' => $u['booking_mode'],
'max_capacity' => (int) $u['max_capacity'], 'max_capacity' => (int) $u['max_capacity'],
'expected_capacity' => (int) ($u['expected_capacity'] ?? 1),
]; ];
} }
...@@ -229,11 +230,12 @@ class BookingWizardController extends Controller ...@@ -229,11 +230,12 @@ class BookingWizardController extends Controller
} }
return $this->json([ return $this->json([
'success' => true, 'success' => true,
'unit_name' => $unit['name_ar'], 'unit_name' => $unit['name_ar'],
'booking_mode' => $unit['booking_mode'], 'booking_mode' => $unit['booking_mode'],
'max_capacity' => (int) $unit['max_capacity'], 'max_capacity' => (int) $unit['max_capacity'],
'slots' => $slots, 'expected_capacity' => (int) ($unit['expected_capacity'] ?? 1),
'slots' => $slots,
]); ]);
} }
...@@ -262,22 +264,32 @@ class BookingWizardController extends Controller ...@@ -262,22 +264,32 @@ class BookingWizardController extends Controller
$nationalId = trim((string) $request->post('national_id', '')); $nationalId = trim((string) $request->post('national_id', ''));
$isMember = (bool) $request->post('is_member', false); $isMember = (bool) $request->post('is_member', false);
$memberId = (int) $request->post('member_id', 0); $memberId = (int) $request->post('member_id', 0);
$organizationName = trim((string) $request->post('organization_name', ''));
$organizationContact = trim((string) $request->post('organization_contact', ''));
$participantMode = trim((string) $request->post('participant_mode', 'passes'));
$branch = App::getInstance()->currentBranch(); $branch = App::getInstance()->currentBranch();
$bookerType = $isMember ? 'member' : 'guest';
$bookerType = $request->post('booker_type', '');
if ($bookerType === '') {
$bookerType = $isMember ? 'member' : 'guest';
}
$bookingResult = BookingService::createHourlyBooking([ $bookingResult = BookingService::createHourlyBooking([
'facility_unit_id' => $unitId, 'facility_unit_id' => $unitId,
'booking_date' => $date, 'booking_date' => $date,
'start_time' => $startTime, 'start_time' => $startTime,
'end_time' => $endTime, 'end_time' => $endTime,
'participant_count' => $participants, 'participant_count' => $participants,
'spots_reserved' => $participants, 'spots_reserved' => $participants,
'booker_type' => $bookerType, 'booker_type' => $bookerType,
'booker_id' => $memberId > 0 ? $memberId : null, 'booker_id' => $memberId > 0 ? $memberId : null,
'booker_name' => $bookerName, 'booker_name' => $bookerName,
'notes' => null, 'organization_name' => $organizationName ?: null,
'branch_id' => $branch ? (int) $branch['id'] : null, 'organization_contact' => $organizationContact ?: null,
'participant_mode' => $participantMode,
'notes' => null,
'branch_id' => $branch ? (int) $branch['id'] : null,
]); ]);
if (!$bookingResult['success']) { if (!$bookingResult['success']) {
......
...@@ -63,6 +63,7 @@ class FacilityUnitController extends Controller ...@@ -63,6 +63,7 @@ class FacilityUnitController extends Controller
$unitType = trim((string) $request->post('unit_type', '')); $unitType = trim((string) $request->post('unit_type', ''));
$bookingMode = trim((string) $request->post('booking_mode', '')); $bookingMode = trim((string) $request->post('booking_mode', ''));
$maxCapacity = (int) $request->post('max_capacity', 1); $maxCapacity = (int) $request->post('max_capacity', 1);
$expectedCapacity = (int) $request->post('expected_capacity', 1);
$sortOrder = (int) $request->post('sort_order', 0); $sortOrder = (int) $request->post('sort_order', 0);
// Validation // Validation
...@@ -89,6 +90,9 @@ class FacilityUnitController extends Controller ...@@ -89,6 +90,9 @@ class FacilityUnitController extends Controller
if ($maxCapacity < 1) { if ($maxCapacity < 1) {
$errors[] = 'السعة القصوى يجب أن تكون 1 على الأقل'; $errors[] = 'السعة القصوى يجب أن تكون 1 على الأقل';
} }
if ($expectedCapacity < 1) {
$expectedCapacity = 1;
}
if (!empty($errors)) { if (!empty($errors)) {
$session = App::getInstance()->session(); $session = App::getInstance()->session();
...@@ -112,15 +116,16 @@ class FacilityUnitController extends Controller ...@@ -112,15 +116,16 @@ class FacilityUnitController extends Controller
} }
FacilityUnit::create([ FacilityUnit::create([
'facility_id' => (int) $fid, 'facility_id' => (int) $fid,
'code' => $code, 'code' => $code,
'name_ar' => $nameAr, 'name_ar' => $nameAr,
'name_en' => $nameEn ?: null, 'name_en' => $nameEn ?: null,
'unit_type' => $unitType, 'unit_type' => $unitType,
'booking_mode' => $bookingMode, 'booking_mode' => $bookingMode,
'max_capacity' => $maxCapacity, 'max_capacity' => $maxCapacity,
'sort_order' => $sortOrder, 'expected_capacity' => $expectedCapacity,
'is_active' => 1, 'sort_order' => $sortOrder,
'is_active' => 1,
]); ]);
return $this->redirect("/sa/facilities/{$fid}/units")->withSuccess('تم إضافة الوحدة بنجاح'); return $this->redirect("/sa/facilities/{$fid}/units")->withSuccess('تم إضافة الوحدة بنجاح');
...@@ -168,6 +173,7 @@ class FacilityUnitController extends Controller ...@@ -168,6 +173,7 @@ class FacilityUnitController extends Controller
$unitType = trim((string) $request->post('unit_type', '')); $unitType = trim((string) $request->post('unit_type', ''));
$bookingMode = trim((string) $request->post('booking_mode', '')); $bookingMode = trim((string) $request->post('booking_mode', ''));
$maxCapacity = (int) $request->post('max_capacity', 1); $maxCapacity = (int) $request->post('max_capacity', 1);
$expectedCapacity = (int) $request->post('expected_capacity', 1);
$sortOrder = (int) $request->post('sort_order', 0); $sortOrder = (int) $request->post('sort_order', 0);
// Validation // Validation
...@@ -194,6 +200,9 @@ class FacilityUnitController extends Controller ...@@ -194,6 +200,9 @@ class FacilityUnitController extends Controller
if ($maxCapacity < 1) { if ($maxCapacity < 1) {
$errors[] = 'السعة القصوى يجب أن تكون 1 على الأقل'; $errors[] = 'السعة القصوى يجب أن تكون 1 على الأقل';
} }
if ($expectedCapacity < 1) {
$expectedCapacity = 1;
}
if (!empty($errors)) { if (!empty($errors)) {
$session = App::getInstance()->session(); $session = App::getInstance()->session();
...@@ -217,13 +226,14 @@ class FacilityUnitController extends Controller ...@@ -217,13 +226,14 @@ class FacilityUnitController extends Controller
} }
$unit->update([ $unit->update([
'code' => $code, 'code' => $code,
'name_ar' => $nameAr, 'name_ar' => $nameAr,
'name_en' => $nameEn ?: null, 'name_en' => $nameEn ?: null,
'unit_type' => $unitType, 'unit_type' => $unitType,
'booking_mode' => $bookingMode, 'booking_mode' => $bookingMode,
'max_capacity' => $maxCapacity, 'max_capacity' => $maxCapacity,
'sort_order' => $sortOrder, 'expected_capacity' => $expectedCapacity,
'sort_order' => $sortOrder,
]); ]);
return $this->redirect("/sa/facilities/{$fid}/units")->withSuccess('تم تحديث الوحدة بنجاح'); return $this->redirect("/sa/facilities/{$fid}/units")->withSuccess('تم تحديث الوحدة بنجاح');
......
...@@ -455,17 +455,40 @@ class GroupController extends Controller ...@@ -455,17 +455,40 @@ class GroupController extends Controller
return $this->redirect('/sa/groups/' . $id)->withSuccess("تم حفظ الجدول ({$inserted} حصة)"); return $this->redirect('/sa/groups/' . $id)->withSuccess("تم حفظ الجدول ({$inserted} حصة)");
} }
public function forceEnroll(Request $request, string $id): Response
{
$this->authorize('sa.group.force_enroll');
$playerId = (int) $request->post('player_id', 0);
if ($playerId === 0) {
return $this->redirect('/sa/groups/' . $id)->withError('يرجى اختيار لاعب');
}
$result = EnrollmentService::forceEnroll((int) $id, $playerId);
if ($result['success']) {
$msg = 'تم تسجيل اللاعب (تجاوز السعة) — في انتظار الدفع';
if (!empty($result['request_number'])) {
$msg .= ' (طلب دفع: ' . $result['request_number'] . ')';
}
return $this->redirect('/sa/groups/' . $id)->withSuccess($msg);
}
return $this->redirect('/sa/groups/' . $id)->withError($result['error']);
}
public function transferPlayer(Request $request, string $id): Response public function transferPlayer(Request $request, string $id): Response
{ {
$playerId = (int) $request->post('player_id', 0); $playerId = (int) $request->post('player_id', 0);
$toGroupId = (int) $request->post('to_group_id', 0); $toGroupId = (int) $request->post('to_group_id', 0);
$reason = trim((string) $request->post('reason', '')); $reason = trim((string) $request->post('reason', ''));
$force = (bool) $request->post('force', false);
if ($playerId === 0 || $toGroupId === 0) { if ($playerId === 0 || $toGroupId === 0) {
return $this->redirect('/sa/groups/' . $id)->withError('يرجى تحديد اللاعب والمجموعة المنقول إليها'); return $this->redirect('/sa/groups/' . $id)->withError('يرجى تحديد اللاعب والمجموعة المنقول إليها');
} }
$result = GroupTransferService::transfer($playerId, (int) $id, $toGroupId, $reason); $result = GroupTransferService::transfer($playerId, (int) $id, $toGroupId, $reason, $force);
if ($result['success']) { if ($result['success']) {
return $this->redirect('/sa/groups/' . $id)->withSuccess('تم نقل اللاعب بنجاح'); return $this->redirect('/sa/groups/' . $id)->withSuccess('تم نقل اللاعب بنجاح');
......
...@@ -7,35 +7,64 @@ use App\Core\Controller; ...@@ -7,35 +7,64 @@ use App\Core\Controller;
use App\Core\Request; use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Modules\SportsActivity\Models\Facility;
use App\Modules\SportsActivity\Services\MirrorStateService; use App\Modules\SportsActivity\Services\MirrorStateService;
class MirrorController extends Controller class MirrorController extends Controller
{ {
/**
* List all active facilities with links to mirror views.
*/
public function index(Request $request): Response public function index(Request $request): Response
{ {
$facilities = Facility::getActive(); $db = App::getInstance()->db();
$disciplines = $db->select(
"SELECT d.id, d.name_ar, d.name_en, COUNT(f.id) as facility_count
FROM sa_disciplines d
INNER JOIN sa_facilities f ON f.discipline_id = d.id AND f.is_active = 1 AND f.is_archived = 0
WHERE d.is_active = 1 AND d.is_archived = 0
GROUP BY d.id, d.name_ar, d.name_en
ORDER BY d.name_ar"
);
$facilities = $db->select(
"SELECT f.*, d.name_ar as discipline_name
FROM sa_facilities f
LEFT JOIN sa_disciplines d ON d.id = f.discipline_id
WHERE f.is_active = 1 AND f.is_archived = 0
ORDER BY d.name_ar, f.name_ar"
);
$grouped = [];
foreach ($facilities as $f) {
$dName = $f['discipline_name'] ?? 'أخرى';
$grouped[$dName][] = $f;
}
return $this->view('SportsActivity.Views.mirror.index', [ return $this->view('SportsActivity.Views.mirror.index', [
'facilities' => $facilities, 'facilities' => $facilities,
'disciplines' => $disciplines,
'grouped' => $grouped,
]); ]);
} }
/** public function byDiscipline(Request $request, string $id): Response
* Mirror view for a facility on today's date. {
*/ $today = date('Y-m-d');
return $this->renderDisciplineView((int) $id, $today);
}
public function byDisciplineDate(Request $request, string $id, string $date): Response
{
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
return $this->redirect('/sa/mirror')->withError('تاريخ غير صالح');
}
return $this->renderDisciplineView((int) $id, $date);
}
public function facility(Request $request, string $id): Response public function facility(Request $request, string $id): Response
{ {
$today = date('Y-m-d'); $today = date('Y-m-d');
return $this->renderFacilityView((int) $id, $today); return $this->renderFacilityView((int) $id, $today);
} }
/**
* Mirror view for a facility on a specific date.
*/
public function facilityDate(Request $request, string $id, string $date): Response public function facilityDate(Request $request, string $id, string $date): Response
{ {
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) { if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
...@@ -44,9 +73,21 @@ class MirrorController extends Controller ...@@ -44,9 +73,21 @@ class MirrorController extends Controller
return $this->renderFacilityView((int) $id, $date); return $this->renderFacilityView((int) $id, $date);
} }
/** private function renderDisciplineView(int $disciplineId, string $date): Response
* Render the facility mirror grid. {
*/ $state = MirrorStateService::getMultiFacilityState($disciplineId, $date);
if (isset($state['error'])) {
return $this->redirect('/sa/mirror')->withError($state['error']);
}
return $this->view('SportsActivity.Views.mirror.discipline_view', [
'discipline' => $state['discipline'],
'facilityStates' => $state['facilities'],
'date' => $date,
]);
}
private function renderFacilityView(int $facilityId, string $date): Response private function renderFacilityView(int $facilityId, string $date): Response
{ {
$state = MirrorStateService::getFacilityState($facilityId, $date); $state = MirrorStateService::getFacilityState($facilityId, $date);
......
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\SportsActivity\Services\PoolTicketService;
use App\Modules\SportsActivity\Services\PoolGridService;
class PoolTicketController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$facilityId = (int) $request->get('facility_id', 0);
$date = trim((string) $request->get('date', date('Y-m-d')));
$facilities = $db->select(
"SELECT * FROM sa_facilities WHERE facility_type = 'pool' AND is_active = 1 AND is_archived = 0"
);
$tickets = [];
if ($facilityId > 0) {
$tickets = PoolTicketService::getActiveTickets($facilityId, $date);
}
return $this->view('SportsActivity.Views.pool-tickets.index', [
'facilities' => $facilities,
'tickets' => $tickets,
'facilityId' => $facilityId,
'date' => $date,
]);
}
public function issue(Request $request): Response
{
$data = [
'facility_id' => (int) $request->post('facility_id', 0),
'booking_date' => trim((string) $request->post('booking_date', date('Y-m-d'))),
'start_time' => trim((string) $request->post('start_time', '')),
'end_time' => trim((string) $request->post('end_time', '')),
'is_member' => (bool) $request->post('is_member', false),
'member_id' => $request->post('member_id') ? (int) $request->post('member_id') : null,
'player_id' => $request->post('player_id') ? (int) $request->post('player_id') : null,
'guest_name' => trim((string) $request->post('guest_name', '')),
'guest_national_id' => trim((string) $request->post('guest_national_id', '')),
];
if ($data['facility_id'] <= 0 || $data['start_time'] === '') {
return $this->json(['success' => false, 'error' => 'بيانات غير مكتملة']);
}
$result = PoolTicketService::issueTicket($data);
return $this->json($result);
}
public function cancel(Request $request, string $id): Response
{
$reason = trim((string) $request->post('reason', ''));
$result = PoolTicketService::cancelTicket((int) $id, $reason);
return $this->json($result);
}
public function entry(Request $request, string $id): Response
{
$result = PoolTicketService::recordEntry((int) $id);
return $this->json($result);
}
public function exit(Request $request, string $id): Response
{
$result = PoolTicketService::recordExit((int) $id);
return $this->json($result);
}
}
...@@ -177,18 +177,56 @@ class RegistrationWizardController extends Controller ...@@ -177,18 +177,56 @@ class RegistrationWizardController extends Controller
public function selectActivity(Request $request, string $id): Response public function selectActivity(Request $request, string $id): Response
{ {
$registrationId = (int) $id; $registrationId = (int) $id;
$programId = (int) $request->post('program_id', 0);
$groupId = (int) $request->post('group_id', 0); $groupId = (int) $request->post('group_id', 0);
$months = max(1, (int) $request->post('months', 1)); $months = max(1, (int) $request->post('months', 1));
$hasSibling = (bool) $request->post('has_sibling', false); $hasSibling = (bool) $request->post('has_sibling', false);
if ($groupId <= 0) { if ($programId > 0) {
return $this->json(['success' => false, 'error' => 'اختر مجموعة']); $result = RegistrationWizardService::selectProgram($registrationId, $programId, $months, $hasSibling);
} elseif ($groupId > 0) {
$result = RegistrationWizardService::selectGroup($registrationId, $groupId, $months, $hasSibling);
} else {
return $this->json(['success' => false, 'error' => 'اختر برنامج أو مجموعة']);
} }
$result = RegistrationWizardService::selectGroup($registrationId, $groupId, $months, $hasSibling);
return $this->json($result); return $this->json($result);
} }
public function getPrograms(Request $request, string $disciplineId): Response
{
$db = App::getInstance()->db();
$programs = $db->select(
"SELECT p.id, p.name_ar, p.name_en, p.description_ar,
COUNT(g.id) as group_count,
MIN(g.monthly_fee_member) as min_fee_member,
MIN(g.monthly_fee_nonmember) as min_fee_nonmember
FROM sa_programs p
LEFT JOIN sa_groups g ON g.program_id = p.id AND g.status = 'active' AND g.is_archived = 0
WHERE p.discipline_id = ? AND p.is_archived = 0
GROUP BY p.id, p.name_ar, p.name_en, p.description_ar
ORDER BY p.name_ar",
[(int) $disciplineId]
);
return $this->json(['success' => true, 'programs' => $programs]);
}
public function getDisciplines(Request $request): Response
{
$db = App::getInstance()->db();
$disciplines = $db->select(
"SELECT d.id, d.name_ar, d.name_en, d.icon
FROM sa_disciplines d
WHERE d.is_active = 1 AND d.is_archived = 0
ORDER BY d.name_ar"
);
return $this->json(['success' => true, 'disciplines' => $disciplines]);
}
public function submitPayment(Request $request, string $id): Response public function submitPayment(Request $request, string $id): Response
{ {
$registrationId = (int) $id; $registrationId = (int) $id;
......
...@@ -93,6 +93,7 @@ return [ ...@@ -93,6 +93,7 @@ return [
['GET', '/sa/groups/{id:\d+}/edit', 'SportsActivity\Controllers\GroupController@edit', ['auth'], 'sa.group.manage'], ['GET', '/sa/groups/{id:\d+}/edit', 'SportsActivity\Controllers\GroupController@edit', ['auth'], 'sa.group.manage'],
['POST', '/sa/groups/{id:\d+}', 'SportsActivity\Controllers\GroupController@update', ['auth', 'csrf'], 'sa.group.manage'], ['POST', '/sa/groups/{id:\d+}', 'SportsActivity\Controllers\GroupController@update', ['auth', 'csrf'], 'sa.group.manage'],
['POST', '/sa/groups/{id:\d+}/enroll', 'SportsActivity\Controllers\GroupController@enroll', ['auth', 'csrf'], 'sa.group.enroll'], ['POST', '/sa/groups/{id:\d+}/enroll', 'SportsActivity\Controllers\GroupController@enroll', ['auth', 'csrf'], 'sa.group.enroll'],
['POST', '/sa/groups/{id:\d+}/force-enroll', 'SportsActivity\Controllers\GroupController@forceEnroll', ['auth', 'csrf'], 'sa.group.force_enroll'],
['POST', '/sa/groups/{id:\d+}/remove-player', 'SportsActivity\Controllers\GroupController@removePlayer', ['auth', 'csrf'], 'sa.group.manage'], ['POST', '/sa/groups/{id:\d+}/remove-player', 'SportsActivity\Controllers\GroupController@removePlayer', ['auth', 'csrf'], 'sa.group.manage'],
['POST', '/sa/groups/{id:\d+}/transfer-player', 'SportsActivity\Controllers\GroupController@transferPlayer', ['auth', 'csrf'], 'sa.group.manage'], ['POST', '/sa/groups/{id:\d+}/transfer-player', 'SportsActivity\Controllers\GroupController@transferPlayer', ['auth', 'csrf'], 'sa.group.manage'],
['POST', '/sa/groups/{id:\d+}/schedule', 'SportsActivity\Controllers\GroupController@saveSchedule', ['auth', 'csrf'], 'sa.group.manage'], ['POST', '/sa/groups/{id:\d+}/schedule', 'SportsActivity\Controllers\GroupController@saveSchedule', ['auth', 'csrf'], 'sa.group.manage'],
...@@ -143,6 +144,8 @@ return [ ...@@ -143,6 +144,8 @@ return [
// Mirror (read-only display) // Mirror (read-only display)
['GET', '/sa/mirror', 'SportsActivity\Controllers\MirrorController@index', ['auth'], 'sa.mirror.view'], ['GET', '/sa/mirror', 'SportsActivity\Controllers\MirrorController@index', ['auth'], 'sa.mirror.view'],
['GET', '/sa/mirror/discipline/{id:\d+}', 'SportsActivity\Controllers\MirrorController@byDiscipline', ['auth'], 'sa.mirror.view'],
['GET', '/sa/mirror/discipline/{id:\d+}/{date}', 'SportsActivity\Controllers\MirrorController@byDisciplineDate', ['auth'], 'sa.mirror.view'],
['GET', '/sa/mirror/{id:\d+}', 'SportsActivity\Controllers\MirrorController@facility', ['auth'], 'sa.mirror.view'], ['GET', '/sa/mirror/{id:\d+}', 'SportsActivity\Controllers\MirrorController@facility', ['auth'], 'sa.mirror.view'],
['GET', '/sa/mirror/{id:\d+}/{date}', 'SportsActivity\Controllers\MirrorController@facilityDate', ['auth'], 'sa.mirror.view'], ['GET', '/sa/mirror/{id:\d+}/{date}', 'SportsActivity\Controllers\MirrorController@facilityDate', ['auth'], 'sa.mirror.view'],
['GET', '/sa/mirror/facility/{id:\d+}', 'SportsActivity\Controllers\MirrorController@facility', ['auth'], 'sa.mirror.view'], ['GET', '/sa/mirror/facility/{id:\d+}', 'SportsActivity\Controllers\MirrorController@facility', ['auth'], 'sa.mirror.view'],
...@@ -214,6 +217,10 @@ return [ ...@@ -214,6 +217,10 @@ return [
['GET', '/sa/registration/{id:\d+}/print-card', 'SportsActivity\Controllers\RegistrationWizardController@printCard', ['auth'], 'sa.registration.manage'], ['GET', '/sa/registration/{id:\d+}/print-card', 'SportsActivity\Controllers\RegistrationWizardController@printCard', ['auth'], 'sa.registration.manage'],
['POST', '/sa/registration/{id:\d+}/cancel', 'SportsActivity\Controllers\RegistrationWizardController@cancel', ['auth', 'csrf'], 'sa.registration.manage'], ['POST', '/sa/registration/{id:\d+}/cancel', 'SportsActivity\Controllers\RegistrationWizardController@cancel', ['auth', 'csrf'], 'sa.registration.manage'],
// Registration Wizard APIs
['GET', '/api/sa/registration/disciplines', 'SportsActivity\Controllers\RegistrationWizardController@getDisciplines', ['auth'], 'sa.registration.manage'],
['GET', '/api/sa/registration/programs/{id:\d+}', 'SportsActivity\Controllers\RegistrationWizardController@getPrograms', ['auth'], 'sa.registration.manage'],
// ─── SA Player Cards ──────────────────────────────────────────────────────── // ─── SA Player Cards ────────────────────────────────────────────────────────
['GET', '/sa/cards/report', 'SportsActivity\Controllers\SaCardController@report', ['auth'], 'sa.card.view'], ['GET', '/sa/cards/report', 'SportsActivity\Controllers\SaCardController@report', ['auth'], 'sa.card.view'],
['GET', '/sa/cards', 'SportsActivity\Controllers\SaCardController@index', ['auth'], 'sa.card.view'], ['GET', '/sa/cards', 'SportsActivity\Controllers\SaCardController@index', ['auth'], 'sa.card.view'],
...@@ -230,6 +237,18 @@ return [ ...@@ -230,6 +237,18 @@ return [
['GET', '/sa/gate/log', 'SportsActivity\Controllers\GateController@log', ['auth'], 'sa.gate.view'], ['GET', '/sa/gate/log', 'SportsActivity\Controllers\GateController@log', ['auth'], 'sa.gate.view'],
['GET', '/sa/gate/report', 'SportsActivity\Controllers\GateController@report', ['auth'], 'sa.gate.view'], ['GET', '/sa/gate/report', 'SportsActivity\Controllers\GateController@report', ['auth'], 'sa.gate.view'],
// Booking Passes
['GET', '/sa/bookings/{id:\d+}/passes', 'SportsActivity\Controllers\BookingPassController@passes', ['auth'], 'sa.booking.view'],
['POST', '/sa/booking-passes/use', 'SportsActivity\Controllers\BookingPassController@usePass', ['auth', 'csrf'], 'sa.booking.manage'],
['GET', '/api/sa/booking-passes/validate', 'SportsActivity\Controllers\BookingPassController@validatePass',['auth'], 'sa.booking.view'],
// Pool Tickets
['GET', '/sa/pool-tickets', 'SportsActivity\Controllers\PoolTicketController@index', ['auth'], 'sa.pool_ticket.view'],
['POST', '/sa/pool-tickets/issue', 'SportsActivity\Controllers\PoolTicketController@issue', ['auth', 'csrf'], 'sa.pool_ticket.issue'],
['POST', '/sa/pool-tickets/{id:\d+}/cancel', 'SportsActivity\Controllers\PoolTicketController@cancel', ['auth', 'csrf'], 'sa.pool_ticket.manage'],
['POST', '/sa/pool-tickets/{id:\d+}/entry', 'SportsActivity\Controllers\PoolTicketController@entry', ['auth', 'csrf'], 'sa.pool_ticket.manage'],
['POST', '/sa/pool-tickets/{id:\d+}/exit', 'SportsActivity\Controllers\PoolTicketController@exit', ['auth', 'csrf'], 'sa.pool_ticket.manage'],
// ─── Activity Browser ─────────────────────────────────────────────────────── // ─── Activity Browser ───────────────────────────────────────────────────────
['GET', '/sa/activities', 'SportsActivity\Controllers\DisciplineController@activityBrowser', ['auth'], 'sa.discipline.view'], ['GET', '/sa/activities', 'SportsActivity\Controllers\DisciplineController@activityBrowser', ['auth'], 'sa.discipline.view'],
......
...@@ -35,6 +35,28 @@ final class SaConstants ...@@ -35,6 +35,28 @@ final class SaConstants
// Booker types // Booker types
const BOOKER_MEMBER = 'member'; const BOOKER_MEMBER = 'member';
const BOOKER_GUEST = 'guest'; const BOOKER_GUEST = 'guest';
const BOOKER_ORGANIZATION = 'organization';
// Participant modes
const PARTICIPANT_MODE_FULL = 'full';
const PARTICIPANT_MODE_PARTIAL = 'partial';
const PARTICIPANT_MODE_PASSES = 'passes';
// Booking pass statuses
const PASS_ISSUED = 'issued';
const PASS_USED = 'used';
const PASS_EXPIRED = 'expired';
const PASS_CANCELLED = 'cancelled';
// Pool ticket statuses
const TICKET_ISSUED = 'issued';
const TICKET_ACTIVE = 'active';
const TICKET_COMPLETED = 'completed';
const TICKET_CANCELLED = 'cancelled';
// Pool access modes
const ACCESS_RESERVED = 'reserved';
const ACCESS_OPEN = 'open_access';
// Medical statuses // Medical statuses
const MEDICAL_FIT = 'fit'; const MEDICAL_FIT = 'fit';
......
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Modules\SportsActivity\SaConstants;
final class BookingPassService
{
public static function generatePasses(int $bookingId, int $count): array
{
$db = App::getInstance()->db();
$now = date('Y-m-d H:i:s');
$passes = [];
$booking = $db->selectOne("SELECT booking_number FROM sa_bookings WHERE id = ?", [$bookingId]);
$prefix = $booking ? $booking['booking_number'] : ('BK-' . str_pad((string) $bookingId, 5, '0', STR_PAD_LEFT));
for ($i = 1; $i <= $count; $i++) {
$passNumber = $prefix . '-P' . str_pad((string) $i, 2, '0', STR_PAD_LEFT);
$passId = $db->insert('sa_booking_passes', [
'booking_id' => $bookingId,
'pass_number' => $passNumber,
'status' => SaConstants::PASS_ISSUED,
'created_at' => $now,
'updated_at' => $now,
]);
$passes[] = ['id' => $passId, 'pass_number' => $passNumber];
}
$db->update('sa_bookings', [
'passes_total' => $count,
'updated_at' => $now,
], 'id = ?', [$bookingId]);
return ['success' => true, 'passes' => $passes, 'count' => $count];
}
public static function usePass(string $passNumber, ?string $usedByName, int $employeeId): array
{
$db = App::getInstance()->db();
$pass = $db->selectOne(
"SELECT bp.*, b.booking_date, b.start_time, b.end_time, b.status as booking_status
FROM sa_booking_passes bp
INNER JOIN sa_bookings b ON b.id = bp.booking_id
WHERE bp.pass_number = ?",
[$passNumber]
);
if (!$pass) {
return ['success' => false, 'error' => 'رقم الباس غير موجود'];
}
if ($pass['status'] !== SaConstants::PASS_ISSUED) {
return ['success' => false, 'error' => 'هذا الباس ' . ($pass['status'] === 'used' ? 'مستخدم بالفعل' : 'غير صالح')];
}
if ($pass['booking_status'] === 'cancelled') {
return ['success' => false, 'error' => 'الحجز ملغي'];
}
$now = date('Y-m-d H:i:s');
$db->update('sa_booking_passes', [
'status' => SaConstants::PASS_USED,
'used_at' => $now,
'used_by_name' => $usedByName,
'gate_employee_id' => $employeeId,
'updated_at' => $now,
], 'id = ?', [(int) $pass['id']]);
$db->query(
"UPDATE sa_bookings SET passes_used = passes_used + 1, updated_at = ? WHERE id = ?",
[$now, (int) $pass['booking_id']]
);
return ['success' => true, 'pass_number' => $passNumber, 'booking_id' => (int) $pass['booking_id']];
}
public static function validatePass(string $passNumber): array
{
$db = App::getInstance()->db();
$pass = $db->selectOne(
"SELECT bp.*, b.booking_date, b.start_time, b.end_time, b.booker_name,
b.organization_name, b.status as booking_status
FROM sa_booking_passes bp
INNER JOIN sa_bookings b ON b.id = bp.booking_id
WHERE bp.pass_number = ?",
[$passNumber]
);
if (!$pass) {
return ['valid' => false, 'error' => 'رقم الباس غير موجود'];
}
if ($pass['status'] !== SaConstants::PASS_ISSUED) {
return ['valid' => false, 'error' => 'الباس ' . $pass['status'], 'pass' => $pass];
}
if ($pass['booking_status'] === 'cancelled') {
return ['valid' => false, 'error' => 'الحجز ملغي', 'pass' => $pass];
}
return ['valid' => true, 'pass' => $pass];
}
public static function getBookingPasses(int $bookingId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM sa_booking_passes WHERE booking_id = ? ORDER BY pass_number",
[$bookingId]
);
}
public static function expireBookingPasses(int $bookingId): int
{
$db = App::getInstance()->db();
$now = date('Y-m-d H:i:s');
$stmt = $db->query(
"UPDATE sa_booking_passes SET status = ?, updated_at = ? WHERE booking_id = ? AND status = ?",
[SaConstants::PASS_EXPIRED, $now, $bookingId, SaConstants::PASS_ISSUED]
);
return $stmt->rowCount();
}
}
...@@ -17,19 +17,25 @@ final class BookingService ...@@ -17,19 +17,25 @@ final class BookingService
$date = $data['booking_date'] ?? ''; $date = $data['booking_date'] ?? '';
$startTime = $data['start_time'] ?? ''; $startTime = $data['start_time'] ?? '';
$endTime = $data['end_time'] ?? ''; $endTime = $data['end_time'] ?? '';
$participantCount = (int) ($data['participant_count'] ?? 1);
$spotsReserved = (int) ($data['spots_reserved'] ?? $participantCount);
$bookerType = $data['booker_type'] ?? SaConstants::BOOKER_GUEST; $bookerType = $data['booker_type'] ?? SaConstants::BOOKER_GUEST;
$bookerId = isset($data['booker_id']) ? (int) $data['booker_id'] : null; $bookerId = isset($data['booker_id']) ? (int) $data['booker_id'] : null;
$bookerName = $data['booker_name'] ?? ''; $bookerName = $data['booker_name'] ?? '';
$organizationName = $data['organization_name'] ?? null;
$organizationContact = $data['organization_contact'] ?? null;
$participantMode = $data['participant_mode'] ?? SaConstants::PARTICIPANT_MODE_PASSES;
$isMember = ($bookerType === SaConstants::BOOKER_MEMBER); $isMember = ($bookerType === SaConstants::BOOKER_MEMBER);
$unit = $db->selectOne("SELECT expected_capacity FROM sa_facility_units WHERE id = ?", [$unitId]);
$expectedCapacity = $unit ? (int) $unit['expected_capacity'] : 1;
$participantCount = (int) ($data['participant_count'] ?? $expectedCapacity);
$spotsReserved = (int) ($data['spots_reserved'] ?? $participantCount);
$availability = SlotAvailabilityService::check($unitId, $date, $startTime, $endTime, $spotsReserved); $availability = SlotAvailabilityService::check($unitId, $date, $startTime, $endTime, $spotsReserved);
if (!$availability['available']) { if (!$availability['available']) {
return ['success' => false, 'error' => $availability['reason']]; return ['success' => false, 'error' => $availability['reason']];
} }
$pricing = PricingCalculatorService::calculate($unitId, $date, $startTime, $endTime, $participantCount, $isMember); $pricing = PricingCalculatorService::calculate($unitId, $date, $startTime, $endTime, $participantCount, $bookerType);
$totalAmount = $pricing ? $pricing['total_amount'] : 0.00; $totalAmount = $pricing ? $pricing['total_amount'] : 0.00;
$bookingNumber = NumberGeneratorService::bookingNumber(); $bookingNumber = NumberGeneratorService::bookingNumber();
...@@ -38,25 +44,30 @@ final class BookingService ...@@ -38,25 +44,30 @@ final class BookingService
$db->beginTransaction(); $db->beginTransaction();
try { try {
$bookingData = [ $bookingData = [
'booking_number' => $bookingNumber, 'booking_number' => $bookingNumber,
'facility_unit_id' => $unitId, 'facility_unit_id' => $unitId,
'booking_type' => SaConstants::BOOKING_TYPE_HOURLY, 'booking_type' => SaConstants::BOOKING_TYPE_HOURLY,
'booking_date' => $date, 'booking_date' => $date,
'start_time' => $startTime, 'start_time' => $startTime,
'end_time' => $endTime, 'end_time' => $endTime,
'booker_type' => $bookerType, 'booker_type' => $bookerType,
'booker_id' => $bookerId, 'booker_id' => $bookerId,
'booker_name' => $bookerName, 'booker_name' => $bookerName,
'participant_count' => $participantCount, 'organization_name' => $organizationName,
'spots_reserved' => $spotsReserved, 'organization_contact' => $organizationContact,
'total_amount' => $totalAmount, 'participant_count' => $participantCount,
'payment_status' => SaConstants::PAYMENT_UNPAID, 'participant_mode' => $participantMode,
'status' => SaConstants::BOOKING_CONFIRMED, 'passes_total' => ($participantMode === SaConstants::PARTICIPANT_MODE_PASSES) ? $participantCount : 0,
'notes' => $data['notes'] ?? null, 'passes_used' => 0,
'branch_id' => $data['branch_id'] ?? null, 'spots_reserved' => $spotsReserved,
'created_by' => $employeeId, 'total_amount' => $totalAmount,
'created_at' => date('Y-m-d H:i:s'), 'payment_status' => SaConstants::PAYMENT_UNPAID,
'updated_at' => date('Y-m-d H:i:s'), 'status' => SaConstants::BOOKING_CONFIRMED,
'notes' => $data['notes'] ?? null,
'branch_id' => $data['branch_id'] ?? null,
'created_by' => $employeeId,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]; ];
$db->insert('sa_bookings', $bookingData); $db->insert('sa_bookings', $bookingData);
...@@ -75,6 +86,10 @@ final class BookingService ...@@ -75,6 +86,10 @@ final class BookingService
} }
} }
if ($participantMode === SaConstants::PARTICIPANT_MODE_PASSES && $participantCount > 0) {
BookingPassService::generatePasses($bookingId, $participantCount);
}
$db->commit(); $db->commit();
} catch (\Throwable $e) { } catch (\Throwable $e) {
$db->rollBack(); $db->rollBack();
......
...@@ -215,6 +215,188 @@ final class EnrollmentService ...@@ -215,6 +215,188 @@ final class EnrollmentService
} }
} }
public static function forceEnroll(int $groupId, int $playerId): array
{
$db = App::getInstance()->db();
$player = $db->selectOne(
"SELECT * FROM sa_players WHERE id = ? AND is_archived = 0",
[$playerId]
);
if (!$player) {
return ['success' => false, 'error' => 'اللاعب غير مسجل في النظام'];
}
$group = $db->selectOne("SELECT * FROM sa_groups WHERE id = ? AND is_archived = 0", [$groupId]);
if (!$group) {
return ['success' => false, 'error' => 'المجموعة غير موجودة'];
}
if ($group['status'] !== SaConstants::GROUP_ACTIVE) {
return ['success' => false, 'error' => 'المجموعة غير نشطة'];
}
$existing = $db->selectOne(
"SELECT id FROM sa_group_players WHERE group_id = ? AND player_id = ? AND status IN ('active','pending_payment')",
[$groupId, $playerId]
);
if ($existing) {
return ['success' => false, 'error' => 'اللاعب مسجل بالفعل في هذه المجموعة'];
}
$fee = $player['player_type'] === SaConstants::PLAYER_MEMBER
? (string) $group['monthly_fee_member']
: (string) $group['monthly_fee_nonmember'];
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$db->beginTransaction();
try {
$enrollmentId = $db->insert('sa_group_players', [
'group_id' => $groupId,
'player_id' => $playerId,
'enrolled_at' => date('Y-m-d'),
'status' => SaConstants::STATUS_PENDING_PAYMENT,
'force_enrolled' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employeeId,
]);
$memberId = !empty($player['member_id']) ? (int) $player['member_id'] : 0;
$description = 'تسجيل رياضي (تجاوز السعة) — ' . $group['name_ar'] . ' — ' . $player['full_name_ar'];
$requestResult = PaymentRequestService::createRequest([
'member_id' => $memberId,
'payment_type' => SaConstants::PAY_TYPE_SPORTS_REG,
'amount' => $fee,
'description_ar' => $description,
'related_entity_type' => SaConstants::ENTITY_GROUP_PLAYERS,
'related_entity_id' => $enrollmentId,
]);
if ($requestResult['success']) {
$db->update('sa_group_players', [
'payment_request_id' => (int) $requestResult['request_id'],
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$enrollmentId]);
}
$newCount = (int) $group['current_count'] + 1;
$db->update('sa_groups', [
'current_count' => $newCount,
'is_full' => 1,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$groupId]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل عملية التسجيل: ' . $e->getMessage()];
}
return [
'success' => true,
'enrollment_id' => $enrollmentId,
'request_id' => $requestResult['request_id'] ?? null,
'request_number' => $requestResult['request_number'] ?? null,
'fee' => $fee,
'force_enrolled' => true,
];
}
public static function findOrCreateAvailableGroup(int $programId): array
{
$db = App::getInstance()->db();
$program = $db->selectOne(
"SELECT * FROM sa_programs WHERE id = ? AND is_archived = 0",
[$programId]
);
if (!$program) {
return ['success' => false, 'error' => 'البرنامج غير موجود'];
}
$availableGroup = $db->selectOne(
"SELECT * FROM sa_groups WHERE program_id = ? AND status = 'active' AND is_archived = 0 AND is_full = 0
ORDER BY id DESC LIMIT 1",
[$programId]
);
if ($availableGroup) {
return ['success' => true, 'group' => $availableGroup];
}
$sourceGroup = $db->selectOne(
"SELECT * FROM sa_groups WHERE program_id = ? AND is_archived = 0
ORDER BY id DESC LIMIT 1",
[$programId]
);
if (!$sourceGroup) {
return ['success' => false, 'error' => 'لا توجد مجموعات مرجعية للبرنامج'];
}
$newName = self::incrementGroupName($sourceGroup['name_ar']);
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$db->beginTransaction();
try {
$newGroupId = $db->insert('sa_groups', [
'name_ar' => $newName,
'program_id' => $programId,
'facility_unit_id' => $sourceGroup['facility_unit_id'],
'coach_id' => $sourceGroup['coach_id'],
'monthly_fee_member' => $sourceGroup['monthly_fee_member'],
'monthly_fee_nonmember' => $sourceGroup['monthly_fee_nonmember'],
'min_age' => $sourceGroup['min_age'],
'max_age' => $sourceGroup['max_age'],
'max_capacity' => $sourceGroup['max_capacity'],
'current_count' => 0,
'is_full' => 0,
'status' => 'active',
'branch_id' => $sourceGroup['branch_id'] ?? null,
'created_by' => $employeeId,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
$schedules = $db->select(
"SELECT * FROM sa_group_schedule WHERE group_id = ? AND is_active = 1",
[(int) $sourceGroup['id']]
);
foreach ($schedules as $sch) {
$db->insert('sa_group_schedule', [
'group_id' => $newGroupId,
'facility_unit_id' => $sch['facility_unit_id'],
'day_of_week' => $sch['day_of_week'],
'start_time' => $sch['start_time'],
'end_time' => $sch['end_time'],
'is_active' => 1,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
$db->commit();
$newGroup = $db->selectOne("SELECT * FROM sa_groups WHERE id = ?", [$newGroupId]);
return ['success' => true, 'group' => $newGroup, 'auto_created' => true];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل إنشاء مجموعة جديدة: ' . $e->getMessage()];
}
}
private static function incrementGroupName(string $name): string
{
if (preg_match('/^(.+)\s(\d+)$/', $name, $matches)) {
return $matches[1] . ' ' . ((int) $matches[2] + 1);
}
return $name . ' 2';
}
public static function deactivateEnrollment(int $enrollmentId): bool public static function deactivateEnrollment(int $enrollmentId): bool
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
......
...@@ -9,7 +9,7 @@ use App\Modules\SportsActivity\SaConstants; ...@@ -9,7 +9,7 @@ use App\Modules\SportsActivity\SaConstants;
final class GroupTransferService final class GroupTransferService
{ {
public static function transfer(int $playerId, int $fromGroupId, int $toGroupId, string $reason = ''): array public static function transfer(int $playerId, int $fromGroupId, int $toGroupId, string $reason = '', bool $force = false): array
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
...@@ -37,7 +37,7 @@ final class GroupTransferService ...@@ -37,7 +37,7 @@ final class GroupTransferService
return ['success' => false, 'error' => 'المجموعة المستهدفة غير موجودة أو غير نشطة']; return ['success' => false, 'error' => 'المجموعة المستهدفة غير موجودة أو غير نشطة'];
} }
if ((int) $toGroup['current_count'] >= (int) $toGroup['max_capacity']) { if (!$force && (int) $toGroup['current_count'] >= (int) $toGroup['max_capacity']) {
$db->rollBack(); $db->rollBack();
return ['success' => false, 'error' => 'المجموعة المستهدفة ممتلئة']; return ['success' => false, 'error' => 'المجموعة المستهدفة ممتلئة'];
} }
......
...@@ -27,7 +27,9 @@ final class MirrorStateService ...@@ -27,7 +27,9 @@ final class MirrorStateService
$bookings = $db->select( $bookings = $db->select(
"SELECT b.*, fu.name_ar as unit_name, fu.booking_mode, fu.max_capacity, "SELECT b.*, fu.name_ar as unit_name, fu.booking_mode, fu.max_capacity,
c.full_name_ar as coach_name, g.name_ar as group_name c.full_name_ar as coach_name, g.name_ar as group_name,
b.booker_type, b.organization_name, b.passes_total, b.passes_used,
b.participant_mode
FROM sa_bookings b FROM sa_bookings b
JOIN sa_facility_units fu ON fu.id = b.facility_unit_id JOIN sa_facility_units fu ON fu.id = b.facility_unit_id
LEFT JOIN sa_coaches c ON c.id = b.coach_id LEFT JOIN sa_coaches c ON c.id = b.coach_id
...@@ -179,6 +181,43 @@ final class MirrorStateService ...@@ -179,6 +181,43 @@ final class MirrorStateService
]; ];
} }
public static function getMultiFacilityState(int $disciplineId, string $date): array
{
$db = App::getInstance()->db();
$discipline = $db->selectOne(
"SELECT * FROM sa_disciplines WHERE id = ? AND is_active = 1 AND is_archived = 0",
[$disciplineId]
);
if (!$discipline) {
return ['error' => 'اللعبة غير موجودة'];
}
$facilities = $db->select(
"SELECT * FROM sa_facilities WHERE discipline_id = ? AND is_active = 1 AND is_archived = 0 ORDER BY name_ar",
[$disciplineId]
);
if (empty($facilities)) {
return ['error' => 'لا توجد مرافق مرتبطة بهذه اللعبة'];
}
$facilityStates = [];
foreach ($facilities as $facility) {
$state = self::getFacilityState((int) $facility['id'], $date);
if (!isset($state['error'])) {
$facilityStates[] = $state;
}
}
return [
'discipline' => $discipline,
'facilities' => $facilityStates,
'date' => $date,
];
}
private static function buildTimeSlots(string $start, string $end, int $slotMinutes): array private static function buildTimeSlots(string $start, string $end, int $slotMinutes): array
{ {
$slots = []; $slots = [];
......
...@@ -113,7 +113,7 @@ final class PoolGridService ...@@ -113,7 +113,7 @@ final class PoolGridService
$ts = date('Y-m-d H:i:s'); $ts = date('Y-m-d H:i:s');
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0); $employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$validActions = ['training', 'blocked', 'maintenance', 'hourly']; $validActions = ['training', 'blocked', 'maintenance', 'hourly', 'open_access'];
if (!in_array($action, $validActions, true)) { if (!in_array($action, $validActions, true)) {
return ['success' => false, 'error' => 'إجراء غير صالح']; return ['success' => false, 'error' => 'إجراء غير صالح'];
} }
...@@ -146,6 +146,8 @@ final class PoolGridService ...@@ -146,6 +146,8 @@ final class PoolGridService
$label = 'صيانة'; $label = 'صيانة';
} elseif ($action === 'hourly') { } elseif ($action === 'hourly') {
$label = $notes ?: 'حجز ساعة'; $label = $notes ?: 'حجز ساعة';
} elseif ($action === 'open_access') {
$label = 'دخول حر';
} }
// Pre-fetch all active pool zone bookings for this facility+date // Pre-fetch all active pool zone bookings for this facility+date
...@@ -220,7 +222,8 @@ final class PoolGridService ...@@ -220,7 +222,8 @@ final class PoolGridService
'end_time' => $endTimeFull, 'end_time' => $endTimeFull,
'zone_row' => $row, 'zone_row' => $row,
'zone_col' => $col, 'zone_col' => $col,
'assignment_type' => $action, 'assignment_type' => ($action === 'open_access') ? 'hourly' : $action,
'access_mode' => ($action === 'open_access') ? 'open_access' : 'reserved',
'group_id' => $groupId, 'group_id' => $groupId,
'coach_id' => $coachId, 'coach_id' => $coachId,
'label' => $label, 'label' => $label,
......
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Modules\Cashier\Services\PaymentRequestService;
use App\Modules\SportsActivity\SaConstants;
final class PoolTicketService
{
public static function issueTicket(array $data): array
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$branch = App::getInstance()->currentBranch();
$facilityId = (int) ($data['facility_id'] ?? 0);
$date = $data['booking_date'] ?? date('Y-m-d');
$startTime = $data['start_time'] ?? '';
$endTime = $data['end_time'] ?? '';
$isMember = (bool) ($data['is_member'] ?? false);
$memberId = isset($data['member_id']) ? (int) $data['member_id'] : null;
$playerId = isset($data['player_id']) ? (int) $data['player_id'] : null;
$guestName = $data['guest_name'] ?? null;
$guestNationalId = $data['guest_national_id'] ?? null;
$zone = $db->selectOne(
"SELECT * FROM sa_pool_zone_bookings
WHERE facility_id = ? AND booking_date = ? AND start_time <= ? AND end_time > ?
AND access_mode = 'open_access' AND status = 'active'
ORDER BY current_occupancy ASC LIMIT 1",
[$facilityId, $date, $startTime . ':00', $startTime . ':00']
);
if (!$zone) {
return ['success' => false, 'error' => 'لا توجد منطقة دخول حر متاحة في هذا الوقت'];
}
if (!empty($zone['access_restriction']) && $zone['access_restriction'] === 'members_only' && !$isMember) {
return ['success' => false, 'error' => 'هذه المنطقة متاحة للأعضاء فقط'];
}
if (!empty($zone['max_occupancy']) && (int) $zone['current_occupancy'] >= (int) $zone['max_occupancy']) {
return ['success' => false, 'error' => 'المنطقة وصلت للحد الأقصى من الإشغال'];
}
$price = $isMember
? (float) ($zone['ticket_price_member'] ?? 0)
: (float) ($zone['ticket_price_nonmember'] ?? 0);
$now = date('Y-m-d H:i:s');
$employeeId = $employee ? (int) $employee->id : null;
$db->beginTransaction();
try {
$ticketId = $db->insert('sa_pool_tickets', [
'facility_id' => $facilityId,
'zone_booking_id' => (int) $zone['id'],
'booking_date' => $date,
'start_time' => $zone['start_time'],
'end_time' => $zone['end_time'],
'member_id' => $memberId,
'player_id' => $playerId,
'guest_name' => $guestName,
'guest_national_id' => $guestNationalId,
'is_member' => $isMember ? 1 : 0,
'amount_paid' => $price,
'payment_status' => 'unpaid',
'status' => SaConstants::TICKET_ISSUED,
'issued_by' => $employeeId,
'branch_id' => $branch ? (int) $branch['id'] : null,
'created_at' => $now,
'updated_at' => $now,
]);
$db->query(
"UPDATE sa_pool_zone_bookings SET current_occupancy = current_occupancy + 1, updated_at = ? WHERE id = ?",
[$now, (int) $zone['id']]
);
if ($price > 0) {
$description = 'تذكرة دخول حمام السباحة — ' . ($guestName ?: 'عضو');
$payResult = PaymentRequestService::createRequest([
'member_id' => $memberId ?? 0,
'payment_type' => 'sa_pool_ticket',
'amount' => (string) $price,
'description_ar' => $description,
'related_entity_type' => 'sa_pool_tickets',
'related_entity_id' => $ticketId,
]);
if ($payResult['success']) {
$db->update('sa_pool_tickets', [
'payment_request_id' => (int) $payResult['request_id'],
'updated_at' => $now,
], 'id = ?', [$ticketId]);
}
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل إصدار التذكرة: ' . $e->getMessage()];
}
return [
'success' => true,
'ticket_id' => $ticketId,
'price' => $price,
'zone_id' => (int) $zone['id'],
];
}
public static function cancelTicket(int $ticketId, string $reason = ''): array
{
$db = App::getInstance()->db();
$ticket = $db->selectOne("SELECT * FROM sa_pool_tickets WHERE id = ?", [$ticketId]);
if (!$ticket) {
return ['success' => false, 'error' => 'التذكرة غير موجودة'];
}
if ($ticket['status'] === 'cancelled') {
return ['success' => false, 'error' => 'التذكرة ملغاة بالفعل'];
}
$now = date('Y-m-d H:i:s');
$db->update('sa_pool_tickets', [
'status' => SaConstants::TICKET_CANCELLED,
'updated_at' => $now,
], 'id = ?', [$ticketId]);
if (!empty($ticket['zone_booking_id'])) {
$db->query(
"UPDATE sa_pool_zone_bookings SET current_occupancy = GREATEST(0, current_occupancy - 1), updated_at = ? WHERE id = ?",
[$now, (int) $ticket['zone_booking_id']]
);
}
return ['success' => true];
}
public static function recordEntry(int $ticketId): array
{
$db = App::getInstance()->db();
$now = date('Y-m-d H:i:s');
$db->update('sa_pool_tickets', [
'status' => SaConstants::TICKET_ACTIVE,
'entry_time' => $now,
'updated_at' => $now,
], 'id = ?', [$ticketId]);
return ['success' => true];
}
public static function recordExit(int $ticketId): array
{
$db = App::getInstance()->db();
$now = date('Y-m-d H:i:s');
$ticket = $db->selectOne("SELECT * FROM sa_pool_tickets WHERE id = ?", [$ticketId]);
if (!$ticket) {
return ['success' => false, 'error' => 'التذكرة غير موجودة'];
}
$db->update('sa_pool_tickets', [
'status' => SaConstants::TICKET_COMPLETED,
'exit_time' => $now,
'updated_at' => $now,
], 'id = ?', [$ticketId]);
if (!empty($ticket['zone_booking_id'])) {
$db->query(
"UPDATE sa_pool_zone_bookings SET current_occupancy = GREATEST(0, current_occupancy - 1), updated_at = ? WHERE id = ?",
[$now, (int) $ticket['zone_booking_id']]
);
}
return ['success' => true];
}
public static function getActiveTickets(int $facilityId, string $date): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT * FROM sa_pool_tickets
WHERE facility_id = ? AND booking_date = ? AND status IN ('issued','active')
ORDER BY created_at DESC",
[$facilityId, $date]
);
}
}
...@@ -13,8 +13,10 @@ final class PricingCalculatorService ...@@ -13,8 +13,10 @@ final class PricingCalculatorService
string $startTime, string $startTime,
string $endTime, string $endTime,
int $participantCount, int $participantCount,
bool $isMember bool|string $isMemberOrBookerType
): ?array { ): ?array {
$bookerType = is_string($isMemberOrBookerType) ? $isMemberOrBookerType : ($isMemberOrBookerType ? 'member' : 'guest');
$isMember = ($bookerType === 'member');
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$unit = $db->selectOne( $unit = $db->selectOne(
...@@ -58,9 +60,13 @@ final class PricingCalculatorService ...@@ -58,9 +60,13 @@ final class PricingCalculatorService
return null; return null;
} }
$pricePerPerson = $isMember if ($bookerType === 'organization' && !empty($rule['price_per_person_organization'])) {
? (float) $rule['price_per_person_member'] $pricePerPerson = (float) $rule['price_per_person_organization'];
: (float) $rule['price_per_person_nonmember']; } else {
$pricePerPerson = $isMember
? (float) $rule['price_per_person_member']
: (float) $rule['price_per_person_nonmember'];
}
$durationSeconds = strtotime($endTime) - strtotime($startTime); $durationSeconds = strtotime($endTime) - strtotime($startTime);
$durationHours = $durationSeconds / 3600.0; $durationHours = $durationSeconds / 3600.0;
...@@ -75,6 +81,7 @@ final class PricingCalculatorService ...@@ -75,6 +81,7 @@ final class PricingCalculatorService
'bracket_name' => $bracket['name_ar'], 'bracket_name' => $bracket['name_ar'],
'bracket_type' => $bracket['bracket_type'], 'bracket_type' => $bracket['bracket_type'],
'is_member' => $isMember, 'is_member' => $isMember,
'booker_type' => $bookerType,
'rule_id' => (int) $rule['id'], 'rule_id' => (int) $rule['id'],
]; ];
} }
......
...@@ -243,6 +243,29 @@ final class RegistrationWizardService ...@@ -243,6 +243,29 @@ final class RegistrationWizardService
]; ];
} }
public static function selectProgram(int $registrationId, int $programId, int $months = 1, bool $hasSibling = false): array
{
$db = App::getInstance()->db();
$registration = $db->selectOne(
"SELECT * FROM sa_registrations WHERE id = ? AND status = 'in_progress'",
[$registrationId]
);
if (!$registration) {
return ['success' => false, 'error' => 'التسجيل غير موجود أو مكتمل'];
}
$groupResult = EnrollmentService::findOrCreateAvailableGroup($programId);
if (!$groupResult['success']) {
return $groupResult;
}
$group = $groupResult['group'];
$groupId = (int) $group['id'];
return self::selectGroup($registrationId, $groupId, $months, $hasSibling);
}
public static function calculateFees(string $playerType): array public static function calculateFees(string $playerType): array
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>باسات الحجز #<?= e($booking['booking_number'] ?? '') ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sa/bookings/<?= (int) $booking['id'] ?>" class="btn btn-outline"><i data-lucide="arrow-right" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> تفاصيل الحجز</a>
<button onclick="window.print()" class="btn btn-primary"><i data-lucide="printer" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> طباعة</button>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Booking Info -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<div style="display:grid;grid-template-columns:repeat(auto-fit, minmax(200px, 1fr));gap:10px;font-size:13px;">
<div><strong>الحاجز:</strong> <?= e($booking['booker_name'] ?? '') ?></div>
<div><strong>المرفق:</strong> <?= e($booking['facility_name'] ?? '') ?> - <?= e($booking['unit_name'] ?? '') ?></div>
<div><strong>التاريخ:</strong> <?= e($booking['booking_date'] ?? '') ?></div>
<div><strong>الوقت:</strong> <?= e($booking['start_time'] ?? '') ?> - <?= e($booking['end_time'] ?? '') ?></div>
<div><strong>نظام المشاركين:</strong>
<?php
$modeLabels = ['full' => 'تسجيل كامل', 'partial' => 'أسماء فقط', 'passes' => 'باسات'];
echo e($modeLabels[$booking['participant_mode'] ?? 'passes'] ?? 'باسات');
?>
</div>
<div><strong>الاستخدام:</strong> <?= (int) ($booking['passes_used'] ?? 0) ?> / <?= (int) ($booking['passes_total'] ?? 0) ?></div>
</div>
</div>
<!-- Passes List -->
<div class="card">
<div style="padding:12px 15px;border-bottom:1px solid #E5E7EB;display:flex;justify-content:space-between;align-items:center;">
<span style="font-weight:700;">الباسات (<?= count($passes) ?>)</span>
</div>
<table style="width:100%;border-collapse:collapse;font-size:13px;">
<thead>
<tr style="background:#F9FAFB;">
<th style="padding:10px;border-bottom:1px solid #E5E7EB;text-align:right;">#</th>
<th style="padding:10px;border-bottom:1px solid #E5E7EB;text-align:right;">رقم الباس</th>
<th style="padding:10px;border-bottom:1px solid #E5E7EB;text-align:center;">الحالة</th>
<th style="padding:10px;border-bottom:1px solid #E5E7EB;text-align:right;">تم الاستخدام</th>
<th style="padding:10px;border-bottom:1px solid #E5E7EB;text-align:right;">بواسطة</th>
</tr>
</thead>
<tbody>
<?php foreach ($passes as $i => $pass): ?>
<?php
$statusColors = ['issued' => '#ECFDF5', 'used' => '#FEF3C7', 'expired' => '#FEE2E2', 'cancelled' => '#F3F4F6'];
$statusLabels = ['issued' => 'متاح', 'used' => 'مستخدم', 'expired' => 'منتهي', 'cancelled' => 'ملغي'];
$statusColor = $statusColors[$pass['status']] ?? '#F3F4F6';
$statusLabel = $statusLabels[$pass['status']] ?? $pass['status'];
?>
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:10px;"><?= $i + 1 ?></td>
<td style="padding:10px;font-family:monospace;font-weight:600;"><?= e($pass['pass_number']) ?></td>
<td style="padding:10px;text-align:center;">
<span style="background:<?= $statusColor ?>;padding:2px 8px;border-radius:8px;font-size:11px;font-weight:600;"><?= e($statusLabel) ?></span>
</td>
<td style="padding:10px;font-size:12px;color:#6B7280;"><?= e($pass['used_at'] ?? '-') ?></td>
<td style="padding:10px;font-size:12px;"><?= e($pass['used_by_name'] ?? '-') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') { lucide.createIcons(); }
});
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>مرآة: <?= e($discipline['name_ar'] ?? '') ?> - جميع المرافق<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sa/mirror" class="btn btn-outline"><i data-lucide="arrow-right" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> كل المرافق</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<meta http-equiv="refresh" content="30">
<!-- Date Picker -->
<div class="card" style="margin-bottom:20px;padding:15px;display:flex;align-items:center;gap:15px;">
<label style="font-weight:600;font-size:14px;">التاريخ:</label>
<input type="date" id="mirror-date" value="<?= e($date) ?>" class="form-input" style="width:180px;"
onchange="window.location.href='/sa/mirror/discipline/<?= (int) $discipline['id'] ?>/' + this.value;">
<?php if ($date === date('Y-m-d')): ?>
<span style="background:#ECFDF5;color:#059669;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;">اليوم</span>
<?php endif; ?>
<span style="font-size:13px;font-weight:600;color:#2563EB;"><?= e($discipline['name_ar'] ?? '') ?></span>
<span style="font-size:11px;color:#9CA3AF;margin-right:auto;">يتم التحديث تلقائيا كل 30 ثانية</span>
</div>
<!-- Legend -->
<div class="card" style="margin-bottom:20px;padding:12px;display:flex;gap:15px;flex-wrap:wrap;font-size:12px;">
<span><span style="display:inline-block;width:14px;height:14px;background:#D1FAE5;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> متاح</span>
<span><span style="display:inline-block;width:14px;height:14px;background:#FEF3C7;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> جزئي</span>
<span><span style="display:inline-block;width:14px;height:14px;background:#FEE2E2;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> ممتلئ</span>
<span><span style="display:inline-block;width:14px;height:14px;background:#DBEAFE;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> تدريب</span>
<span><span style="display:inline-block;width:14px;height:14px;background:#D1FAE5;border:2px solid #059669;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> حجز عضو</span>
<span><span style="display:inline-block;width:14px;height:14px;background:#FEF9C3;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> حجز زائر</span>
<span><span style="display:inline-block;width:14px;height:14px;background:#FFEDD5;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> حجز مؤسسة</span>
<span><span style="display:inline-block;width:14px;height:14px;background:#E5E7EB;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> مغلق</span>
</div>
<?php foreach ($facilityStates as $state): ?>
<div class="card" style="margin-bottom:20px;overflow-x:auto;">
<div style="padding:12px 15px;border-bottom:1px solid #E5E7EB;background:#F9FAFB;">
<span style="font-weight:700;font-size:14px;"><?= e($state['facility']['name_ar'] ?? '') ?></span>
<a href="/sa/mirror/<?= (int) $state['facility']['id'] ?>/<?= e($date) ?>" style="font-size:12px;margin-right:10px;color:#2563EB;">عرض مفصل</a>
</div>
<table style="width:100%;border-collapse:collapse;font-size:12px;min-width:800px;">
<thead>
<tr>
<th style="padding:8px;border:1px solid #E5E7EB;background:#F9FAFB;position:sticky;right:0;z-index:2;min-width:100px;">الوحدة</th>
<?php foreach ($state['slots'] as $slot): ?>
<th style="padding:6px 3px;border:1px solid #E5E7EB;background:#F9FAFB;text-align:center;white-space:nowrap;min-width:60px;font-size:11px;">
<?= e($slot['start']) ?>
</th>
<?php endforeach; ?>
</tr>
</thead>
<tbody>
<?php foreach ($state['grid'] as $unitId => $unitData): ?>
<tr>
<td style="padding:8px;border:1px solid #E5E7EB;font-weight:600;background:#FAFAFA;position:sticky;right:0;z-index:1;font-size:11px;">
<?= e($unitData['unit']['name_ar'] ?? '') ?>
</td>
<?php foreach ($unitData['slots'] as $cell): ?>
<?php
$bgColor = '#FFFFFF';
$cellText = '';
if ($cell['status'] === 'free') {
$bgColor = '#D1FAE5';
} elseif ($cell['status'] === 'partial') {
$bgColor = '#FEF3C7';
$cellText = $cell['occupied'] . '/' . $cell['max'];
} elseif ($cell['status'] === 'full') {
$bgColor = '#FEE2E2';
} elseif ($cell['status'] === 'training') {
$bgColor = '#DBEAFE';
if (!empty($cell['bookings'])) {
$firstBooking = reset($cell['bookings']);
$cellText = mb_substr($firstBooking['group_name'] ?? '', 0, 8);
}
} elseif ($cell['status'] === 'booked') {
if (!empty($cell['bookings'])) {
$firstBooking = reset($cell['bookings']);
$bookerType = $firstBooking['booker_type'] ?? 'guest';
if ($bookerType === 'organization') {
$bgColor = '#FFEDD5';
} elseif ($bookerType === 'member') {
$bgColor = '#D1FAE5';
} else {
$bgColor = '#FEF9C3';
}
$cellText = mb_substr($firstBooking['booker_name'] ?? '', 0, 8);
}
} elseif ($cell['status'] === 'blocked') {
$bgColor = '#E5E7EB';
}
?>
<td style="padding:4px 2px;border:1px solid #E5E7EB;background:<?= $bgColor ?>;text-align:center;vertical-align:middle;font-size:10px;font-weight:500;">
<?= e($cellText) ?>
</td>
<?php endforeach; ?>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php endforeach; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') { lucide.createIcons(); }
});
</script>
<?php $__template->endSection(); ?>
...@@ -25,7 +25,10 @@ ...@@ -25,7 +25,10 @@
<span><span style="display:inline-block;width:14px;height:14px;background:#FEF3C7;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> جزئي</span> <span><span style="display:inline-block;width:14px;height:14px;background:#FEF3C7;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> جزئي</span>
<span><span style="display:inline-block;width:14px;height:14px;background:#FEE2E2;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> ممتلئ</span> <span><span style="display:inline-block;width:14px;height:14px;background:#FEE2E2;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> ممتلئ</span>
<span><span style="display:inline-block;width:14px;height:14px;background:#DBEAFE;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> تدريب</span> <span><span style="display:inline-block;width:14px;height:14px;background:#DBEAFE;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> تدريب</span>
<span><span style="display:inline-block;width:14px;height:14px;background:#FFEDD5;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> محجوز</span> <span><span style="display:inline-block;width:14px;height:14px;background:#D1FAE5;border:2px solid #059669;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> حجز عضو</span>
<span><span style="display:inline-block;width:14px;height:14px;background:#FEF9C3;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> حجز زائر</span>
<span><span style="display:inline-block;width:14px;height:14px;background:#FFEDD5;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> حجز مؤسسة</span>
<span><span style="display:inline-block;width:14px;height:14px;background:#CFFAFE;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> دخول حر</span>
<span><span style="display:inline-block;width:14px;height:14px;background:#E5E7EB;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> مغلق</span> <span><span style="display:inline-block;width:14px;height:14px;background:#E5E7EB;border-radius:3px;vertical-align:middle;margin-left:4px;"></span> مغلق</span>
</div> </div>
...@@ -50,28 +53,55 @@ ...@@ -50,28 +53,55 @@
</td> </td>
<?php foreach ($unitData['slots'] as $cell): ?> <?php foreach ($unitData['slots'] as $cell): ?>
<?php <?php
$cellColors = [ $bgColor = '#FFFFFF';
'free' => '#D1FAE5',
'partial' => '#FEF3C7',
'full' => '#FEE2E2',
'training' => '#DBEAFE',
'booked' => '#FFEDD5',
'blocked' => '#E5E7EB',
];
$bgColor = $cellColors[$cell['status']] ?? '#FFFFFF';
$cellText = ''; $cellText = '';
if ($cell['status'] === 'partial') { $passIndicator = '';
if ($cell['status'] === 'free') {
$bgColor = '#D1FAE5';
} elseif ($cell['status'] === 'partial') {
$bgColor = '#FEF3C7';
$cellText = $cell['occupied'] . '/' . $cell['max'];
} elseif ($cell['status'] === 'full') {
$bgColor = '#FEE2E2';
$cellText = $cell['occupied'] . '/' . $cell['max']; $cellText = $cell['occupied'] . '/' . $cell['max'];
} elseif ($cell['status'] === 'training' && !empty($cell['bookings'])) { } elseif ($cell['status'] === 'training') {
$firstBooking = reset($cell['bookings']); $bgColor = '#DBEAFE';
$cellText = $firstBooking['group_name'] ?? ''; if (!empty($cell['bookings'])) {
} elseif ($cell['status'] === 'booked' && !empty($cell['bookings'])) { $firstBooking = reset($cell['bookings']);
$firstBooking = reset($cell['bookings']); $cellText = $firstBooking['group_name'] ?? '';
$cellText = $firstBooking['booker_name'] ?? ''; }
} elseif ($cell['status'] === 'booked') {
if (!empty($cell['bookings'])) {
$firstBooking = reset($cell['bookings']);
$bookerType = $firstBooking['booker_type'] ?? 'guest';
if ($bookerType === 'organization') {
$bgColor = '#FFEDD5';
$cellText = $firstBooking['organization_name'] ?? $firstBooking['booker_name'] ?? '';
} elseif ($bookerType === 'member') {
$bgColor = '#D1FAE5';
$cellText = $firstBooking['booker_name'] ?? '';
} else {
$bgColor = '#FEF9C3';
$cellText = $firstBooking['booker_name'] ?? '';
}
$passesTotal = (int) ($firstBooking['passes_total'] ?? 0);
$passesUsed = (int) ($firstBooking['passes_used'] ?? 0);
if ($passesTotal > 0) {
$passIndicator = $passesUsed . '/' . $passesTotal;
}
}
} elseif ($cell['status'] === 'blocked') {
$bgColor = '#E5E7EB';
} }
?> ?>
<td style="padding:6px 4px;border:1px solid #E5E7EB;background:<?= $bgColor ?>;text-align:center;vertical-align:middle;font-size:11px;font-weight:500;"> <td style="padding:6px 4px;border:1px solid #E5E7EB;background:<?= $bgColor ?>;text-align:center;vertical-align:middle;font-size:11px;font-weight:500;position:relative;">
<?= e($cellText) ?> <?= e($cellText) ?>
<?php if ($passIndicator !== ''): ?>
<span style="position:absolute;top:2px;left:2px;font-size:9px;color:#6B7280;background:rgba(255,255,255,0.8);padding:0 2px;border-radius:2px;"><?= e($passIndicator) ?></span>
<?php endif; ?>
</td> </td>
<?php endforeach; ?> <?php endforeach; ?>
</tr> </tr>
......
...@@ -3,31 +3,49 @@ ...@@ -3,31 +3,49 @@
<?php $__template->section('content'); ?> <?php $__template->section('content'); ?>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(280px, 1fr));gap:15px;"> <?php if (!empty($disciplines)): ?>
<?php if (!empty($facilities)): ?> <div class="card" style="margin-bottom:20px;padding:15px;">
<?php foreach ($facilities as $f): ?> <div style="font-weight:700;margin-bottom:10px;font-size:14px;">تصفية بالرياضة</div>
<a href="/sa/mirror/<?= (int) $f['id'] ?>" class="card" style="padding:20px;text-decoration:none;color:inherit;transition:box-shadow 0.2s;border-right:4px solid #3B82F6;"> <div style="display:flex;gap:10px;flex-wrap:wrap;">
<div style="display:flex;align-items:center;gap:12px;"> <?php foreach ($disciplines as $d): ?>
<div style="width:44px;height:44px;background:#DBEAFE;border-radius:10px;display:flex;align-items:center;justify-content:center;"> <a href="/sa/mirror/discipline/<?= (int) $d['id'] ?>" class="btn btn-outline" style="font-size:13px;">
<i data-lucide="monitor" style="width:22px;height:22px;color:#3B82F6;"></i> <?= e($d['name_ar']) ?> <span style="background:#DBEAFE;color:#2563EB;padding:1px 6px;border-radius:8px;font-size:11px;margin-right:4px;"><?= (int) $d['facility_count'] ?></span>
</div> </a>
<div> <?php endforeach; ?>
<div style="font-weight:700;font-size:15px;"><?= e($f['name_ar'] ?? '') ?></div> </div>
<?php </div>
$typeLabels = ['pool' => 'حمام سباحة', 'court' => 'ملعب', 'pitch' => 'ملعب كبير', 'gym' => 'صالة رياضية', 'track' => 'مضمار', 'multipurpose' => 'متعدد']; <?php endif; ?>
?>
<div style="font-size:12px;color:#6B7280;"><?= e($typeLabels[$f['facility_type'] ?? ''] ?? '') ?></div> <?php if (!empty($grouped)): ?>
<?php foreach ($grouped as $disciplineName => $disciplineFacilities): ?>
<div style="margin-bottom:20px;">
<h3 style="font-size:15px;font-weight:700;margin-bottom:10px;color:#374151;"><?= e($disciplineName) ?></h3>
<div style="display:grid;grid-template-columns:repeat(auto-fill, minmax(280px, 1fr));gap:15px;">
<?php foreach ($disciplineFacilities as $f): ?>
<a href="/sa/mirror/<?= (int) $f['id'] ?>" class="card" style="padding:20px;text-decoration:none;color:inherit;transition:box-shadow 0.2s;border-right:4px solid #3B82F6;">
<div style="display:flex;align-items:center;gap:12px;">
<div style="width:44px;height:44px;background:#DBEAFE;border-radius:10px;display:flex;align-items:center;justify-content:center;">
<i data-lucide="monitor" style="width:22px;height:22px;color:#3B82F6;"></i>
</div>
<div>
<div style="font-weight:700;font-size:15px;"><?= e($f['name_ar'] ?? '') ?></div>
<?php
$typeLabels = ['pool' => 'حمام سباحة', 'court' => 'ملعب', 'pitch' => 'ملعب كبير', 'gym' => 'صالة رياضية', 'track' => 'مضمار', 'multipurpose' => 'متعدد'];
?>
<div style="font-size:12px;color:#6B7280;"><?= e($typeLabels[$f['facility_type'] ?? ''] ?? '') ?></div>
</div>
</div> </div>
</div> </a>
</a> <?php endforeach; ?>
<?php endforeach; ?>
<?php else: ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;grid-column:1/-1;">
<i data-lucide="monitor" style="width:36px;height:36px;color:#D1D5DB;display:block;margin:0 auto 10px;"></i>
لا توجد مرافق نشطة
</div> </div>
<?php endif; ?>
</div> </div>
<?php endforeach; ?>
<?php else: ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;">
<i data-lucide="monitor" style="width:36px;height:36px;color:#D1D5DB;display:block;margin:0 auto 10px;"></i>
لا توجد مرافق نشطة
</div>
<?php endif; ?>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
......
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تذاكر حمام السباحة<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<!-- Filters -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/sa/pool-tickets" style="display:flex;gap:10px;align-items:flex-end;flex-wrap:wrap;">
<div>
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px;">حمام السباحة</label>
<select name="facility_id" class="form-input" style="min-width:200px;">
<option value="">-- اختر --</option>
<?php foreach ($facilities as $f): ?>
<option value="<?= (int) $f['id'] ?>" <?= $facilityId === (int) $f['id'] ? 'selected' : '' ?>><?= e($f['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div>
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px;">التاريخ</label>
<input type="date" name="date" value="<?= e($date) ?>" class="form-input">
</div>
<button type="submit" class="btn btn-primary">عرض</button>
</form>
</div>
<?php if ($facilityId > 0): ?>
<!-- Issue Ticket Form -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<h3 style="font-size:14px;font-weight:700;margin-bottom:12px;">إصدار تذكرة جديدة</h3>
<form id="issueTicketForm" style="display:grid;grid-template-columns:repeat(auto-fit, minmax(180px, 1fr));gap:10px;align-items:end;">
<input type="hidden" name="facility_id" value="<?= $facilityId ?>">
<input type="hidden" name="booking_date" value="<?= e($date) ?>">
<div>
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px;">وقت البداية</label>
<input type="time" name="start_time" class="form-input" required>
</div>
<div>
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px;">وقت النهاية</label>
<input type="time" name="end_time" class="form-input" required>
</div>
<div>
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px;">النوع</label>
<select name="is_member" class="form-input">
<option value="0">زائر</option>
<option value="1">عضو</option>
</select>
</div>
<div>
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px;">الاسم</label>
<input type="text" name="guest_name" class="form-input" placeholder="اسم الزائر">
</div>
<div>
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px;">الرقم القومي</label>
<input type="text" name="guest_national_id" class="form-input" placeholder="اختياري" maxlength="14">
</div>
<div>
<button type="submit" class="btn btn-primary" style="width:100%;">إصدار تذكرة</button>
</div>
</form>
<div id="ticketResult" style="margin-top:10px;display:none;"></div>
</div>
<!-- Active Tickets -->
<div class="card">
<div style="padding:12px 15px;border-bottom:1px solid #E5E7EB;">
<span style="font-weight:700;">التذاكر النشطة (<?= count($tickets) ?>)</span>
</div>
<?php if (empty($tickets)): ?>
<div style="padding:30px;text-align:center;color:#9CA3AF;">لا توجد تذاكر نشطة</div>
<?php else: ?>
<table style="width:100%;border-collapse:collapse;font-size:13px;">
<thead>
<tr style="background:#F9FAFB;">
<th style="padding:10px;border-bottom:1px solid #E5E7EB;text-align:right;">#</th>
<th style="padding:10px;border-bottom:1px solid #E5E7EB;text-align:right;">الاسم</th>
<th style="padding:10px;border-bottom:1px solid #E5E7EB;text-align:center;">نوع</th>
<th style="padding:10px;border-bottom:1px solid #E5E7EB;text-align:right;">الوقت</th>
<th style="padding:10px;border-bottom:1px solid #E5E7EB;text-align:right;">المبلغ</th>
<th style="padding:10px;border-bottom:1px solid #E5E7EB;text-align:center;">الحالة</th>
<th style="padding:10px;border-bottom:1px solid #E5E7EB;text-align:center;">إجراء</th>
</tr>
</thead>
<tbody>
<?php foreach ($tickets as $t): ?>
<?php
$statusLabels = ['issued' => 'صادرة', 'active' => 'داخل', 'completed' => 'خرج', 'cancelled' => 'ملغاة'];
$statusColors = ['issued' => '#DBEAFE', 'active' => '#ECFDF5', 'completed' => '#F3F4F6', 'cancelled' => '#FEE2E2'];
?>
<tr style="border-bottom:1px solid #F3F4F6;">
<td style="padding:10px;"><?= (int) $t['id'] ?></td>
<td style="padding:10px;"><?= e($t['guest_name'] ?? 'عضو') ?></td>
<td style="padding:10px;text-align:center;">
<span style="background:<?= $t['is_member'] ? '#ECFDF5' : '#FEF3C7' ?>;padding:2px 8px;border-radius:8px;font-size:11px;"><?= $t['is_member'] ? 'عضو' : 'زائر' ?></span>
</td>
<td style="padding:10px;font-size:12px;"><?= e($t['start_time'] ?? '') ?> - <?= e($t['end_time'] ?? '') ?></td>
<td style="padding:10px;"><?= number_format((float) ($t['amount_paid'] ?? 0), 2) ?> ج.م</td>
<td style="padding:10px;text-align:center;">
<span style="background:<?= $statusColors[$t['status']] ?? '#F3F4F6' ?>;padding:2px 8px;border-radius:8px;font-size:11px;font-weight:600;"><?= e($statusLabels[$t['status']] ?? $t['status']) ?></span>
</td>
<td style="padding:10px;text-align:center;">
<?php if ($t['status'] === 'issued'): ?>
<button onclick="ticketAction(<?= (int) $t['id'] ?>, 'entry')" class="btn btn-sm" style="font-size:11px;padding:3px 8px;background:#059669;color:white;border:none;border-radius:4px;cursor:pointer;">دخول</button>
<button onclick="ticketAction(<?= (int) $t['id'] ?>, 'cancel')" class="btn btn-sm" style="font-size:11px;padding:3px 8px;background:#DC2626;color:white;border:none;border-radius:4px;cursor:pointer;">إلغاء</button>
<?php elseif ($t['status'] === 'active'): ?>
<button onclick="ticketAction(<?= (int) $t['id'] ?>, 'exit')" class="btn btn-sm" style="font-size:11px;padding:3px 8px;background:#D97706;color:white;border:none;border-radius:4px;cursor:pointer;">خروج</button>
<?php else: ?>
-
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') { lucide.createIcons(); }
const form = document.getElementById('issueTicketForm');
if (form) {
form.addEventListener('submit', function(e) {
e.preventDefault();
const fd = new FormData(form);
const data = {};
fd.forEach((v,k) => data[k] = v);
fetch('/sa/pool-tickets/issue', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content || ''},
body: new URLSearchParams(data)
})
.then(r => r.json())
.then(res => {
const el = document.getElementById('ticketResult');
if (res.success) {
el.innerHTML = '<div style="background:#ECFDF5;padding:8px 12px;border-radius:6px;color:#059669;font-size:13px;">تم إصدار التذكرة بنجاح — المبلغ: ' + (res.price || 0) + ' ج.م</div>';
el.style.display = 'block';
setTimeout(() => location.reload(), 1500);
} else {
el.innerHTML = '<div style="background:#FEE2E2;padding:8px 12px;border-radius:6px;color:#DC2626;font-size:13px;">' + (res.error || 'خطأ') + '</div>';
el.style.display = 'block';
}
});
});
}
});
function ticketAction(id, action) {
fetch('/sa/pool-tickets/' + id + '/' + action, {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': document.querySelector('meta[name=csrf-token]')?.content || ''},
})
.then(r => r.json())
.then(res => {
if (res.success) { location.reload(); }
else { alert(res.error || 'خطأ'); }
});
}
</script>
<?php $__template->endSection(); ?>
...@@ -40,6 +40,7 @@ MenuRegistry::register('sports_activity', [ ...@@ -40,6 +40,7 @@ MenuRegistry::register('sports_activity', [
['label_ar' => 'قائمة الانتظار', 'label_en' => 'Waitlist', 'route' => '/sa/waitlist', 'permission' => 'sa.waitlist.view', 'order' => 19], ['label_ar' => 'قائمة الانتظار', 'label_en' => 'Waitlist', 'route' => '/sa/waitlist', 'permission' => 'sa.waitlist.view', 'order' => 19],
['label_ar' => 'اللوكرات', 'label_en' => 'Lockers', 'route' => '/sa/lockers', 'permission' => 'sa.locker.view', 'order' => 20], ['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' => '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],
], ],
]); ]);
...@@ -95,6 +96,10 @@ PermissionRegistry::register('sports_activity', [ ...@@ -95,6 +96,10 @@ PermissionRegistry::register('sports_activity', [
'sa.card.print' => ['ar' => 'طباعة كروت النشاط', 'en' => 'Print SA Cards'], 'sa.card.print' => ['ar' => 'طباعة كروت النشاط', 'en' => 'Print SA Cards'],
'sa.gate.view' => ['ar' => 'عرض البوابة', 'en' => 'View Gate Access'], 'sa.gate.view' => ['ar' => 'عرض البوابة', 'en' => 'View Gate Access'],
'sa.gate.scan' => ['ar' => 'مسح البوابة', 'en' => 'Scan Gate Access'], 'sa.gate.scan' => ['ar' => 'مسح البوابة', 'en' => 'Scan Gate Access'],
'sa.group.force_enroll' => ['ar' => 'تسجيل تجاوز السعة', 'en' => 'Force Enroll (Override Capacity)'],
'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'],
]); ]);
// ─── Event Listeners ──────────────────────────────────────────────────────── // ─── Event Listeners ────────────────────────────────────────────────────────
......
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE sa_bookings ADD COLUMN organization_name VARCHAR(255) NULL AFTER booker_name;
ALTER TABLE sa_bookings ADD COLUMN organization_contact VARCHAR(100) NULL AFTER organization_name
",
'down' => "
ALTER TABLE sa_bookings DROP COLUMN organization_contact;
ALTER TABLE sa_bookings DROP COLUMN organization_name
",
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE sa_facility_units ADD COLUMN expected_capacity INT UNSIGNED NOT NULL DEFAULT 1 COMMENT 'Expected number of people per booking (e.g., 5-a-side pitch = 10)' AFTER max_capacity
",
'down' => "
ALTER TABLE sa_facility_units DROP COLUMN expected_capacity
",
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE sa_group_players ADD COLUMN force_enrolled TINYINT(1) NOT NULL DEFAULT 0 AFTER status
",
'down' => "
ALTER TABLE sa_group_players DROP COLUMN force_enrolled
",
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE sa_bookings ADD COLUMN participant_mode ENUM('full','partial','passes') NOT NULL DEFAULT 'passes' AFTER participant_count;
ALTER TABLE sa_bookings ADD COLUMN passes_total INT UNSIGNED NOT NULL DEFAULT 0 AFTER participant_mode;
ALTER TABLE sa_bookings ADD COLUMN passes_used INT UNSIGNED NOT NULL DEFAULT 0 AFTER passes_total;
CREATE TABLE sa_booking_passes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
booking_id BIGINT UNSIGNED NOT NULL,
pass_number VARCHAR(30) NOT NULL,
status ENUM('issued','used','expired','cancelled') NOT NULL DEFAULT 'issued',
used_at DATETIME NULL,
used_by_name VARCHAR(200) NULL,
gate_employee_id BIGINT UNSIGNED NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_bp_booking (booking_id),
UNIQUE KEY uq_bp_number (pass_number)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
",
'down' => "
DROP TABLE IF EXISTS sa_booking_passes;
ALTER TABLE sa_bookings DROP COLUMN passes_used;
ALTER TABLE sa_bookings DROP COLUMN passes_total;
ALTER TABLE sa_bookings DROP COLUMN participant_mode
",
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE sa_pool_zone_bookings ADD COLUMN access_mode ENUM('reserved','open_access') NOT NULL DEFAULT 'reserved' AFTER assignment_type;
ALTER TABLE sa_pool_zone_bookings ADD COLUMN access_restriction ENUM('all','members_only') NULL AFTER access_mode;
ALTER TABLE sa_pool_zone_bookings ADD COLUMN ticket_price_member DECIMAL(10,2) NULL AFTER access_restriction;
ALTER TABLE sa_pool_zone_bookings ADD COLUMN ticket_price_nonmember DECIMAL(10,2) NULL AFTER ticket_price_member;
ALTER TABLE sa_pool_zone_bookings ADD COLUMN max_occupancy INT UNSIGNED NULL AFTER ticket_price_nonmember;
ALTER TABLE sa_pool_zone_bookings ADD COLUMN current_occupancy INT UNSIGNED NOT NULL DEFAULT 0 AFTER max_occupancy;
CREATE TABLE sa_pool_tickets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
facility_id INT UNSIGNED NOT NULL,
zone_booking_id BIGINT UNSIGNED NULL,
booking_date DATE NOT NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
member_id INT UNSIGNED NULL,
player_id INT UNSIGNED NULL,
guest_name VARCHAR(200) NULL,
guest_national_id VARCHAR(14) NULL,
is_member TINYINT(1) NOT NULL DEFAULT 0,
amount_paid DECIMAL(10,2) NOT NULL DEFAULT 0.00,
payment_status ENUM('unpaid','paid','refunded') NOT NULL DEFAULT 'unpaid',
payment_request_id INT UNSIGNED NULL,
entry_time DATETIME NULL,
exit_time DATETIME NULL,
status ENUM('issued','active','completed','cancelled') NOT NULL DEFAULT 'issued',
issued_by INT UNSIGNED NULL,
branch_id INT UNSIGNED NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_pt_facility_date (facility_id, booking_date),
INDEX idx_pt_zone (zone_booking_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
",
'down' => "
DROP TABLE IF EXISTS sa_pool_tickets;
ALTER TABLE sa_pool_zone_bookings DROP COLUMN current_occupancy;
ALTER TABLE sa_pool_zone_bookings DROP COLUMN max_occupancy;
ALTER TABLE sa_pool_zone_bookings DROP COLUMN ticket_price_nonmember;
ALTER TABLE sa_pool_zone_bookings DROP COLUMN ticket_price_member;
ALTER TABLE sa_pool_zone_bookings DROP COLUMN access_restriction;
ALTER TABLE sa_pool_zone_bookings DROP COLUMN access_mode
",
];
أنا عاوز أوضح بشكل دقيق المفروض منظومة النشاط الرياضي في النادي تكون شغالة إزاي، وبعد كده يتم تحليل النظام الحالي ومقارنته بالنظام المطلوب، مع توضيح الفروقات والنواقص بشكل واضح.
1. منظومة المرافق والحجوزات الحرة
كل نشاط رياضي داخل النادي يحتوي على مرافق مرتبطة به، وكل مرفق له:
* مواعيد تشغيل يومية.
* أسعار مختلفة للساعة حسب:
* الوقت.
* نوع العميل (عضو / غير عضو / مؤسسة).
لكن أسعار الساعات دي تُستخدم فقط في حالة:
* الحجز الحر.
* الحجز الفردي.
* الحجز المؤسسي.
* أي استخدام غير مرتبط ببرنامج تدريبي أو تمرين مجدول.
وفي نفس الوقت، أي حجز يتم على المرفق لازم يظهر داخل “مراية المرفق” بشكل واضح على الـ Timeline، مع توضيح:
* الوقت المحجوز.
* الجهة أو الشخص الحاجز.
* نوع الحجز.
2. منظومة الأكاديميات والبرامج التدريبية
النادي يحتوي على أكاديميات رياضية مسؤولة عن إدارة التدريبات داخل النادي.
كل أكاديمية ممكن:
* تكون مسؤولة عن رياضة واحدة أو أكثر.
* تقدم عدة برامج تدريبية لكل رياضة.
وكل برنامج تدريبي يكون له:
* سعر للأعضاء.
* سعر لغير الأعضاء.
بعد إنشاء البرنامج التدريبي، يتم إنشاء مجموعات تدريبية مرتبطة به، وكل مجموعة:
* يتم تحديد أيام وساعات تدريبها.
* يتم حجز مواعيدها على المرفق الخاص بها.
ومن المهم جدًا إن النظام يسمح بوجود أكثر من مجموعة تدريبية في نفس الوقت على نفس المرفق إذا كانت سعة المرفق تسمح بذلك (Overlapping Groups).
ولازم مراية المرفق تعرض هذا الأمر بشكل بصري واضح واحترافي على الـ Timeline الخاص بالمرفق، مع مراعاة الـ Capacity الخاصة بالمرفق سواء كانت:
* مجموعات تدريبية.
* حجوزات فردية.
* حجوزات مؤسسية.
* حجوزات حرة.
3. مفهوم “مراية المرفق”
المراية هي مجرد Visualization للمرافق والحجوزات، ويجب أن تكون:
* قابلة للتصنيف حسب الرياضة.
* قابلة للفلترة بشكل عملي وسريع.
على سبيل المثال:
لو موظف يريد حجز Bowling أو PlayStation، لا يصح أن يدخل على كل مرفق بشكل منفصل ليرى المتاح.
المطلوب هو عرض Visualized محترم وواضح يبين:
* كل المرافق الخاصة بالنشاط.
* المتاح والمحجوز.
* الفترات الزمنية.
* السعة الحالية.
بشكل سريع وسهل الاستخدام.
4. منظومة حمامات السباحة
حمامات السباحة يجب أن تتبع نفس المنظومة العامة للمرافق، ولكن بطريقة عرض وحجز مختلفة.
كل حمام سباحة يعتبر “مرفق” عادي داخل النظام، لكن شاشة الحجز الخاصة به تكون بنظام Grid كامل بدل الـ Timeline التقليدي.
الهدف من الـ Grid هو دعم جميع الـ Edge Cases المطلوبة.
ويجب أن يدعم النظام:
* حجز حارات.
* حجز Cells أو مناطق محددة داخل الحمام.
* تحديد أيام ومواعيد لكل مساحة.
* تحديد نوع الإتاحة:
* متاح للحجز الحر.
* متاح للأعضاء فقط.
* متاح للجميع.
وفي بعض الحالات تكون المساحة “غير محجوزة” ولكنها “متاحة للاستخدام الحر” خلال فترة معينة، بحيث أي شخص يستطيع دفع تذكرة دخول واستخدامها.
مع مراعاة:
* وجود أسعار مختلفة للأعضاء وغير الأعضاء.
* عدم وجود مفهوم “فرد يحجز حمام سباحة بالكامل لنفسه”، لأن طبيعة استخدام حمامات السباحة مختلفة عن الملاعب والمرافق الفردية.
5. Wizard مكتب التسجيل
مطلوب تبسيط الـ Wizard الخاص بمكتب التسجيل لأقصى درجة ممكنة.
الـ Workflow المطلوب يكون كالتالي:
* الموظف يدخل بيانات اللاعب.
* يرفع صورة اللاعب.
* يرفع الشهادة الطبية (إن وجدت).
* يختار الرياضة.
* يختار البرنامج التدريبي.
بعدها النظام يقوم تلقائيًا بـ:
* حساب الرسوم.
* إضافة المبلغ على الخزنة.
* إدخال اللاعب في آخر مجموعة غير مكتملة داخل البرنامج التدريبي المختار.
ولو كل المجموعات الحالية مكتملة:
* النظام يفتح مجموعة جديدة تلقائيًا.
* ويتم إضافة اللاعب إليها مباشرة.
وفي نفس الوقت:
* المدرب يجب أن يمتلك صلاحية نقل أي لاعب يدويًا من مجموعة إلى أخرى.
* حتى لو هذا النقل تسبب في تجاوز العدد المسموح للمجموعة (Overloading Group).
في نقطة محورية مهمة جدًا لازم تكون واضحة في المنظومة، وهي إن أي مرفق داخل النادي في النهاية بيكون محجوز لواحدة من 3 حالات فقط:
* فرد / حجز حر.
* مجموعة تدريبية.
* مؤسسة أو جهة.
لكن حتى في حالة “الحجز الفردي” أو “حجز المؤسسة”، الحجز في الحقيقة لا يخص شخصًا واحدًا فقط، بل يخص عددًا معينًا من الأشخاص المسموح لهم بالدخول واستخدام المرفق.
يعني مثلًا:
لو شخص قام بحجز ملعب كرة خماسي لمدة ساعة، فمن الطبيعي إن الاستخدام الفعلي للمرفق سيكون لـ 10 أفراد، وليس لشخص واحد فقط.
لذلك النظام يجب أن يدعم أكثر من طريقة لإدارة الأشخاص المرتبطين بالحجز:
1. تسجيل كامل للأفراد
يتم إدخال:
* أسماء اللاعبين.
* أرقامهم القومية أو بياناتهم الكاملة.
وفي هذه الحالة يصبح كل شخص مسجل داخل النظام ويمكن التحقق منه عند الدخول.
2. تسجيل جزئي للأفراد
في بعض الحالات قد لا تتوفر كل البيانات، لذلك النظام يجب أن يسمح بإدخال:
* أسماء الأشخاص فقط.
بدون الحاجة لبيانات كاملة أو رقم قومي.
3. نظام الـ Passes
في حالة عدم توفر أي بيانات عن الأفراد، النظام يجب أن يسمح بإنشاء عدد محدد من الـ Passes المرتبطة بالحجز.
مثال:
* حجز ملعب كرة خماسي = 10 Passes.
* حجز Paddle زوجي = 4 Passes.
* حجز Bowling لحارتين = عدد محدد حسب الحجز.
وفي هذه الحالة:
* موظف البوابة يتأكد فقط إن عدد الداخلين لا يتجاوز عدد الـ Passes المسموح بها.
* لا يجوز دخول عدد أكبر من العدد المحدد.
* ويُفضل أيضًا تسجيل عدد من تم استخدامه فعليًا من الـ Passes.
وهذا المفهوم يجب أن يطبق على:
* الملاعب.
* حمامات السباحة.
* البولينج.
* البادل.
* البلايستيشن.
* أي مرفق ترفيهي أو رياضي داخل النادي.
لأن الحجز في النهاية ليس “حجز شخص”، بل هو:
“حجز Capacity / حق استخدام لمجموعة من الأشخاص خلال فترة زمنية محددة”.
ولذلك يجب أن تكون منظومة:
* الحجوزات.
* البوابات.
* الـ Access Control.
* والمرايات الخاصة بالمرافق.
كلها مترابطة مع مفهوم:
عدد الأشخاص المسموح لهم بالدخول الفعلي بناءً على نوع الحجز وسعته.
لازم يكون في مفهوم أساسي داخل تعريف أي مرفق في النظام وهو:
* Expected Capacity
أو
* Perfect Capacity
وهو العدد الطبيعي أو المتوقع للأشخاص الذين يستخدمون هذا المرفق في الحجز الواحد.
القيمة دي يتم تحديدها أثناء إنشاء المرفق نفسه، وتصبح جزء أساسي من إعداداته.
أمثلة:
* ملعب كرة خماسي → Expected Capacity = 10
* جهاز PlayStation → Expected Capacity = 2
* حارة Bowling → Expected Capacity = 6
* ملعب Paddle → Expected Capacity = 4
* طاولة Billiard → Expected Capacity = 2
وأهمية الرقم ده إنه يستخدم تلقائيًا داخل النظام في حالات الحجز الحر أو الحجز غير التدريبي.
يعني:
لو عميل قام بحجز ملعب كرة خماسي لمدة ساعة، فالنظام تلقائيًا:
* يتوقع دخول 10 أفراد.
* يطلب إما:
* تسجيل أسماء اللاعبين.
* أو إنشاء 10 Passes للدخول.
ولو تم تسجيل عدد أقل أو أكبر من الـ Expected Capacity، النظام:
* إما يعطي Warning.
* أو يطلب تأكيد إداري حسب صلاحيات المستخدم.
أما في حالة البرامج التدريبية والمجموعات:
فالـ Expected Capacity لا يكون شرطًا ثابتًا، لأن:
* المدرب قد يسمح بزيادة العدد.
* بعض المرافق تتحمل أكثر من مجموعة في نفس الوقت.
* وبعض التدريبات طبيعتها مختلفة.
لذلك:
الـ Expected Capacity الخاصة بالمرفق تستخدم بشكل أساسي في:
* الحجوزات الحرة.
* الحجوزات الفردية.
* الحجوزات المؤسسية.
* وتنظيم الدخول من البوابات.
ويجب أن تكون القيمة:
* قابلة للتعديل.
* ظاهرة بوضوح داخل إعدادات المرفق.
* ومستخدمة تلقائيًا في الـ Wizard الخاص بالحجز وإنشاء الـ Passes.
# Sports Activity System — Alignment Plan
## Honest Assessment
The current implementation has the **infrastructure** (tables, models, services, routes) but the **behavior** described in the requirements is NOT met. Here's the brutal truth:
| Requirement | Current Reality |
|-------------|----------------|
| Mirror filtered/categorized by sport | Flat grid of all facilities. No grouping, no filter, no discipline tabs. You click one-by-one. |
| Organization booking with separate pricing | Only `member` / `guest`. No organization concept anywhere in booking flow. |
| Wizard: pick sport → pick program → auto-assign group | Wizard makes user manually browse ALL groups (flat list), pick one themselves. No sport→program flow. |
| Auto-create group when full | Hard error "المجموعة ممتلئة". Suggests waitlist. Dead end. |
| Coach force-enroll past capacity | Hard block at `max_capacity`. No override. No bypass. |
| Coach transfer to full group | `GroupTransferService` hard blocks at line 40 if target full. No force flag. |
| Pool: open-access ticket zones | Pool grid only supports cell-level reserved bookings. No concept of "open zone pay-a-ticket-and-swim". |
| Mirror shows WHO booked + type clearly | Shows `booker_name` for hourly and `group_name` for training. But no organization, no color distinction by booker type. |
| Expected Capacity per facility unit | Does NOT exist. `max_capacity` on `sa_facility_units` is for concurrent booking slots, NOT for "how many people use this facility per booking". |
| Passes system for bookings | Does NOT exist. `sa_booking_participants` has individual names but no concept of anonymous numbered passes. |
| Participant registration (full/partial/passes) | Only basic `participant_name` + `player_id`. No national_id on participants. No pass-code system. No gate integration for bookings. |
| Booking = capacity for group of people | System treats `participant_count` as just a number for pricing. No enforcement at gates. No pass issuance. |
---
## What's Actually Working Correctly
1. **Facility model has `discipline_id`** — the link exists in DB, just unused in mirror UI.
2. **Mirror timeline renders correctly** per single facility — shows units as rows, time as columns, color-coded cells.
3. **Pool grid** works for cell-level assignment (training, blocked, maintenance, hourly).
4. **Booking service** correctly stores `booker_name`, `participant_count`, creates bookings.
5. **`sa_booking_participants` table** exists with: `booking_id`, `player_id`, `participant_name`, `is_member`, `price_charged`, `checked_in_at`, `checked_out_at`.
6. **Enrollment + payments** work for normal (non-full) groups.
7. **Registration wizard** works end-to-end — but with wrong UX flow.
8. **Group schedule + capacity tracking** — correctly maintained.
9. **`sa_bookings.booker_type`** is VARCHAR(20), already supports arbitrary values — no ENUM constraint.
---
## GAP 1: Mirror — No Filtering / No Discipline Categorization
### What the requirement says:
> المراية ما هي الا visualization للمرفق الواحد ولازم يكونو categorized or filtered بالرياضة الواحدة
> لو انا بحجز لحد bowling او باليستيشن مش لازم اخش عليهم مرفق مرفق عشان اشوف انهي حارة فاضية
### What currently exists:
- `mirror/index.php` — flat card grid, `foreach ($facilities as $f)`, no grouping.
- No discipline name shown on cards.
- No tabs, no filters, no search.
- `Facility::getActive()` returns all facilities unsorted by discipline.
- `MirrorController::index()` just passes raw facility list.
### What needs to happen:
**`MirrorController.php`**`index()`:
- Load all disciplines that have active facilities.
- Group facilities by discipline.
- Pass both to view.
**`MirrorController.php`** — new `byDiscipline(Request, string $disciplineId)`:
- Load ALL facilities where `discipline_id = $id`.
- Build combined state for all of them in one page.
- Users see all bowling lanes / all PS courts / all football pitches at once.
**`MirrorController.php`** — new `byDisciplineDate(Request, string $disciplineId, string $date)`:
- Same but for specific date.
**`MirrorStateService.php`** — new `getMultiFacilityState(int $disciplineId, string $date)`:
- Loop each facility of that discipline.
- Call existing `getFacilityState()` per facility.
- Return array of all results.
**`Views/mirror/index.php`** — complete rewrite:
- Discipline tabs/filter chips at top.
- Facilities grouped under discipline headings.
- Click discipline → goes to `/sa/mirror/discipline/{id}` showing all facilities' timelines.
- Click single facility → existing behavior.
**`Views/mirror/discipline_view.php`** — NEW:
- Shows multiple facility timelines stacked vertically.
- Each facility has its own grid (rows = units, cols = time slots).
- Color-coded consistently.
- Date picker applies to all.
**`Routes.php`** — add:
```
['GET', '/sa/mirror/discipline/{id:\d+}', '...MirrorController@byDiscipline', ['auth'], 'sa.mirror.view']
['GET', '/sa/mirror/discipline/{id:\d+}/{date}', '...MirrorController@byDisciplineDate', ['auth'], 'sa.mirror.view']
```
**Cascading**:
- `Facility::getActive()` should also load `discipline_name` via join — or add `getActiveGroupedByDiscipline()`.
- Booking wizard could also benefit from discipline filter (but not required now).
---
## GAP 2: Organization Booker Type
### What the requirement says:
> سعر للساعة على حسب الوقت او اللي بيحجزها عضو او غير عضو او مؤسسة
### What currently exists:
- `SaConstants`: `BOOKER_MEMBER = 'member'`, `BOOKER_GUEST = 'guest'`. That's it.
- `BookingService::createHourlyBooking()` accepts `booker_type` but only member/guest logic.
- `PricingCalculatorService::calculate()` has `$isMember` boolean — binary, no organization path.
- `BookingWizardController` validates `booker_type` but no org option in UI.
- `sa_bookings.booker_type` is VARCHAR(20) — can store 'organization' without migration.
### What needs to happen:
**Migration** (for org-specific fields only):
```sql
ALTER TABLE sa_bookings ADD COLUMN organization_name VARCHAR(255) NULL AFTER booker_name;
ALTER TABLE sa_bookings ADD COLUMN organization_contact VARCHAR(100) NULL AFTER organization_name;
ALTER TABLE sa_bookings ADD COLUMN booker_national_id VARCHAR(14) NULL AFTER organization_contact;
-- booker_national_id may already exist, check first
```
**`SaConstants.php`**:
- Add `BOOKER_ORGANIZATION = 'organization'`
**`PricingCalculatorService.php`**:
- Change from `bool $isMember` to `string $bookerType`.
- Add pricing rule lookup for `organization` type.
- Fallback: if no org pricing rule exists, use guest pricing.
**`BookingService.php`**:
- Accept + store `organization_name`, `organization_contact`.
**`BookingWizardController.php`**:
- Add `organization` option to booker_type select.
- Show/hide org name + contact fields based on selection.
- Validation: if org type, require org name.
**`Views/booking-wizard/` (or `Views/bookings/wizard.php`)**:
- Add radio/select for "مؤسسة" as third booker type option.
- Conditional fields: org name, contact person.
**`sa_pricing_rules`** or **`sa_time_brackets`**:
- Verify schema supports a `target_type` or similar for organization pricing.
- If not: add organization rate columns to `sa_time_brackets`.
**Cascading**:
- Mirror view already shows `booker_name` — for org bookings, show `organization_name` instead.
- `MirrorStateService` — include `organization_name` in booking data passed to view.
- Reports that filter by booker_type will auto-include new type.
---
## GAP 3: Registration Wizard — Wrong Flow
### What the requirement says:
> بدخل اللاعب وبياناته برفق صورته وشهادته الطبية ان وجدت واختار الرياضة ثم اختار البرنامج التدريبي المسعر واحدفه على الخزنة اوتوماتيكي
### What currently exists:
- 5-step wizard: **استمارة** (form fee) → **الصورة** (photo) → **النشاط** (activity/group) → **الدفع** (pay) → **الاستلام** (card)
- Step 3 shows a flat list of ALL active groups across all disciplines.
- User must scroll through groups, manually pick one.
- No "choose sport first" step.
- No "choose program" step (program is just a label on the group card).
- Group list shows: group name, discipline name, program name, price, capacity bar.
- System does NOT auto-pick a group. It's 100% manual.
### What needs to happen:
**New wizard flow**:
| Step | Label | What happens |
|------|-------|-------------|
| 1 | بيانات اللاعب | Player data + photo + medical cert upload — all in ONE step |
| 2 | الرياضة | Pick discipline (sport) — big clickable tiles |
| 3 | البرنامج | Pick program (shows price for member/non-member). System auto-assigns group. |
| 4 | الدفع | Total fees calculated → send to cashier |
| 5 | الاستلام | Print form + generate card |
**`RegistrationWizardController.php`**:
- `wizardStep()` — restructure: load disciplines for step 2, programs for step 3.
- New endpoint: `getPrograms(Request, string $disciplineId): Response` — returns programs for selected discipline as JSON.
- `selectActivity()` — accept `program_id` instead of `group_id`. Internally resolve group via auto-assign.
- `determineStep()` — rewrite logic:
- No photo + no medical → step 1
- No discipline selected → step 2
- No program selected → step 3
- Not paid → step 4
- Paid → step 5
**`RegistrationWizardService.php`**:
- `selectGroup()` → refactor to `selectProgram(int $registrationId, int $programId, int $months, bool $hasSibling)`:
- Call `EnrollmentService::findOrCreateAvailableGroup($programId)` (from GAP 4).
- Use returned group for enrollment.
- Rest of logic (fee calc, discount, etc.) stays same.
- `startRegistration()` — accept photo + medical doc in same call (or keep separate endpoint but UI presents as one step).
**`sa_registrations` table**:
- May need `discipline_id` column if not present (check — might already have via `group_id``program``discipline`).
**`Views/registration/wizard.php`** — major rewrite:
- Step 1: merge current step 1 (form fee) + step 2 (photo) into single panel. Player fills data, captures photo, uploads medical cert — one "next" button.
- Step 2: NEW — show discipline cards (football, swimming, basketball, etc.) as big clickable tiles.
- Step 3: NEW — show programs under selected discipline with prices. No groups visible to user.
- Step 4: show fee breakdown + "إرسال للخزنة" button. Group is auto-assigned in background.
- Step 5: print + card (stays same).
**`Routes.php`** — add:
```
['GET', '/api/sa/programs-by-discipline/{id:\d+}', '...RegistrationWizardController@getPrograms', ['auth'], 'sa.registration.manage']
```
**Cascading**:
- If form fee is still required, include it in step 4 total (not separate step).
- Medical cert upload: add to step 1. Creates `sa_player_documents` record.
- Old in-progress registrations (old flow) — `determineStep()` must handle gracefully.
---
## GAP 4: Auto-Group Creation When Full
### What the requirement says:
> السيستم بيدخله في اخر مجموعة غير مكتملة للبرنامج دا ولو المجموعة المتاحة طلعت مكتملة السيستم اوتوماتيك يفتح مجموعة جديدة ويدخل اللاعب دا فيها
### What currently exists:
- `EnrollmentService::enroll()` line 70: `if (current_count >= max_capacity) → return error "المجموعة ممتلئة"`.
- `RegistrationWizardService::selectGroup()` line 190: same check — `return error "المجموعة ممتلئة"`.
- No method to find "last non-full group for program".
- No method to create a new group from a program template.
### What needs to happen:
**New method `EnrollmentService::findOrCreateAvailableGroup(int $programId): array`**:
1. Find last active non-full group for this program.
2. If found → return it.
3. If not found → clone from last group of same program (even if full):
- Copy: `name_ar` (increment number), `program_id`, `facility_unit_id`, `coach_id`, fees, age limits, `max_capacity`, `branch_id`.
- Set: `current_count = 0`, `is_full = 0`, `status = 'active'`.
- Duplicate all `sa_group_schedule` entries from source group.
4. Return new group.
**Group naming**: "كرة قدم - مجموعة 3" → "كرة قدم - مجموعة 4". If no number, append " 2".
**Modify `RegistrationWizardService`**:
- New `selectProgram()` method — accepts `program_id`, auto-resolves group.
**Cascading**:
- `sa_group_schedule` entries duplicated → mirror shows training automatically.
- `SubscriptionGeneratorService` picks up new groups automatically.
- New group has `current_count = 0` — ready for enrollment.
---
## GAP 5: Coach Force-Enroll / Overload Group
### What the requirement says:
> من حق المدرب يدويا يرحل اي لاعب من مجموعة لمجموعة اخرى حتى لو هو فوق العدد المتاح (he can overload a group)
### What currently exists:
- `EnrollmentService::enroll()` line 70: hard block.
- `GroupTransferService::transfer()` line 40: `if (current_count >= max_capacity) → return error`.
- No permission for override. No UI button for force-add.
### What needs to happen:
**`EnrollmentService.php`** — new method `forceEnroll(int $groupId, int $playerId): array`:
- Same as `enroll()` but SKIP the capacity check.
- After enrollment, set `is_full = 1` if count >= max (informational only).
**`GroupTransferService.php`** — add `bool $force = false` parameter:
- When `$force = true` → skip capacity check at line 40.
**`GroupController.php`** — new `forceEnroll()` action + modify `transferPlayer()` to pass force flag when user has permission.
**`Routes.php`**`['POST', '/sa/groups/{id:\d+}/force-enroll', ...]`
**`bootstrap.php`** — register `sa.group.force_enroll` permission.
**`Views/groups/show.php`** — "إضافة لاعب (تجاوز السعة)" button (shown only to authorized users).
**Cascading**:
- `current_count` can exceed `max_capacity`. `is_full = 1` stays set so normal path blocks.
- Add `force_enrolled` flag to `sa_group_players` for audit trail.
---
## GAP 6: Pool Open-Access Ticket Mode
### What the requirement says:
> ممكن نحجز حارات او cells ونحدد ايامه ومواعيده ون mark المساحة دي انها متاحة للحجز الحر... اي حد ييجي يدفع تيكت دخول
### What currently exists:
- Pool grid supports: training, blocked, maintenance, hourly — via `assignment_type`.
- `ServiceDeskController` has basic "تذكرة نشاط" — carnet-based, NOT pool-zone-aware.
- No concept of "open-access zone during time X".
- No occupancy tracking for open zones.
- Can't mark a zone as "members only" or "everyone".
### What needs to happen:
**Migration**:
```sql
ALTER TABLE sa_pool_zone_bookings ADD COLUMN access_mode ENUM('reserved','open_access') NOT NULL DEFAULT 'reserved' AFTER assignment_type;
ALTER TABLE sa_pool_zone_bookings ADD COLUMN access_restriction ENUM('all','members_only') NULL AFTER access_mode;
ALTER TABLE sa_pool_zone_bookings ADD COLUMN ticket_price_member DECIMAL(10,2) NULL;
ALTER TABLE sa_pool_zone_bookings ADD COLUMN ticket_price_nonmember DECIMAL(10,2) NULL;
ALTER TABLE sa_pool_zone_bookings ADD COLUMN max_occupancy INT UNSIGNED NULL;
ALTER TABLE sa_pool_zone_bookings ADD COLUMN current_occupancy INT UNSIGNED NOT NULL DEFAULT 0;
CREATE TABLE sa_pool_tickets (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
facility_id INT UNSIGNED NOT NULL,
zone_booking_id BIGINT UNSIGNED NULL,
booking_date DATE NOT NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
member_id INT UNSIGNED NULL,
player_id INT UNSIGNED NULL,
guest_name VARCHAR(200) NULL,
guest_national_id VARCHAR(14) NULL,
is_member TINYINT(1) NOT NULL DEFAULT 0,
amount_paid DECIMAL(10,2) NOT NULL DEFAULT 0.00,
payment_status ENUM('unpaid','paid','refunded') NOT NULL DEFAULT 'unpaid',
payment_request_id INT UNSIGNED NULL,
entry_time DATETIME NULL,
exit_time DATETIME NULL,
status ENUM('issued','active','completed','cancelled') NOT NULL DEFAULT 'issued',
issued_by INT UNSIGNED NULL,
branch_id INT UNSIGNED NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_pt_facility_date (facility_id, booking_date),
INDEX idx_pt_zone (zone_booking_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
**New `Services/PoolTicketService.php`**:
- `issueTicket()` — find open-access zone, check restriction + occupancy, create ticket, payment request, increment occupancy.
- `cancelTicket()` — decrement occupancy.
- `recordEntry()` / `recordExit()`.
**New `Controllers/PoolTicketController.php`** — list, issue, cancel.
**`PoolGridService.php`** — handle `access_mode` in state + `bulkAssign()`.
**`Views/pool-grid/`** — different color + occupancy display for open_access cells.
**Cascading**:
- `MirrorStateService` — open_access zones show as "دخول حر" not "محجوز".
- `PoolGridTemplateService` — templates need `access_mode` support.
- Pool financial dashboard includes ticket revenue.
---
## GAP 7: Expected Capacity + Passes System (NEW)
### What the requirement says:
> أي مرفق داخل النادي في النهاية بيكون محجوز... الحجز في الحقيقة لا يخص شخصًا واحدًا فقط، بل يخص عددًا معينًا من الأشخاص المسموح لهم بالدخول
>
> لو شخص قام بحجز ملعب كرة خماسي لمدة ساعة، فمن الطبيعي إن الاستخدام الفعلي سيكون لـ 10 أفراد
>
> Expected Capacity = العدد الطبيعي أو المتوقع للأشخاص الذين يستخدمون هذا المرفق في الحجز الواحد
### What currently exists:
- `sa_facility_units.max_capacity` — this is for **concurrent booking slots** (shared mode), NOT "how many people enter per booking".
- `sa_bookings.participant_count` — stored as a number, used only for pricing calculation.
- `sa_booking_participants` — can store names/player_ids, has `checked_in_at`/`checked_out_at`. But:
- No national_id field on participants.
- No "pass number" or anonymous pass concept.
- No gate integration (gate system is for player cards only via `sa_gate_access_log`).
- No enforcement at entry — just passive data.
- No concept of "expected capacity" on the facility unit.
### What needs to happen:
#### 7A: Expected Capacity on Facility Units
**Migration**:
```sql
ALTER TABLE sa_facility_units ADD COLUMN expected_capacity INT UNSIGNED NOT NULL DEFAULT 1
COMMENT 'Expected number of people per booking (e.g., 5-a-side pitch = 10)' AFTER max_capacity;
```
**`FacilityUnitController`** (or wherever units are managed):
- Add `expected_capacity` to create/edit forms.
- Display prominently in unit settings.
**`BookingService::createHourlyBooking()`**:
- When `participant_count` is not provided, default to `expected_capacity` from the facility unit.
- When `participant_count` significantly differs from `expected_capacity`, flag a warning (not a hard block).
**`BookingWizardController`**:
- Pre-fill participant count with `expected_capacity`.
- Show label: "العدد المتوقع: 10 أشخاص".
**`Views/booking-wizard/`**:
- Auto-fill participant count field.
- Visual indicator of expected vs entered.
#### 7B: Participant Registration — 3 Modes
The system must support 3 modes for tracking who is associated with a booking:
**Mode 1: Full Registration**
- Enter names + national IDs → creates `sa_booking_participants` with `player_id` linked.
- Each person is verifiable at the gate.
- Already partially supported — `sa_booking_participants` has `player_id`.
**Mode 2: Partial Registration (Names Only)**
- Enter names without full data.
- Already partially supported — `sa_booking_participants.participant_name`.
- Need: make this the default entry mode. Currently only used when booking is created via `BookingService` with explicit participants array.
**Mode 3: Passes (Anonymous)**
- Generate N passes linked to the booking.
- No personal data required.
- Gate staff count entries against pass limit.
**Migration for passes**:
```sql
CREATE TABLE sa_booking_passes (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
booking_id BIGINT UNSIGNED NOT NULL,
pass_number VARCHAR(30) NOT NULL COMMENT 'e.g., BK-00123-P01',
status ENUM('issued','used','expired','cancelled') NOT NULL DEFAULT 'issued',
used_at DATETIME NULL,
used_by_name VARCHAR(200) NULL COMMENT 'optional name if collected at gate',
gate_employee_id BIGINT UNSIGNED NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_bp_booking (booking_id),
UNIQUE KEY uq_bp_number (pass_number),
CONSTRAINT fk_bp_booking FOREIGN KEY (booking_id) REFERENCES sa_bookings(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Track which mode a booking uses
ALTER TABLE sa_bookings ADD COLUMN participant_mode ENUM('full','partial','passes') NOT NULL DEFAULT 'passes' AFTER participant_count;
ALTER TABLE sa_bookings ADD COLUMN passes_total INT UNSIGNED NOT NULL DEFAULT 0 AFTER participant_mode;
ALTER TABLE sa_bookings ADD COLUMN passes_used INT UNSIGNED NOT NULL DEFAULT 0 AFTER passes_total;
```
**`SaConstants.php`**:
```php
const PARTICIPANT_MODE_FULL = 'full';
const PARTICIPANT_MODE_PARTIAL = 'partial';
const PARTICIPANT_MODE_PASSES = 'passes';
const PASS_ISSUED = 'issued';
const PASS_USED = 'used';
const PASS_EXPIRED = 'expired';
const PASS_CANCELLED = 'cancelled';
```
**New `Services/BookingPassService.php`**:
- `generatePasses(int $bookingId, int $count): array` — creates N pass records with sequential numbers.
- `usePass(string $passNumber, ?string $name, int $employeeId): array` — marks pass as used, increments `passes_used`.
- `getBookingPasses(int $bookingId): array` — list all passes for a booking.
- `validatePass(string $passNumber): array` — check if valid and unused.
**`BookingService::createHourlyBooking()`**:
- After creating booking, based on `participant_mode`:
- `passes` → auto-call `BookingPassService::generatePasses()` with count = `participant_count` (defaulting to `expected_capacity`).
- `full` / `partial` → create `sa_booking_participants` entries (existing logic).
**`BookingWizardController`**:
- After booking creation, prompt:
- "هل تريد تسجيل بيانات المشاركين أم إصدار Passes؟"
- Option 1: Enter names/IDs (full/partial mode).
- Option 2: Issue passes (default — system auto-generates based on expected_capacity).
**New `Controllers/BookingPassController.php`**:
- `passes(Request, string $bookingId)` — view/print passes for a booking.
- `usePass(Request)` — gate employee scans/enters pass number → marks used.
- `printPasses(Request, string $bookingId)` — generate printable pass cards.
**Gate Integration**:
- `GateAccessService` (or equivalent) — when pass number is scanned:
- Validate against `sa_booking_passes`.
- Check booking date/time matches current time (within tolerance).
- Mark pass as used.
- Log entry.
- If participants registered (mode = full): validate against `sa_booking_participants` by player card or national ID.
- Mirror/dashboard: show "X/Y passes used" in real-time.
**`Views/bookings/show.php`**:
- Show participant mode.
- If passes: show pass list with status (issued/used), print button.
- If full/partial: show participant names.
**`Views/mirror/facility_view.php`**:
- Show pass usage in booking cell tooltip: "محجوز لـ [Name] — 7/10 دخلوا".
**Cascading**:
- Every booking type (individual, organization) uses this system.
- Pool tickets (GAP 6) are a separate concept — they're for open-access zones, not reserved bookings.
- Booking wizard must ask for mode AFTER creating the booking or as part of creation.
- `participant_count` field now has real meaning — it's the number of passes/people allowed.
- `expected_capacity` pre-fills it, but user can override.
- Reports: show pass utilization (how many passes issued vs used).
#### 7C: Expected Capacity ≠ Training Group Capacity
Per the requirements:
> في حالة البرامج التدريبية والمجموعات: الـ Expected Capacity لا يكون شرطًا ثابتًا
This means:
- `expected_capacity` on facility units is ONLY used for **free/hourly/org bookings**.
- Training group bookings do NOT auto-generate passes. The group has its own roster.
- The group's `max_capacity` on `sa_groups` is the player enrollment limit (separate concept).
- Multiple groups can share a facility at once (overlapping) — each group's attendees are tracked via `sa_attendance`, not via passes.
No additional code change needed here — just ensure `BookingService` only auto-generates passes for `booking_type = 'hourly'`, not `'training'`.
---
## GAP 8: Mirror Booking Labels + Type Colors
### What the requirement says:
> أي حجز لازم يظهر مع توضيح: الوقت المحجوز، الجهة أو الشخص الحاجز، نوع الحجز
### What currently exists:
- `facility_view.php` line 67: training shows `group_name`.
- `facility_view.php` line 70: booked shows `booker_name`.
- One color per status type. No distinction between member/guest/org bookings.
- Legend only shows: متاح, جزئي, ممتلئ, تدريب, محجوز, مغلق.
### What needs to happen:
**`Views/mirror/facility_view.php`**:
- For `booked` status cells:
- If `booker_type = 'organization'` → show `organization_name` + orange background.
- If `booker_type = 'member'` → show `booker_name` + green background.
- If `booker_type = 'guest'` → show `booker_name` + yellow background.
- Add pass usage indicator: "7/10" in corner of cell.
- Legend update: show all actual booking types with colors.
**`MirrorStateService.php`**:
- Pass `booker_type`, `organization_name`, `passes_used`, `passes_total` in cell booking data.
**Color scheme**:
- Training → blue (#DBEAFE)
- Hourly member → green (#D1FAE5)
- Hourly guest → yellow (#FEF9C3)
- Hourly org → orange (#FFEDD5)
- Blocked/Maintenance → gray (#E5E7EB)
- Open access (pool) → cyan (#CFFAFE)
**Cascading**: Mostly view changes. Depends on GAP 2 (org type) and GAP 7 (passes) being done first.
---
## Execution Order
```
PHASE 1 — Foundation (independent, can be parallel):
├── 1A: GAP 2 — Organization booker type
│ (migration + constants + pricing + booking service)
├── 1B: GAP 5 — Force-enroll capability
│ (service + permission + route)
├── 1C: GAP 4 — Auto-group creation
│ (new EnrollmentService method)
└── 1D: GAP 7A — Expected Capacity on facility units
(migration + UI field + booking wizard pre-fill)
PHASE 2 — Major features (some depend on Phase 1):
├── 2A: GAP 7B — Passes system
│ (migration + BookingPassService + controller + gate integration)
│ depends on: 1D (expected_capacity for default pass count)
└── 2B: GAP 6 — Pool open-access tickets
(migration + PoolTicketService + controller)
(independent of Phase 1)
PHASE 3 — UI Overhaul (depends on Phase 1+2):
├── 3A: GAP 1 — Mirror discipline filter + multi-facility view
│ depends on: 1A (org labels), 2A (pass counts in cells)
├── 3B: GAP 8 — Mirror labels + colors
│ depends on: 1A (org type), 2A (passes display)
└── 3C: GAP 3 — Registration wizard new flow
depends on: 1C (auto-group), 1B (coach can still override after)
```
---
## Dependency Graph
```
GAP 7A (Expected Capacity) ──→ GAP 7B (Passes System) ──┐
├──→ GAP 8 (Mirror Labels)
GAP 2 (Org Booker) ──────────────────────────────────────┘ │
GAP 4 (Auto-Group) ────→ GAP 3 (Wizard Simplify) GAP 1 (Mirror Filter)
GAP 5 (Force Enroll) ──── standalone
GAP 6 (Pool Tickets) ──── standalone
```
---
## Complete File List
### New Files to Create
| File | Purpose |
|------|---------|
| `database/migrations/Phase_70_XXX_add_expected_capacity.php` | expected_capacity on sa_facility_units |
| `database/migrations/Phase_70_XXX_add_booking_passes.php` | sa_booking_passes table + participant_mode/passes columns on sa_bookings |
| `database/migrations/Phase_70_XXX_add_organization_booker.php` | Org columns on sa_bookings |
| `database/migrations/Phase_70_XXX_pool_open_access.php` | access_mode + sa_pool_tickets table |
| `app/Modules/SportsActivity/Services/BookingPassService.php` | Pass generation, validation, usage |
| `app/Modules/SportsActivity/Services/PoolTicketService.php` | Pool ticket issuance |
| `app/Modules/SportsActivity/Controllers/BookingPassController.php` | Pass management endpoints |
| `app/Modules/SportsActivity/Controllers/PoolTicketController.php` | Pool ticket endpoints |
| `app/Modules/SportsActivity/Views/mirror/discipline_view.php` | Multi-facility discipline view |
| `app/Modules/SportsActivity/Views/bookings/passes.php` | Booking passes view/print |
| `app/Modules/SportsActivity/Views/pool-tickets/index.php` | Pool tickets list |
| `app/Modules/SportsActivity/Views/pool-tickets/issue.php` | Issue ticket form |
### Existing Files to Modify
| File | Gaps |
|------|------|
| `SaConstants.php` | 2, 5, 6, 7 |
| `EnrollmentService.php` | 4, 5 |
| `GroupTransferService.php` | 5 |
| `GroupController.php` | 5 |
| `BookingService.php` | 2, 7 |
| `PricingCalculatorService.php` | 2 |
| `BookingWizardController.php` | 2, 7 |
| `MirrorController.php` | 1 |
| `MirrorStateService.php` | 1, 7, 8 |
| `PoolGridService.php` | 6 |
| `PoolGridTemplateService.php` | 6 |
| `RegistrationWizardController.php` | 3, 4 |
| `RegistrationWizardService.php` | 3, 4 |
| `GateAccessService.php` | 7 |
| `ServiceDeskController.php` | 6 |
| `FacilityUnitController.php` (or FacilityController) | 7A |
| `bootstrap.php` | 5, 6 |
| `Routes.php` | 1, 2, 5, 6, 7 |
| `Views/mirror/index.php` | 1 |
| `Views/mirror/facility_view.php` | 8 |
| `Views/registration/wizard.php` | 3 |
| `Views/groups/show.php` | 5 |
| `Views/bookings/wizard.php` | 2, 7 |
| `Views/bookings/show.php` | 7 |
| `Views/pool-grid/*.php` | 6 |
| `Views/facilities/` (unit forms) | 7A |
---
## Risk / Edge Cases
1. **Auto-group has no schedule**: If source group has no `sa_group_schedule` entries, new group won't have any either. Warn coach.
2. **Force-enroll audit**: Add `force_enrolled = 1` flag on `sa_group_players` for tracking.
3. **Pool ticket occupancy drift**: Cron job to reconcile `current_occupancy` against active tickets daily.
4. **Old wizard registrations**: Existing in-progress registrations use old flow. `determineStep()` must handle NULL `discipline_id` gracefully.
5. **Multi-facility mirror performance**: Lazy-load per facility via AJAX or limit per page.
6. **Pass expiry**: Passes should auto-expire when booking time window ends. Cron or check-on-scan.
7. **Expected capacity override**: User can book with MORE or FEWER than expected capacity. System warns but doesn't block. Only generates passes equal to actual `participant_count` entered (which defaults to `expected_capacity`).
8. **Gate without passes**: If booking uses `participant_mode = 'full'` (names registered), gate validates by player card/NID against `sa_booking_participants`. No passes needed.
9. **Organization + passes**: An org booking for a 5-a-side pitch still gets 10 passes. The org contact is the booker, but the 10 people entering are different. System must support this.
10. **Mixed mode**: A booking starts with passes, but later some participants' names are collected at the gate. Consider allowing pass → name upgrade (optional, not blocking).
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