Commit b49f5cac authored by Mahmoud Aglan's avatar Mahmoud Aglan

Test

parent c674d6f6
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Controllers\Api;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
class ActivityBrowserApiController extends Controller
{
public function disciplines(Request $request): Response
{
$db = App::getInstance()->db();
$disciplines = $db->select(
"SELECT d.id, d.code, d.name_ar, d.icon, d.category,
(SELECT COUNT(*) FROM sa_programs p WHERE p.discipline_id = d.id AND p.is_archived = 0) as program_count
FROM sa_disciplines d
WHERE d.is_active = 1 AND d.is_archived = 0
ORDER BY d.sort_order ASC, d.name_ar ASC",
[]
);
return $this->json(['success' => true, 'disciplines' => $disciplines]);
}
public function programs(Request $request): Response
{
$disciplineId = (int) $request->get('discipline_id', 0);
if ($disciplineId === 0) {
return $this->json(['success' => false, 'error' => 'discipline_id مطلوب']);
}
$db = App::getInstance()->db();
$programs = $db->select(
"SELECT p.id, p.code, p.name_ar, p.program_type, p.skill_level,
p.age_from, p.age_to, p.gender_restriction, p.sessions_per_week,
p.session_duration_minutes,
(SELECT COUNT(*) FROM sa_groups g WHERE g.program_id = p.id AND g.is_archived = 0 AND g.status = 'active') as group_count
FROM sa_programs p
WHERE p.discipline_id = ? AND p.is_archived = 0
ORDER BY p.name_ar ASC",
[$disciplineId]
);
return $this->json(['success' => true, 'programs' => $programs]);
}
public function groups(Request $request): Response
{
$programId = (int) $request->get('program_id', 0);
if ($programId === 0) {
return $this->json(['success' => false, 'error' => 'program_id مطلوب']);
}
$db = App::getInstance()->db();
$groups = $db->select(
"SELECT g.id, g.code, g.name_ar, g.current_count, g.max_capacity, g.status,
g.monthly_fee_member, g.monthly_fee_nonmember,
c.full_name_ar as coach_name
FROM sa_groups g
LEFT JOIN sa_coaches c ON c.id = g.coach_id
WHERE g.program_id = ? AND g.is_archived = 0
ORDER BY g.name_ar ASC",
[$programId]
);
return $this->json(['success' => true, 'groups' => $groups]);
}
public function programsByDiscipline(Request $request): Response
{
$disciplineId = (int) $request->get('discipline_id', 0);
$db = App::getInstance()->db();
$where = "p.is_archived = 0";
$params = [];
if ($disciplineId > 0) {
$where .= " AND p.discipline_id = ?";
$params[] = $disciplineId;
}
$programs = $db->select(
"SELECT p.id, p.name_ar, p.age_from, p.age_to, p.sessions_per_week,
p.gender_restriction, p.session_duration_minutes
FROM sa_programs p
WHERE {$where}
ORDER BY p.name_ar ASC",
$params
);
return $this->json(['success' => true, 'programs' => $programs]);
}
public function coachesByDiscipline(Request $request): Response
{
$disciplineId = (int) $request->get('discipline_id', 0);
$db = App::getInstance()->db();
if ($disciplineId > 0) {
$coaches = $db->select(
"SELECT c.id, c.full_name_ar as name_ar, c.gender
FROM sa_coaches c
INNER JOIN sa_coach_disciplines cd ON cd.coach_id = c.id AND cd.discipline_id = ?
WHERE c.is_archived = 0
ORDER BY c.full_name_ar ASC",
[$disciplineId]
);
} else {
$coaches = $db->select(
"SELECT id, full_name_ar as name_ar, gender FROM sa_coaches WHERE is_archived = 0 ORDER BY full_name_ar ASC",
[]
);
}
return $this->json(['success' => true, 'coaches' => $coaches]);
}
}
...@@ -6,6 +6,7 @@ namespace App\Modules\SportsActivity\Controllers\Api; ...@@ -6,6 +6,7 @@ namespace App\Modules\SportsActivity\Controllers\Api;
use App\Core\Controller; 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\Modules\SportsActivity\Services\PoolGridService; use App\Modules\SportsActivity\Services\PoolGridService;
class PoolGridApiController extends Controller class PoolGridApiController extends Controller
...@@ -97,6 +98,107 @@ class PoolGridApiController extends Controller ...@@ -97,6 +98,107 @@ class PoolGridApiController extends Controller
return $this->json($result, $result['success'] ? 200 : 422); return $this->json($result, $result['success'] ? 200 : 422);
} }
public function preview(Request $request, string $id): Response
{
$date = $request->get('date', date('Y-m-d'));
$slotsJson = $request->get('slots', '');
$cellsJson = $request->get('cells', '');
$action = $request->get('action', 'training');
$slots = json_decode($slotsJson, true) ?: [];
$cells = json_decode($cellsJson, true) ?: [];
if (empty($slots) || empty($cells)) {
return $this->json(['success' => false, 'error' => 'بيانات ناقصة']);
}
$db = App::getInstance()->db();
$conflicts = 0;
$free = 0;
$details = [];
foreach ($slots as $slot) {
$startTime = $slot['start_time'] ?? '';
$endTime = $slot['end_time'] ?? '';
foreach ($cells as $cell) {
$row = (int) ($cell['row'] ?? $cell[0] ?? 0);
$col = (int) ($cell['col'] ?? $cell[1] ?? 0);
$existing = $db->selectOne(
"SELECT id FROM sa_pool_zone_bookings WHERE facility_id = ? AND booking_date = ? AND start_time = ? AND zone_row = ? AND zone_col = ? AND status = 'active'",
[(int) $id, $date, $startTime, $row, $col]
);
if ($existing) {
$conflicts++;
$details[] = ['row' => $row, 'col' => $col, 'slot' => $startTime, 'status' => 'conflict'];
} else {
$free++;
$details[] = ['row' => $row, 'col' => $col, 'slot' => $startTime, 'status' => 'free'];
}
}
}
return $this->json([
'success' => true,
'total' => $conflicts + $free,
'free' => $free,
'conflicts' => $conflicts,
'details' => $details,
]);
}
public function quickRepeat(Request $request, string $id): Response
{
$body = $request->jsonBody();
$date = trim((string) ($body['date'] ?? ''));
$endDate = trim((string) ($body['end_date'] ?? ''));
$action = trim((string) ($body['action'] ?? ''));
$cells = $body['cells'] ?? [];
$slots = $body['slots'] ?? [];
$groupId = !empty($body['group_id']) ? (int) $body['group_id'] : null;
if (!$date || !$endDate || !$action || empty($cells) || empty($slots)) {
return $this->json(['success' => false, 'error' => 'بيانات ناقصة']);
}
$startTs = strtotime($date);
$endTs = strtotime($endDate);
$maxDays = 60;
if ($endTs <= $startTs) {
return $this->json(['success' => false, 'error' => 'تاريخ النهاية يجب أن يكون بعد تاريخ البداية']);
}
if (($endTs - $startTs) / 86400 > $maxDays) {
$endTs = $startTs + ($maxDays * 86400);
}
$dayOfWeek = (int) date('w', $startTs);
$totalAssigned = 0;
$totalSkipped = 0;
$weeks = 0;
$current = $startTs;
while ($current <= $endTs) {
if ((int) date('w', $current) === $dayOfWeek) {
$dateStr = date('Y-m-d', $current);
$result = PoolGridService::bulkAssign((int) $id, $dateStr, $slots, $action, $cells, $groupId, null);
if ($result['success']) {
$totalAssigned += (int) ($result['assigned'] ?? 0);
$totalSkipped += (int) ($result['skipped'] ?? 0);
}
$weeks++;
}
$current += 86400;
}
return $this->json([
'success' => true,
'assigned' => $totalAssigned,
'skipped' => $totalSkipped,
'weeks' => $weeks,
]);
}
public function copySlot(Request $request, string $id): Response public function copySlot(Request $request, string $id): Response
{ {
$body = $request->jsonBody(); $body = $request->jsonBody();
......
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Controllers\Api;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\SportsActivity\SaConstants;
class SubscriptionPreviewApiController extends Controller
{
public function preview(Request $request): Response
{
$yearMonth = $request->get('month', date('Y-m', strtotime('+1 month')));
if (!preg_match('/^\d{4}-\d{2}$/', $yearMonth)) {
return $this->json(['success' => false, 'error' => 'صيغة الشهر غير صالحة']);
}
$db = App::getInstance()->db();
$periodStart = $yearMonth . '-01';
$groupPlayers = $db->select(
"SELECT gp.player_id, gp.group_id, gp.enrolled_at, gp.paused_months,
sp.player_type, sp.full_name_ar as player_name,
g.monthly_fee_member, g.monthly_fee_nonmember, g.name_ar as group_name
FROM sa_group_players gp
JOIN sa_players sp ON sp.id = gp.player_id
JOIN sa_groups g ON g.id = gp.group_id
WHERE gp.status = ?
AND g.status = ? AND g.is_archived = 0",
[SaConstants::STATUS_ACTIVE, SaConstants::GROUP_ACTIVE]
);
$existingSubs = $db->select(
"SELECT player_id, group_id FROM sa_subscriptions WHERE period_start = ?",
[$periodStart]
);
$existingSet = [];
foreach ($existingSubs as $es) {
$existingSet[$es['player_id'] . ':' . $es['group_id']] = true;
}
$items = [];
$exceptions = [];
$totalAmount = 0.0;
foreach ($groupPlayers as $gp) {
$key = $gp['player_id'] . ':' . $gp['group_id'];
if (isset($existingSet[$key])) {
$exceptions[] = [
'player_name' => $gp['player_name'],
'group_name' => $gp['group_name'],
'reason' => 'اشتراك موجود بالفعل',
];
continue;
}
$pausedMonths = json_decode($gp['paused_months'] ?? '[]', true) ?: [];
if (in_array($yearMonth, $pausedMonths, true)) {
$exceptions[] = [
'player_name' => $gp['player_name'],
'group_name' => $gp['group_name'],
'reason' => 'مؤجل',
];
continue;
}
$amount = $gp['player_type'] === SaConstants::PLAYER_MEMBER
? (float) $gp['monthly_fee_member']
: (float) $gp['monthly_fee_nonmember'];
$isFirstMonth = !$db->selectOne(
"SELECT id FROM sa_subscriptions WHERE player_id = ? AND group_id = ? AND period_start < ? LIMIT 1",
[(int) $gp['player_id'], (int) $gp['group_id'], $periodStart]
);
$prorated = false;
if ($isFirstMonth && !empty($gp['enrolled_at'])) {
$enrollmentDay = (int) date('j', strtotime($gp['enrolled_at']));
if ($enrollmentDay > 15) {
$amount = round($amount / 2, 2);
$prorated = true;
}
}
$items[] = [
'player_id' => (int) $gp['player_id'],
'player_name' => $gp['player_name'],
'group_id' => (int) $gp['group_id'],
'group_name' => $gp['group_name'],
'amount' => $amount,
'prorated' => $prorated,
'paused' => false,
];
$totalAmount += $amount;
}
return $this->json([
'success' => true,
'month' => $yearMonth,
'items' => $items,
'total_amount' => $totalAmount,
'count' => count($items),
'exceptions' => $exceptions,
]);
}
public function pause(Request $request): Response
{
$body = $request->jsonBody();
$playerId = (int) ($body['player_id'] ?? 0);
$groupId = (int) ($body['group_id'] ?? 0);
$month = trim((string) ($body['month'] ?? ''));
if ($playerId === 0 || $groupId === 0 || !preg_match('/^\d{4}-\d{2}$/', $month)) {
return $this->json(['success' => false, 'error' => 'بيانات ناقصة']);
}
$db = App::getInstance()->db();
$gp = $db->selectOne(
"SELECT id, paused_months FROM sa_group_players WHERE player_id = ? AND group_id = ? AND status = ?",
[$playerId, $groupId, SaConstants::STATUS_ACTIVE]
);
if (!$gp) {
return $this->json(['success' => false, 'error' => 'اللاعب غير مسجل في هذه المجموعة']);
}
$paused = json_decode($gp['paused_months'] ?? '[]', true) ?: [];
if (!in_array($month, $paused, true)) {
$paused[] = $month;
$db->update('sa_group_players', [
'paused_months' => json_encode($paused),
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $gp['id']]);
}
return $this->json(['success' => true, 'paused_months' => $paused]);
}
public function unpause(Request $request): Response
{
$body = $request->jsonBody();
$playerId = (int) ($body['player_id'] ?? 0);
$groupId = (int) ($body['group_id'] ?? 0);
$month = trim((string) ($body['month'] ?? ''));
if ($playerId === 0 || $groupId === 0 || !preg_match('/^\d{4}-\d{2}$/', $month)) {
return $this->json(['success' => false, 'error' => 'بيانات ناقصة']);
}
$db = App::getInstance()->db();
$gp = $db->selectOne(
"SELECT id, paused_months FROM sa_group_players WHERE player_id = ? AND group_id = ? AND status = ?",
[$playerId, $groupId, SaConstants::STATUS_ACTIVE]
);
if (!$gp) {
return $this->json(['success' => false, 'error' => 'اللاعب غير مسجل في هذه المجموعة']);
}
$paused = json_decode($gp['paused_months'] ?? '[]', true) ?: [];
$paused = array_values(array_filter($paused, fn($m) => $m !== $month));
$db->update('sa_group_players', [
'paused_months' => json_encode($paused),
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $gp['id']]);
return $this->json(['success' => true, 'paused_months' => $paused]);
}
}
...@@ -200,11 +200,25 @@ class CoachController extends Controller ...@@ -200,11 +200,25 @@ class CoachController extends Controller
$employmentTypes = Coach::getEmploymentTypeOptions(); $employmentTypes = Coach::getEmploymentTypeOptions();
$paymentModels = Coach::getPaymentModelOptions(); $paymentModels = Coach::getPaymentModelOptions();
$weeklySchedule = $db->select(
"SELECT gs.day_of_week, gs.start_time, gs.end_time,
g.id as group_id, g.name_ar as group_name,
fu.name_ar as unit_name, f.name_ar as facility_name
FROM sa_group_schedule gs
INNER JOIN sa_groups g ON g.id = gs.group_id AND g.status = 'active' AND g.is_archived = 0
INNER JOIN sa_facility_units fu ON fu.id = gs.facility_unit_id
INNER JOIN sa_facilities f ON f.id = fu.facility_id
WHERE g.coach_id = ? AND gs.is_active = 1
ORDER BY gs.day_of_week ASC, gs.start_time ASC",
[(int) $id]
);
return $this->view('SportsActivity.Views.coaches.show', [ return $this->view('SportsActivity.Views.coaches.show', [
'coach' => $coach, 'coach' => $coach,
'disciplines' => $disciplines, 'disciplines' => $disciplines,
'employmentTypes' => $employmentTypes, 'employmentTypes' => $employmentTypes,
'paymentModels' => $paymentModels, 'paymentModels' => $paymentModels,
'weeklySchedule' => $weeklySchedule,
]); ]);
} }
......
...@@ -62,6 +62,24 @@ class DisciplineController extends Controller ...@@ -62,6 +62,24 @@ class DisciplineController extends Controller
]); ]);
} }
public function activityBrowser(Request $request): Response
{
$db = App::getInstance()->db();
$disciplines = $db->select(
"SELECT d.id, d.code, d.name_ar, d.icon, d.category,
(SELECT COUNT(*) FROM sa_programs p WHERE p.discipline_id = d.id AND p.is_archived = 0) as program_count
FROM sa_disciplines d
WHERE d.is_active = 1 AND d.is_archived = 0
ORDER BY d.sort_order ASC, d.name_ar ASC",
[]
);
return $this->view('SportsActivity.Views.activities.browser', [
'disciplines' => $disciplines,
]);
}
/** /**
* Show the create discipline form. * Show the create discipline form.
*/ */
......
...@@ -180,15 +180,29 @@ class FacilityController extends Controller ...@@ -180,15 +180,29 @@ class FacilityController extends Controller
$discipline = $db->selectOne("SELECT * FROM sa_disciplines WHERE id = ?", [(int) $facility->discipline_id]); $discipline = $db->selectOne("SELECT * FROM sa_disciplines WHERE id = ?", [(int) $facility->discipline_id]);
} }
$trainingGroups = $db->select(
"SELECT DISTINCT g.id, g.name_ar, g.code, c.full_name_ar as coach_name,
GROUP_CONCAT(DISTINCT CONCAT(gs.day_of_week, '|', gs.start_time, '-', gs.end_time) SEPARATOR ',') as schedule_info
FROM sa_group_schedule gs
INNER JOIN sa_groups g ON g.id = gs.group_id AND g.status = 'active' AND g.is_archived = 0
LEFT JOIN sa_coaches c ON c.id = g.coach_id
WHERE gs.facility_unit_id IN (SELECT id FROM sa_facility_units WHERE facility_id = ?)
AND gs.is_active = 1
GROUP BY g.id, g.name_ar, g.code, c.full_name_ar
ORDER BY g.name_ar ASC",
[(int) $id]
);
return $this->view('SportsActivity.Views.facilities.show', [ return $this->view('SportsActivity.Views.facilities.show', [
'facility' => $facility, 'facility' => $facility,
'units' => $units, 'units' => $units,
'brackets' => $brackets, 'brackets' => $brackets,
'discipline' => $discipline, 'discipline' => $discipline,
'typeOptions' => Facility::getTypeOptions(), 'trainingGroups' => $trainingGroups,
'bracketTypes' => TimeBracket::getBracketTypeOptions(), 'typeOptions' => Facility::getTypeOptions(),
'bracketTypes' => TimeBracket::getBracketTypeOptions(),
'unitTypeOptions' => FacilityUnit::getUnitTypeOptions(), 'unitTypeOptions' => FacilityUnit::getUnitTypeOptions(),
'bookingModes' => FacilityUnit::getBookingModeOptions(), 'bookingModes' => FacilityUnit::getBookingModeOptions(),
]); ]);
} }
......
...@@ -60,6 +60,8 @@ class GateController extends Controller ...@@ -60,6 +60,8 @@ class GateController extends Controller
$accessPoint $accessPoint
); );
$sessionInfo = GateAccessService::getPlayerSessionToday((int) $card['player_id']);
return $this->json([ return $this->json([
'success' => true, 'success' => true,
'granted' => true, 'granted' => true,
...@@ -69,6 +71,8 @@ class GateController extends Controller ...@@ -69,6 +71,8 @@ class GateController extends Controller
'card_type' => $result['card_type'], 'card_type' => $result['card_type'],
'valid_until' => $result['valid_until'], 'valid_until' => $result['valid_until'],
'photo_path' => $card['photo_path'] ?? null, 'photo_path' => $card['photo_path'] ?? null,
'has_session_today' => $sessionInfo['has_session_today'],
'session_info' => $sessionInfo['session_info'],
]); ]);
} }
......
...@@ -8,6 +8,7 @@ use App\Core\Request; ...@@ -8,6 +8,7 @@ use App\Core\Request;
use App\Core\Response; use App\Core\Response;
use App\Core\App; use App\Core\App;
use App\Core\Pagination; use App\Core\Pagination;
use App\Modules\SportsActivity\Models\Discipline;
use App\Modules\SportsActivity\Models\Group; use App\Modules\SportsActivity\Models\Group;
use App\Modules\SportsActivity\Models\Program; use App\Modules\SportsActivity\Models\Program;
use App\Modules\SportsActivity\Services\EnrollmentService; use App\Modules\SportsActivity\Services\EnrollmentService;
...@@ -83,12 +84,14 @@ class GroupController extends Controller ...@@ -83,12 +84,14 @@ class GroupController extends Controller
public function create(Request $request): Response public function create(Request $request): Response
{ {
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$disciplines = Discipline::getActive();
$programs = Program::getActive(); $programs = Program::getActive();
$coaches = $db->select("SELECT id, full_name_ar as name_ar FROM sa_coaches WHERE is_archived = 0 ORDER BY full_name_ar", []); $coaches = $db->select("SELECT id, full_name_ar as name_ar FROM sa_coaches WHERE is_archived = 0 ORDER BY full_name_ar", []);
return $this->view('SportsActivity.Views.groups.create', [ return $this->view('SportsActivity.Views.groups.create', [
'programs' => $programs, 'disciplines' => $disciplines,
'coaches' => $coaches, 'programs' => $programs,
'coaches' => $coaches,
]); ]);
} }
...@@ -229,13 +232,21 @@ class GroupController extends Controller ...@@ -229,13 +232,21 @@ class GroupController extends Controller
} }
$db = App::getInstance()->db(); $db = App::getInstance()->db();
$disciplines = Discipline::getActive();
$programs = Program::getActive(); $programs = Program::getActive();
$coaches = $db->select("SELECT id, full_name_ar as name_ar FROM sa_coaches WHERE is_archived = 0 ORDER BY full_name_ar", []); $coaches = $db->select("SELECT id, full_name_ar as name_ar FROM sa_coaches WHERE is_archived = 0 ORDER BY full_name_ar", []);
$disciplineId = $db->selectOne(
"SELECT discipline_id FROM sa_programs WHERE id = ?",
[(int) $group->program_id]
);
return $this->view('SportsActivity.Views.groups.edit', [ return $this->view('SportsActivity.Views.groups.edit', [
'group' => $group, 'group' => $group,
'programs' => $programs, 'disciplines' => $disciplines,
'coaches' => $coaches, 'programs' => $programs,
'coaches' => $coaches,
'disciplineId' => $disciplineId ? (int) $disciplineId['discipline_id'] : null,
]); ]);
} }
...@@ -434,6 +445,13 @@ class GroupController extends Controller ...@@ -434,6 +445,13 @@ class GroupController extends Controller
], 'group_id = ? AND is_active = 1', [(int) $id]); ], 'group_id = ? AND is_active = 1', [(int) $id]);
} }
// Auto-generate training sessions for next 4 weeks
if ($inserted > 0) {
$fromDate = date('Y-m-d');
$toDate = date('Y-m-d', strtotime('+4 weeks'));
ScheduleGeneratorService::generateForGroup((int) $id, $fromDate, $toDate);
}
return $this->redirect('/sa/groups/' . $id)->withSuccess("تم حفظ الجدول ({$inserted} حصة)"); return $this->redirect('/sa/groups/' . $id)->withSuccess("تم حفظ الجدول ({$inserted} حصة)");
} }
......
...@@ -253,12 +253,49 @@ class PlayerController extends Controller ...@@ -253,12 +253,49 @@ class PlayerController extends Controller
[(int) $id] [(int) $id]
); );
$enrolledGroups = $db->select(
"SELECT gp.status as enrollment_status, gp.joined_at,
g.id as group_id, g.name_ar as group_name, g.code as group_code,
p.name_ar as program_name, c.full_name_ar as coach_name
FROM sa_group_players gp
INNER JOIN sa_groups g ON g.id = gp.group_id
LEFT JOIN sa_programs p ON p.id = g.program_id
LEFT JOIN sa_coaches c ON c.id = g.coach_id
WHERE gp.player_id = ? AND gp.status IN ('active', 'pending_payment')
ORDER BY gp.joined_at DESC",
[(int) $id]
);
$recentBookings = $db->select(
"SELECT b.booking_date, b.start_time, b.end_time, b.booking_type, b.status,
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.player_id = ?
ORDER BY b.booking_date DESC, b.start_time DESC
LIMIT 20",
[(int) $id]
);
$gateHistory = $db->select(
"SELECT ga.scan_time, ga.access_granted, ga.gate_name, ga.denial_reason
FROM sa_gate_access_log ga
WHERE ga.player_id = ?
ORDER BY ga.scan_time DESC
LIMIT 20",
[(int) $id]
);
return $this->view('SportsActivity.Views.players.show', [ return $this->view('SportsActivity.Views.players.show', [
'player' => $player, 'player' => $player,
'documents' => $documents, 'documents' => $documents,
'medicalHistory' => $medicalHistory, 'medicalHistory' => $medicalHistory,
'canApproveMedical' => $canApproveMedical, 'canApproveMedical' => $canApproveMedical,
'unpaidSubs' => $unpaidSubs, 'unpaidSubs' => $unpaidSubs,
'enrolledGroups' => $enrolledGroups,
'recentBookings' => $recentBookings,
'gateHistory' => $gateHistory,
]); ]);
} }
......
...@@ -57,11 +57,17 @@ class PoolGridController extends Controller ...@@ -57,11 +57,17 @@ class PoolGridController extends Controller
[] []
); );
$allPools = $db->select(
"SELECT id, name_ar FROM sa_facilities WHERE is_active = 1 AND is_archived = 0 AND facility_type = 'pool' ORDER BY name_ar",
[]
);
return $this->view('SportsActivity.Views.pool-grid.manage', [ return $this->view('SportsActivity.Views.pool-grid.manage', [
'facility' => $facility, 'facility' => $facility,
'slots' => $slots, 'slots' => $slots,
'groups' => $groups, 'groups' => $groups,
'date' => $date, 'date' => $date,
'allPools' => $allPools,
]); ]);
} }
} }
<?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\Members\Services\MembershipValidationService;
use App\Modules\Carnets\Models\CarnetGuestEntry;
use App\Modules\Carnets\Services\GuestEntryService;
class ServiceDeskController extends Controller
{
public function index(Request $request): Response
{
$this->authorize('sa.registration.manage');
$activityTypes = CarnetGuestEntry::getActivityTypes();
$db = App::getInstance()->db();
$facilities = $db->select(
"SELECT id, name_ar FROM sa_facilities WHERE is_active = 1 AND is_archived = 0 ORDER BY name_ar ASC",
[]
);
return $this->view('SportsActivity.Views.service-desk.index', [
'activityTypes' => $activityTypes,
'facilities' => $facilities,
]);
}
public function lookup(Request $request): Response
{
$this->authorize('sa.registration.manage');
$nationalId = trim((string) $request->post('national_id', ''));
if ($nationalId === '') {
return $this->json(['success' => false, 'error' => 'الرقم القومي مطلوب']);
}
$result = MembershipValidationService::checkByNationalId($nationalId);
$db = App::getInstance()->db();
$player = $db->selectOne(
"SELECT id, full_name_ar, registration_serial, player_type FROM sa_players WHERE national_id = ? LIMIT 1",
[$nationalId]
);
$carnet = null;
if (!empty($result['member_id'])) {
$carnet = $db->selectOne(
"SELECT c.id, c.max_invitations,
(SELECT COUNT(*) FROM carnet_guest_entries cge WHERE cge.carnet_id = c.id AND cge.status != 'cancelled') as used_count
FROM carnets c
WHERE c.member_id = ? AND c.status = 'active'
ORDER BY c.created_at DESC LIMIT 1",
[(int) $result['member_id']]
);
}
return $this->json([
'success' => true,
'membership' => $result,
'player' => $player,
'carnet' => $carnet,
]);
}
public function issueTicket(Request $request): Response
{
$this->authorize('sa.registration.manage');
$activityType = trim((string) $request->post('activity_type', ''));
$guestName = trim((string) $request->post('guest_name', ''));
$guestCount = max(1, (int) $request->post('guest_count', 1));
$amountPaid = (float) $request->post('amount_paid', 0);
$facilityId = $request->post('facility_id', '') !== '' ? (int) $request->post('facility_id') : null;
$carnetId = $request->post('carnet_id', '') !== '' ? (int) $request->post('carnet_id') : null;
$memberId = $request->post('member_id', '') !== '' ? (int) $request->post('member_id') : null;
$notes = trim((string) $request->post('notes', ''));
if ($activityType === '' || !array_key_exists($activityType, CarnetGuestEntry::getActivityTypes())) {
return $this->json(['success' => false, 'error' => 'نوع النشاط غير صالح']);
}
if ($guestName === '') {
return $this->json(['success' => false, 'error' => 'اسم الضيف مطلوب']);
}
$employee = App::getInstance()->currentEmployee();
if ($carnetId) {
$eligibilityErrors = GuestEntryService::checkEligibility($carnetId);
if (!empty($eligibilityErrors)) {
return $this->json(['success' => false, 'error' => implode(' | ', $eligibilityErrors)]);
}
}
$entry = GuestEntryService::recordEntry([
'carnet_id' => $carnetId,
'member_id' => $memberId,
'guest_name' => $guestName,
'guest_phone' => trim((string) $request->post('guest_phone', '')),
'guest_national_id' => '',
'guest_count' => $guestCount,
'entry_date' => date('Y-m-d'),
'entry_time' => date('H:i:s'),
'facility_id' => $facilityId,
'activity_type' => $activityType,
'amount_paid' => $amountPaid,
'notes' => $notes,
'recorded_by' => $employee ? (int) $employee->id : null,
'status' => 'active',
]);
return $this->json([
'success' => true,
'entry' => $entry,
'message' => 'تم إصدار التذكرة بنجاح',
]);
}
}
...@@ -188,6 +188,10 @@ return [ ...@@ -188,6 +188,10 @@ return [
['POST', '/api/sa/pool-grid/{id:\d+}/update-grid', 'SportsActivity\Controllers\Api\PoolGridApiController@updateGrid', ['auth', 'csrf'], 'sa.pool-grid.manage'], ['POST', '/api/sa/pool-grid/{id:\d+}/update-grid', 'SportsActivity\Controllers\Api\PoolGridApiController@updateGrid', ['auth', 'csrf'], 'sa.pool-grid.manage'],
['POST', '/api/sa/pool-grid/{id:\d+}/copy-slot', 'SportsActivity\Controllers\Api\PoolGridApiController@copySlot', ['auth', 'csrf'], 'sa.pool-grid.manage'], ['POST', '/api/sa/pool-grid/{id:\d+}/copy-slot', 'SportsActivity\Controllers\Api\PoolGridApiController@copySlot', ['auth', 'csrf'], 'sa.pool-grid.manage'],
// Pool Grid Preview + Quick Repeat
['GET', '/api/sa/pool-grid/{id:\d+}/preview', 'SportsActivity\Controllers\Api\PoolGridApiController@preview', ['auth'], 'sa.pool-grid.manage'],
['POST', '/api/sa/pool-grid/{id:\d+}/quick-repeat', 'SportsActivity\Controllers\Api\PoolGridApiController@quickRepeat', ['auth', 'csrf'], 'sa.pool-grid.manage'],
// Pool Grid Templates // Pool Grid Templates
['GET', '/api/sa/pool-grid/{id:\d+}/templates', 'SportsActivity\Controllers\Api\PoolGridTemplateApiController@list', ['auth'], 'sa.pool-grid.manage'], ['GET', '/api/sa/pool-grid/{id:\d+}/templates', 'SportsActivity\Controllers\Api\PoolGridTemplateApiController@list', ['auth'], 'sa.pool-grid.manage'],
['POST', '/api/sa/pool-grid/{id:\d+}/templates', 'SportsActivity\Controllers\Api\PoolGridTemplateApiController@store', ['auth', 'csrf'], 'sa.pool-grid.manage'], ['POST', '/api/sa/pool-grid/{id:\d+}/templates', 'SportsActivity\Controllers\Api\PoolGridTemplateApiController@store', ['auth', 'csrf'], 'sa.pool-grid.manage'],
...@@ -226,6 +230,26 @@ return [ ...@@ -226,6 +230,26 @@ 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'],
// ─── Activity Browser ───────────────────────────────────────────────────────
['GET', '/sa/activities', 'SportsActivity\Controllers\DisciplineController@activityBrowser', ['auth'], 'sa.discipline.view'],
// ─── Service Desk ───────────────────────────────────────────────────────────
['GET', '/sa/service-desk', 'SportsActivity\Controllers\ServiceDeskController@index', ['auth'], 'sa.registration.manage'],
['POST', '/sa/service-desk/lookup', 'SportsActivity\Controllers\ServiceDeskController@lookup', ['auth', 'csrf'], 'sa.registration.manage'],
['POST', '/sa/service-desk/ticket', 'SportsActivity\Controllers\ServiceDeskController@issueTicket', ['auth', 'csrf'], 'sa.registration.manage'],
// ─── Activity Browser APIs ──────────────────────────────────────────────────
['GET', '/api/sa/activities/programs', 'SportsActivity\Controllers\Api\ActivityBrowserApiController@programs', ['auth'], 'sa.discipline.view'],
['GET', '/api/sa/activities/groups', 'SportsActivity\Controllers\Api\ActivityBrowserApiController@groups', ['auth'], 'sa.discipline.view'],
['GET', '/api/sa/activities/disciplines', 'SportsActivity\Controllers\Api\ActivityBrowserApiController@disciplines', ['auth'], 'sa.discipline.view'],
['GET', '/api/sa/activities/programs-by-discipline','SportsActivity\Controllers\Api\ActivityBrowserApiController@programsByDiscipline', ['auth'], 'sa.discipline.view'],
['GET', '/api/sa/activities/coaches-by-discipline', 'SportsActivity\Controllers\Api\ActivityBrowserApiController@coachesByDiscipline', ['auth'], 'sa.discipline.view'],
// ─── Subscription Preview APIs ──────────────────────────────────────────────
['GET', '/api/sa/subscriptions/preview', 'SportsActivity\Controllers\Api\SubscriptionPreviewApiController@preview', ['auth'], 'sa.subscription.view'],
['POST', '/api/sa/subscriptions/pause', 'SportsActivity\Controllers\Api\SubscriptionPreviewApiController@pause', ['auth', 'csrf'], 'sa.subscription.generate'],
['POST', '/api/sa/subscriptions/unpause', 'SportsActivity\Controllers\Api\SubscriptionPreviewApiController@unpause', ['auth', 'csrf'], 'sa.subscription.generate'],
// ─── Academy Pricing ──────────────────────────────────────────────────────── // ─── Academy Pricing ────────────────────────────────────────────────────────
['GET', '/sa/academy-pricing', 'SportsActivity\Controllers\AcademyPricingController@index', ['auth'], 'sa.pricing.view'], ['GET', '/sa/academy-pricing', 'SportsActivity\Controllers\AcademyPricingController@index', ['auth'], 'sa.pricing.view'],
['GET', '/sa/academy-pricing/academies', 'SportsActivity\Controllers\AcademyPricingController@academies', ['auth'], 'sa.pricing.view'], ['GET', '/sa/academy-pricing/academies', 'SportsActivity\Controllers\AcademyPricingController@academies', ['auth'], 'sa.pricing.view'],
......
...@@ -161,6 +161,11 @@ final class EnrollmentService ...@@ -161,6 +161,11 @@ final class EnrollmentService
} }
$db->commit(); $db->commit();
if ($wasActive) {
WaitlistAutoOfferService::offerNextInLine($groupId);
}
return ['success' => true, 'new_count' => $newCount]; return ['success' => true, 'new_count' => $newCount];
} catch (\Throwable $e) { } catch (\Throwable $e) {
$db->rollBack(); $db->rollBack();
......
...@@ -211,6 +211,68 @@ final class GateAccessService ...@@ -211,6 +211,68 @@ final class GateAccessService
]; ];
} }
public static function getPlayerSessionToday(int $playerId): array
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
$dayOfWeek = (int) date('w');
$now = date('H:i:s');
$booking = $db->selectOne(
"SELECT b.start_time, b.end_time, g.name_ar as group_name, fu.name_ar as unit_name
FROM sa_bookings b
INNER JOIN sa_groups g ON g.id = b.group_id
INNER JOIN sa_facility_units fu ON fu.id = b.facility_unit_id
INNER JOIN sa_group_players gp ON gp.group_id = b.group_id AND gp.player_id = ? AND gp.status = 'active'
WHERE b.booking_date = ? AND b.status = 'confirmed' AND b.booking_type = 'training'
AND b.end_time > ?
ORDER BY b.start_time ASC
LIMIT 1",
[$playerId, $today, $now]
);
if ($booking) {
return [
'has_session_today' => true,
'session_info' => [
'group_name' => $booking['group_name'],
'start_time' => substr($booking['start_time'], 0, 5),
'end_time' => substr($booking['end_time'], 0, 5),
'unit_name' => $booking['unit_name'],
'source' => 'booking',
],
];
}
$schedule = $db->selectOne(
"SELECT gs.start_time, gs.end_time, g.name_ar as group_name, fu.name_ar as unit_name
FROM sa_group_schedule gs
INNER JOIN sa_groups g ON g.id = gs.group_id
INNER JOIN sa_facility_units fu ON fu.id = gs.facility_unit_id
INNER JOIN sa_group_players gp ON gp.group_id = gs.group_id AND gp.player_id = ? AND gp.status = 'active'
WHERE gs.day_of_week = ? AND gs.is_active = 1
AND gs.end_time > ?
ORDER BY gs.start_time ASC
LIMIT 1",
[$playerId, $dayOfWeek, $now]
);
if ($schedule) {
return [
'has_session_today' => true,
'session_info' => [
'group_name' => $schedule['group_name'],
'start_time' => substr($schedule['start_time'], 0, 5),
'end_time' => substr($schedule['end_time'], 0, 5),
'unit_name' => $schedule['unit_name'],
'source' => 'schedule',
],
];
}
return ['has_session_today' => false, 'session_info' => null];
}
private static function extractCardNumber(?string $scannedData): ?string private static function extractCardNumber(?string $scannedData): ?string
{ {
if ($scannedData === null || $scannedData === '') { if ($scannedData === null || $scannedData === '') {
......
...@@ -38,6 +38,87 @@ final class MirrorStateService ...@@ -38,6 +38,87 @@ final class MirrorStateService
[$facilityId, $date] [$facilityId, $date]
); );
// Also fetch group schedule entries for today (fallback for ungenerated sessions)
$dayOfWeek = (int) date('w', strtotime($date));
$scheduleEntries = $db->select(
"SELECT gs.facility_unit_id, gs.start_time, gs.end_time, gs.group_id,
g.name_ar as group_name, c.full_name_ar as coach_name
FROM sa_group_schedule gs
INNER JOIN sa_groups g ON g.id = gs.group_id AND g.status = 'active'
LEFT JOIN sa_coaches c ON c.id = g.coach_id
WHERE gs.facility_unit_id IN (SELECT id FROM sa_facility_units WHERE facility_id = ?)
AND gs.day_of_week = ? AND gs.is_active = 1",
[$facilityId, $dayOfWeek]
);
foreach ($scheduleEntries as $se) {
$hasMatchingBooking = false;
foreach ($bookings as $b) {
if ((int) $b['facility_unit_id'] === (int) $se['facility_unit_id']
&& $b['start_time'] === $se['start_time']
&& !empty($b['group_id']) && (int) $b['group_id'] === (int) $se['group_id']) {
$hasMatchingBooking = true;
break;
}
}
if (!$hasMatchingBooking) {
$bookings[] = [
'id' => null,
'facility_unit_id' => $se['facility_unit_id'],
'booking_type' => 'training',
'booking_date' => $date,
'start_time' => $se['start_time'],
'end_time' => $se['end_time'],
'group_id' => $se['group_id'],
'group_name' => $se['group_name'],
'coach_name' => $se['coach_name'],
'status' => 'planned',
'spots_reserved' => 1,
'unit_name' => null,
'booking_mode' => null,
'max_capacity' => null,
];
}
}
if ($facility['facility_type'] === 'pool') {
$poolZoneBookings = $db->select(
"SELECT pzb.start_time, pzb.end_time, pzb.status as pzb_status, pzb.action,
pzb.zone_row, pzb.zone_col, g.name_ar as group_name, c.full_name_ar as coach_name
FROM sa_pool_zone_bookings pzb
LEFT JOIN sa_groups g ON g.id = pzb.group_id
LEFT JOIN sa_coaches c ON c.id = g.coach_id
WHERE pzb.facility_id = ? AND pzb.booking_date = ? AND pzb.status = 'active'
ORDER BY pzb.start_time",
[$facilityId, $date]
);
$unitsBySort = array_values($units);
foreach ($poolZoneBookings as $pzb) {
$unitIndex = (int) $pzb['zone_row'];
if (!isset($unitsBySort[$unitIndex])) {
continue;
}
$mappedUnitId = (int) $unitsBySort[$unitIndex]['id'];
$bookings[] = [
'id' => null,
'facility_unit_id' => $mappedUnitId,
'booking_type' => $pzb['action'] ?: 'training',
'booking_date' => $date,
'start_time' => $pzb['start_time'],
'end_time' => $pzb['end_time'],
'group_id' => null,
'group_name' => $pzb['group_name'],
'coach_name' => $pzb['coach_name'],
'status' => 'pool_zone',
'spots_reserved' => 1,
'unit_name' => null,
'booking_mode' => null,
'max_capacity' => null,
];
}
}
$hours = json_decode($facility['operating_hours_json'], true) ?: ['start' => '06:00', 'end' => '22:00', 'slot_minutes' => 60]; $hours = json_decode($facility['operating_hours_json'], true) ?: ['start' => '06:00', 'end' => '22:00', 'slot_minutes' => 60];
$slots = self::buildTimeSlots($hours['start'], $hours['end'], (int) $hours['slot_minutes']); $slots = self::buildTimeSlots($hours['start'], $hours['end'], (int) $hours['slot_minutes']);
......
...@@ -148,7 +148,7 @@ final class PoolGridService ...@@ -148,7 +148,7 @@ final class PoolGridService
$label = $notes ?: 'حجز ساعة'; $label = $notes ?: 'حجز ساعة';
} }
// Pre-fetch all active bookings for this facility+date to avoid per-cell SELECT // Pre-fetch all active pool zone bookings for this facility+date
$allConflicts = $db->select( $allConflicts = $db->select(
"SELECT zone_row, zone_col, start_time FROM sa_pool_zone_bookings "SELECT zone_row, zone_col, start_time FROM sa_pool_zone_bookings
WHERE facility_id = ? AND booking_date = ? AND status = 'active'", WHERE facility_id = ? AND booking_date = ? AND status = 'active'",
...@@ -159,6 +159,19 @@ final class PoolGridService ...@@ -159,6 +159,19 @@ final class PoolGridService
$conflictSet[$c['start_time'] . ':' . $c['zone_row'] . ':' . $c['zone_col']] = true; $conflictSet[$c['start_time'] . ':' . $c['zone_row'] . ':' . $c['zone_col']] = true;
} }
// Cross-check sa_bookings for this pool facility on the same date
$bookingConflicts = $db->select(
"SELECT b.start_time, b.end_time FROM sa_bookings b
INNER JOIN sa_facility_units fu ON fu.id = b.facility_unit_id
WHERE fu.facility_id = ? AND b.booking_date = ?
AND b.status NOT IN ('cancelled', 'no_show')",
[$facilityId, $date]
);
$bookingTimeRanges = [];
foreach ($bookingConflicts as $bc) {
$bookingTimeRanges[] = ['start' => $bc['start_time'], 'end' => $bc['end_time']];
}
$assigned = 0; $assigned = 0;
$skipped = 0; $skipped = 0;
...@@ -185,6 +198,21 @@ final class PoolGridService ...@@ -185,6 +198,21 @@ final class PoolGridService
continue; continue;
} }
// For hourly assignments, cross-check against regular bookings
if ($action === 'hourly' && !empty($bookingTimeRanges)) {
$hasBookingConflict = false;
foreach ($bookingTimeRanges as $btr) {
if ($startTimeFull < $btr['end'] && $endTimeFull > $btr['start']) {
$hasBookingConflict = true;
break;
}
}
if ($hasBookingConflict) {
$skipped++;
continue;
}
}
$insertData = [ $insertData = [
'facility_id' => $facilityId, 'facility_id' => $facilityId,
'booking_date' => $date, 'booking_date' => $date,
......
...@@ -40,6 +40,32 @@ final class SlotAvailabilityService ...@@ -40,6 +40,32 @@ final class SlotAvailabilityService
return ['available' => false, 'reason' => 'المرفق محجوب في هذا التوقيت', 'remaining' => 0]; return ['available' => false, 'reason' => 'المرفق محجوب في هذا التوقيت', 'remaining' => 0];
} }
// Cross-check pool zone bookings for pool-type facilities
$facilityType = $db->selectOne(
"SELECT facility_type FROM sa_facilities WHERE id = ?",
[(int) $unit['fac_id']]
);
if ($facilityType && $facilityType['facility_type'] === 'pool') {
$poolZoneBlocked = (int) ($db->selectOne(
"SELECT COUNT(*) as cnt FROM sa_pool_zone_bookings
WHERE facility_id = ? AND booking_date = ? AND status = 'active'
AND assignment_type IN ('blocked', 'maintenance', 'training')
AND start_time < ? AND end_time > ?",
[(int) $unit['fac_id'], $date, $endTime, $startTime]
)['cnt'] ?? 0);
if ($poolZoneBlocked > 0) {
$totalLanes = (int) ($db->selectOne(
"SELECT pool_grid_cols FROM sa_facilities WHERE id = ?",
[(int) $unit['fac_id']]
)['pool_grid_cols'] ?? 0);
if ($totalLanes > 0 && $poolZoneBlocked >= $totalLanes) {
return ['available' => false, 'reason' => 'جميع حارات الحمام مشغولة في هذا التوقيت (شبكة الحمام)', 'remaining' => 0];
}
}
}
$excludeClause = $excludeBookingId ? " AND id != ?" : ""; $excludeClause = $excludeBookingId ? " AND id != ?" : "";
$params = [$facilityUnitId, $date, $endTime, $startTime]; $params = [$facilityUnitId, $date, $endTime, $startTime];
if ($excludeBookingId) { if ($excludeBookingId) {
......
...@@ -16,9 +16,9 @@ final class SubscriptionGeneratorService ...@@ -16,9 +16,9 @@ final class SubscriptionGeneratorService
$periodEnd = date('Y-m-t', strtotime($periodStart)); $periodEnd = date('Y-m-t', strtotime($periodStart));
$groupPlayers = $db->select( $groupPlayers = $db->select(
"SELECT gp.player_id, gp.group_id, gp.enrolled_at, "SELECT gp.player_id, gp.group_id, gp.enrolled_at, gp.paused_months,
sp.player_type, sp.player_type, sp.full_name_ar as player_name,
g.monthly_fee_member, g.monthly_fee_nonmember g.monthly_fee_member, g.monthly_fee_nonmember, g.name_ar as group_name
FROM sa_group_players gp FROM sa_group_players gp
JOIN sa_players sp ON sp.id = gp.player_id JOIN sa_players sp ON sp.id = gp.player_id
JOIN sa_groups g ON g.id = gp.group_id JOIN sa_groups g ON g.id = gp.group_id
...@@ -47,6 +47,12 @@ final class SubscriptionGeneratorService ...@@ -47,6 +47,12 @@ final class SubscriptionGeneratorService
continue; continue;
} }
$pausedMonths = json_decode($gp['paused_months'] ?? '[]', true) ?: [];
if (in_array($yearMonth, $pausedMonths, true)) {
$skipped++;
continue;
}
$amount = $gp['player_type'] === SaConstants::PLAYER_MEMBER $amount = $gp['player_type'] === SaConstants::PLAYER_MEMBER
? (float) $gp['monthly_fee_member'] ? (float) $gp['monthly_fee_member']
: (float) $gp['monthly_fee_nonmember']; : (float) $gp['monthly_fee_nonmember'];
......
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Core\EventBus;
final class WaitlistAutoOfferService
{
public static function offerNextInLine(int $groupId): ?array
{
$db = App::getInstance()->db();
$group = $db->selectOne(
"SELECT id, name_ar, current_count, max_capacity FROM sa_groups WHERE id = ? AND status = 'active'",
[$groupId]
);
if (!$group) {
return null;
}
if ((int) $group['current_count'] >= (int) $group['max_capacity']) {
return null;
}
$next = $db->selectOne(
"SELECT w.*, p.full_name_ar, p.phone, p.guardian_phone
FROM sa_waitlist w
INNER JOIN sa_players p ON p.id = w.player_id
WHERE w.group_id = ? AND w.status = 'waiting'
ORDER BY w.position ASC, w.created_at ASC
LIMIT 1",
[$groupId]
);
if (!$next) {
return null;
}
$now = date('Y-m-d H:i:s');
$expiresAt = date('Y-m-d H:i:s', strtotime('+48 hours'));
$db->update('sa_waitlist', [
'status' => 'offered',
'offered_at' => $now,
'expires_at' => $expiresAt,
'updated_at' => $now,
], 'id = ?', [(int) $next['id']]);
$phone = $next['guardian_phone'] ?: ($next['phone'] ?? '');
EventBus::dispatch('sa.waitlist.offer', [
'waitlist_id' => (int) $next['id'],
'player_id' => (int) $next['player_id'],
'player_name' => $next['full_name_ar'],
'group_id' => $groupId,
'group_name' => $group['name_ar'],
'phone' => $phone,
'expires_at' => $expiresAt,
]);
return [
'offered_to' => (int) $next['player_id'],
'player_name' => $next['full_name_ar'],
'waitlist_id' => (int) $next['id'],
'expires_at' => $expiresAt,
];
}
public static function acceptOffer(int $waitlistId): array
{
$db = App::getInstance()->db();
$entry = $db->selectOne(
"SELECT * FROM sa_waitlist WHERE id = ? AND status = 'offered'",
[$waitlistId]
);
if (!$entry) {
return ['success' => false, 'error' => 'العرض غير موجود أو منتهي'];
}
if ($entry['expires_at'] && $entry['expires_at'] < date('Y-m-d H:i:s')) {
$db->update('sa_waitlist', [
'status' => 'expired', 'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$waitlistId]);
return ['success' => false, 'error' => 'انتهت صلاحية العرض'];
}
$db->update('sa_waitlist', [
'status' => 'accepted',
'accepted_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$waitlistId]);
$result = EnrollmentService::enroll((int) $entry['group_id'], (int) $entry['player_id']);
return $result;
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>متصفح الأنشطة<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div style="display:grid;grid-template-columns:280px 1fr 1fr;gap:16px;height:calc(100vh - 180px);min-height:500px;">
<!-- Right Panel: Disciplines -->
<div class="card" style="overflow-y:auto;padding:0;">
<div style="padding:12px 16px;border-bottom:1px solid #E5E7EB;position:sticky;top:0;background:#fff;z-index:1;">
<h3 style="margin:0;font-size:14px;font-weight:700;color:#1A1A2E;">الألعاب الرياضية</h3>
</div>
<div id="disciplineList" style="padding:8px;">
<?php foreach ($disciplines as $d): ?>
<div class="discipline-item" data-id="<?= (int) $d['id'] ?>" style="padding:10px 12px;border-radius:8px;cursor:pointer;display:flex;align-items:center;gap:10px;margin-bottom:4px;transition:background .15s;">
<div style="width:36px;height:36px;border-radius:10px;background:#F3F4F6;display:flex;align-items:center;justify-content:center;flex-shrink:0;">
<i data-lucide="<?= e($d['icon'] ?? 'activity') ?>" style="width:18px;height:18px;color:#0D7377;"></i>
</div>
<div style="flex:1;min-width:0;">
<div style="font-size:13px;font-weight:600;color:#1A1A2E;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;"><?= e($d['name_ar']) ?></div>
<div style="font-size:11px;color:#9CA3AF;"><?= (int) $d['program_count'] ?> برنامج</div>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<!-- Middle Panel: Programs -->
<div class="card" style="overflow-y:auto;padding:0;">
<div style="padding:12px 16px;border-bottom:1px solid #E5E7EB;position:sticky;top:0;background:#fff;z-index:1;">
<h3 style="margin:0;font-size:14px;font-weight:700;color:#1A1A2E;" id="programsTitle">البرامج</h3>
</div>
<div id="programList" style="padding:12px;">
<div style="text-align:center;padding:40px 20px;color:#9CA3AF;font-size:13px;">
<i data-lucide="mouse-pointer-click" style="width:32px;height:32px;display:block;margin:0 auto 10px;color:#D1D5DB;"></i>
اختر نشاط رياضي من القائمة
</div>
</div>
</div>
<!-- Left Panel: Groups -->
<div class="card" style="overflow-y:auto;padding:0;">
<div style="padding:12px 16px;border-bottom:1px solid #E5E7EB;position:sticky;top:0;background:#fff;z-index:1;">
<h3 style="margin:0;font-size:14px;font-weight:700;color:#1A1A2E;" id="groupsTitle">المجموعات</h3>
</div>
<div id="groupList" style="padding:12px;">
<div style="text-align:center;padding:40px 20px;color:#9CA3AF;font-size:13px;">
<i data-lucide="users" style="width:32px;height:32px;display:block;margin:0 auto 10px;color:#D1D5DB;"></i>
اختر برنامج لعرض مجموعاته
</div>
</div>
</div>
</div>
<style>
.discipline-item:hover, .discipline-item.active { background: #EFF6FF; }
.discipline-item.active { border-right: 3px solid #2563EB; }
.program-card { padding:12px;border:1px solid #E5E7EB;border-radius:8px;cursor:pointer;margin-bottom:8px;transition:border-color .15s,box-shadow .15s; }
.program-card:hover, .program-card.active { border-color:#2563EB;box-shadow:0 0 0 2px rgba(37,99,235,.1); }
.group-card { padding:12px;border:1px solid #E5E7EB;border-radius:8px;margin-bottom:8px; }
.capacity-bar { height:6px;border-radius:3px;background:#E5E7EB;overflow:hidden;margin-top:6px; }
.capacity-fill { height:100%;border-radius:3px;transition:width .3s; }
</style>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons();
var items = document.querySelectorAll('.discipline-item');
items.forEach(function(item) {
item.addEventListener('click', function() {
items.forEach(function(i) { i.classList.remove('active'); });
this.classList.add('active');
loadPrograms(this.dataset.id, this.querySelector('div > div').textContent.trim());
});
});
function loadPrograms(disciplineId, name) {
document.getElementById('programsTitle').textContent = 'برامج: ' + name;
document.getElementById('programList').innerHTML = '<div style="text-align:center;padding:20px;color:#6B7280;font-size:13px;">جاري التحميل...</div>';
document.getElementById('groupList').innerHTML = '<div style="text-align:center;padding:40px 20px;color:#9CA3AF;font-size:13px;">اختر برنامج لعرض مجموعاته</div>';
document.getElementById('groupsTitle').textContent = 'المجموعات';
fetch('/api/sa/activities/programs?discipline_id=' + disciplineId)
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success || !data.programs.length) {
document.getElementById('programList').innerHTML = '<div style="text-align:center;padding:20px;color:#9CA3AF;font-size:13px;">لا توجد برامج</div>';
return;
}
var html = '';
data.programs.forEach(function(p) {
var meta = [];
if (p.age_from || p.age_to) meta.push((p.age_from||'?') + '-' + (p.age_to||'?') + ' سنة');
if (p.sessions_per_week) meta.push(p.sessions_per_week + ' حصة/أسبوع');
html += '<div class="program-card" data-id="' + p.id + '">' +
'<div style="font-size:13px;font-weight:600;color:#1A1A2E;">' + escHtml(p.name_ar) + '</div>' +
'<div style="font-size:11px;color:#6B7280;margin-top:4px;">' + escHtml(meta.join(' • ')) + '</div>' +
'<div style="font-size:11px;color:#2563EB;margin-top:4px;">' + (p.group_count || 0) + ' مجموعة</div>' +
'</div>';
});
document.getElementById('programList').innerHTML = html;
document.querySelectorAll('.program-card').forEach(function(card) {
card.addEventListener('click', function() {
document.querySelectorAll('.program-card').forEach(function(c) { c.classList.remove('active'); });
this.classList.add('active');
loadGroups(this.dataset.id, this.querySelector('div').textContent.trim());
});
});
});
}
function loadGroups(programId, name) {
document.getElementById('groupsTitle').textContent = 'مجموعات: ' + name;
document.getElementById('groupList').innerHTML = '<div style="text-align:center;padding:20px;color:#6B7280;font-size:13px;">جاري التحميل...</div>';
fetch('/api/sa/activities/groups?program_id=' + programId)
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success || !data.groups.length) {
document.getElementById('groupList').innerHTML = '<div style="text-align:center;padding:20px;color:#9CA3AF;font-size:13px;">لا توجد مجموعات</div>';
return;
}
var html = '';
data.groups.forEach(function(g) {
var pct = g.max_capacity > 0 ? Math.round((g.current_count / g.max_capacity) * 100) : 0;
var barColor = pct >= 90 ? '#DC2626' : (pct >= 70 ? '#F59E0B' : '#059669');
var statusBadge = g.status === 'active' ? '<span style="color:#059669;font-size:10px;">● نشطة</span>' : '<span style="color:#6B7280;font-size:10px;">● ' + escHtml(g.status) + '</span>';
html += '<div class="group-card">' +
'<div style="display:flex;justify-content:space-between;align-items:center;">' +
'<a href="/sa/groups/' + g.id + '" style="font-size:13px;font-weight:600;color:#1A1A2E;text-decoration:none;">' + escHtml(g.name_ar) + '</a>' +
statusBadge + '</div>' +
'<div style="font-size:11px;color:#6B7280;margin-top:4px;">المدرب: ' + escHtml(g.coach_name || '—') + '</div>' +
'<div style="font-size:11px;color:#6B7280;margin-top:2px;">الرسوم: ' + (g.monthly_fee_member || 0) + ' / ' + (g.monthly_fee_nonmember || 0) + ' ج.م</div>' +
'<div class="capacity-bar"><div class="capacity-fill" style="width:' + pct + '%;background:' + barColor + ';"></div></div>' +
'<div style="font-size:10px;color:#6B7280;margin-top:3px;">' + g.current_count + ' / ' + g.max_capacity + ' (' + pct + '%)</div>' +
'</div>';
});
document.getElementById('groupList').innerHTML = html;
});
}
function escHtml(str) {
var div = document.createElement('div');
div.textContent = str || '';
return div.innerHTML;
}
});
</script>
<?php $__template->endSection(); ?>
...@@ -194,6 +194,42 @@ $isActive = (int) ($coach['is_active'] ?? 0); ...@@ -194,6 +194,42 @@ $isActive = (int) ($coach['is_active'] ?? 0);
</div> </div>
</div> </div>
<!-- Weekly Schedule -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="calendar" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">جدول المدرب الأسبوعي</h3>
<span style="margin-right:auto;background:#EFF6FF;color:#2563EB;padding:2px 8px;border-radius:8px;font-size:11px;"><?= count($weeklySchedule) ?> حصة</span>
</div>
<?php if (!empty($weeklySchedule)): ?>
<?php $dayNames = ['الأحد', 'الاثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت']; ?>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#F9FAFB;">
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">اليوم</th>
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">الوقت</th>
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">المجموعة</th>
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">المرفق</th>
</tr>
</thead>
<tbody>
<?php foreach ($weeklySchedule as $ws): ?>
<tr style="border-top:1px solid #F3F4F6;">
<td style="padding:10px 12px;font-size:13px;font-weight:600;"><?= e($dayNames[(int)$ws['day_of_week']] ?? '') ?></td>
<td style="padding:10px 12px;font-size:13px;direction:ltr;text-align:right;"><?= e($ws['start_time']) ?> - <?= e($ws['end_time']) ?></td>
<td style="padding:10px 12px;"><a href="/sa/groups/<?= (int) $ws['group_id'] ?>" style="color:#2563EB;font-size:13px;text-decoration:none;"><?= e($ws['group_name']) ?></a></td>
<td style="padding:10px 12px;font-size:12px;color:#6B7280;"><?= e($ws['facility_name']) ?><?= e($ws['unit_name']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<p style="color:#9CA3AF;text-align:center;padding:20px 0;margin:0;font-size:13px;">لا يوجد جدول تدريب مسجل لهذا المدرب</p>
<?php endif; ?>
</div>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') { if (typeof lucide !== 'undefined') {
......
...@@ -143,6 +143,51 @@ ...@@ -143,6 +143,51 @@
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- Training Groups at this Facility -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="users" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">المجموعات المتدربة هنا</h3>
<span style="margin-right:auto;background:#EFF6FF;color:#2563EB;padding:2px 8px;border-radius:8px;font-size:11px;"><?= count($trainingGroups) ?></span>
</div>
<?php if (!empty($trainingGroups)): ?>
<?php
$dayNames = ['الأحد', 'الاثنين', 'الثلاثاء', 'الأربعاء', 'الخميس', 'الجمعة', 'السبت'];
?>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#F9FAFB;">
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">المجموعة</th>
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">المدرب</th>
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">المواعيد</th>
</tr>
</thead>
<tbody>
<?php foreach ($trainingGroups as $tg): ?>
<tr style="border-top:1px solid #F3F4F6;">
<td style="padding:10px 12px;"><a href="/sa/groups/<?= (int) $tg['id'] ?>" style="color:#2563EB;font-size:13px;font-weight:600;text-decoration:none;"><?= e($tg['name_ar']) ?></a></td>
<td style="padding:10px 12px;font-size:13px;color:#374151;"><?= e($tg['coach_name'] ?? '—') ?></td>
<td style="padding:10px 12px;font-size:12px;color:#6B7280;">
<?php
$parts = explode(',', $tg['schedule_info'] ?? '');
foreach ($parts as $part):
$split = explode('|', $part);
if (count($split) === 2):
?>
<span style="display:inline-block;background:#F3F4F6;padding:2px 6px;border-radius:4px;margin:1px 2px;font-size:11px;"><?= e($dayNames[(int)$split[0]] ?? '') ?> <?= e($split[1]) ?></span>
<?php endif; endforeach; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<p style="color:#9CA3AF;text-align:center;padding:20px 0;margin:0;font-size:13px;">لا توجد مجموعات تتدرب في هذه المنشأة حالياً</p>
<?php endif; ?>
</div>
<!-- Blackout Date Form --> <!-- Blackout Date Form -->
<div class="card" style="padding:20px;"> <div class="card" style="padding:20px;">
<h4 style="margin:0 0 15px;color:#1F2937;">إضافة تاريخ إيقاف</h4> <h4 style="margin:0 0 15px;color:#1F2937;">إضافة تاريخ إيقاف</h4>
......
...@@ -106,10 +106,18 @@ document.addEventListener('DOMContentLoaded', function() { ...@@ -106,10 +106,18 @@ document.addEventListener('DOMContentLoaded', function() {
if (res.granted) { if (res.granted) {
scanResult.style.background = '#ECFDF5'; scanResult.style.background = '#ECFDF5';
scanResult.style.border = '2px solid #059669'; scanResult.style.border = '2px solid #059669';
var sessionHtml = '';
if (res.has_session_today && res.session_info) {
var si = res.session_info;
var sourceLabel = si.source === 'schedule' ? ' (مخطط)' : '';
sessionHtml = '<div style="margin-top:10px;padding:8px 12px;background:#DBEAFE;border-radius:8px;font-size:12px;color:#1E40AF;">' +
'<strong>جلسة اليوم:</strong> ' + si.group_name + ' — ' + si.start_time + ' إلى ' + si.end_time +
' — ' + si.unit_name + sourceLabel + '</div>';
}
scanResult.innerHTML = '<div style="font-size:40px;margin-bottom:8px;">✓</div>' + scanResult.innerHTML = '<div style="font-size:40px;margin-bottom:8px;">✓</div>' +
'<div style="font-size:18px;font-weight:700;color:#059669;">مسموح بالدخول</div>' + '<div style="font-size:18px;font-weight:700;color:#059669;">مسموح بالدخول</div>' +
'<div style="margin-top:8px;font-size:14px;color:#1A1A2E;">' + (res.player_name || '') + '</div>' + '<div style="margin-top:8px;font-size:14px;color:#1A1A2E;">' + (res.player_name || '') + '</div>' +
'<div style="font-size:12px;color:#6B7280;">' + (res.card_number || '') + '</div>'; '<div style="font-size:12px;color:#6B7280;">' + (res.card_number || '') + '</div>' + sessionHtml;
} else { } else {
scanResult.style.background = '#FEF2F2'; scanResult.style.background = '#FEF2F2';
scanResult.style.border = '2px solid #DC2626'; scanResult.style.border = '2px solid #DC2626';
......
...@@ -31,10 +31,20 @@ ...@@ -31,10 +31,20 @@
<input type="text" name="name_en" value="<?= e(old('name_en')) ?>" class="form-input" maxlength="300" placeholder="e.g. Swimming Group A" style="direction:ltr;text-align:left;"> <input type="text" name="name_en" value="<?= e(old('name_en')) ?>" class="form-input" maxlength="300" placeholder="e.g. Swimming Group A" style="direction:ltr;text-align:left;">
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:15px;"> <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;margin-top:15px;">
<div class="form-group">
<label class="form-label">النشاط الرياضي</label>
<select id="discipline_id" class="form-select">
<option value="">-- كل الأنشطة --</option>
<?php foreach ($disciplines as $d): ?>
<option value="<?= (int) $d['id'] ?>"><?= e($d['name_ar']) ?></option>
<?php endforeach; ?>
</select>
<small style="color:#6B7280;font-size:11px;margin-top:4px;display:block;">اختياري — لتصفية البرامج والمدربين</small>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">البرنامج <span style="color:#DC2626;">*</span></label> <label class="form-label">البرنامج <span style="color:#DC2626;">*</span></label>
<select name="program_id" class="form-select" required> <select name="program_id" id="program_id" class="form-select" required>
<option value="">-- اختر البرنامج --</option> <option value="">-- اختر البرنامج --</option>
<?php foreach ($programs as $p): ?> <?php foreach ($programs as $p): ?>
<option value="<?= (int) $p['id'] ?>" <?= old('program_id', $_GET['program_id'] ?? '') == $p['id'] ? 'selected' : '' ?>><?= e($p['name_ar']) ?></option> <option value="<?= (int) $p['id'] ?>" <?= old('program_id', $_GET['program_id'] ?? '') == $p['id'] ? 'selected' : '' ?>><?= e($p['name_ar']) ?></option>
...@@ -43,7 +53,7 @@ ...@@ -43,7 +53,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">المدرب <span style="color:#DC2626;">*</span></label> <label class="form-label">المدرب <span style="color:#DC2626;">*</span></label>
<select name="coach_id" class="form-select" required> <select name="coach_id" id="coach_id" class="form-select" required>
<option value="">-- اختر المدرب --</option> <option value="">-- اختر المدرب --</option>
<?php foreach ($coaches as $c): ?> <?php foreach ($coaches as $c): ?>
<option value="<?= (int) $c['id'] ?>" <?= old('coach_id') == $c['id'] ? 'selected' : '' ?>><?= e($c['name_ar']) ?></option> <option value="<?= (int) $c['id'] ?>" <?= old('coach_id') == $c['id'] ? 'selected' : '' ?>><?= e($c['name_ar']) ?></option>
...@@ -114,6 +124,47 @@ ...@@ -114,6 +124,47 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons(); if (typeof lucide !== 'undefined') lucide.createIcons();
var disciplineEl = document.getElementById('discipline_id');
var programEl = document.getElementById('program_id');
var coachEl = document.getElementById('coach_id');
var allPrograms = <?= json_encode(array_map(fn($p) => ['id' => (int)$p['id'], 'name_ar' => $p['name_ar']], $programs)) ?>;
var allCoaches = <?= json_encode(array_map(fn($c) => ['id' => (int)$c['id'], 'name_ar' => $c['name_ar']], $coaches)) ?>;
disciplineEl.addEventListener('change', function() {
var did = this.value;
if (!did) {
rebuildSelect(programEl, allPrograms, '');
rebuildSelect(coachEl, allCoaches, '');
return;
}
fetch('/api/sa/activities/programs-by-discipline?discipline_id=' + did)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) rebuildSelect(programEl, data.programs, '');
});
fetch('/api/sa/activities/coaches-by-discipline?discipline_id=' + did)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) rebuildSelect(coachEl, data.coaches, '');
});
});
function rebuildSelect(el, items, selectedId) {
var placeholder = el === programEl ? '-- اختر البرنامج --' : '-- اختر المدرب --';
var html = '<option value="">' + placeholder + '</option>';
items.forEach(function(item) {
var sel = String(item.id) === String(selectedId) ? ' selected' : '';
html += '<option value="' + item.id + '"' + sel + '>' + escHtml(item.name_ar) + '</option>';
});
el.innerHTML = html;
}
function escHtml(str) {
var d = document.createElement('div');
d.textContent = str || '';
return d.innerHTML;
}
}); });
</script> </script>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
...@@ -31,10 +31,20 @@ ...@@ -31,10 +31,20 @@
<input type="text" name="name_en" value="<?= e(old('name_en', $group->name_en ?? '')) ?>" class="form-input" maxlength="300" style="direction:ltr;text-align:left;"> <input type="text" name="name_en" value="<?= e(old('name_en', $group->name_en ?? '')) ?>" class="form-input" maxlength="300" style="direction:ltr;text-align:left;">
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;margin-top:15px;"> <div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;margin-top:15px;">
<div class="form-group">
<label class="form-label">النشاط الرياضي</label>
<select id="discipline_id" class="form-select">
<option value="">-- كل الأنشطة --</option>
<?php foreach ($disciplines as $d): ?>
<option value="<?= (int) $d['id'] ?>" <?= ($disciplineId ?? null) == $d['id'] ? 'selected' : '' ?>><?= e($d['name_ar']) ?></option>
<?php endforeach; ?>
</select>
<small style="color:#6B7280;font-size:11px;margin-top:4px;display:block;">اختياري — لتصفية البرامج والمدربين</small>
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">البرنامج <span style="color:#DC2626;">*</span></label> <label class="form-label">البرنامج <span style="color:#DC2626;">*</span></label>
<select name="program_id" class="form-select" required> <select name="program_id" id="program_id" class="form-select" required>
<option value="">-- اختر البرنامج --</option> <option value="">-- اختر البرنامج --</option>
<?php foreach ($programs as $p): ?> <?php foreach ($programs as $p): ?>
<option value="<?= (int) $p['id'] ?>" <?= (old('program_id', $group->program_id)) == $p['id'] ? 'selected' : '' ?>><?= e($p['name_ar']) ?></option> <option value="<?= (int) $p['id'] ?>" <?= (old('program_id', $group->program_id)) == $p['id'] ? 'selected' : '' ?>><?= e($p['name_ar']) ?></option>
...@@ -43,7 +53,7 @@ ...@@ -43,7 +53,7 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">المدرب <span style="color:#DC2626;">*</span></label> <label class="form-label">المدرب <span style="color:#DC2626;">*</span></label>
<select name="coach_id" class="form-select" required> <select name="coach_id" id="coach_id" class="form-select" required>
<option value="">-- اختر المدرب --</option> <option value="">-- اختر المدرب --</option>
<?php foreach ($coaches as $c): ?> <?php foreach ($coaches as $c): ?>
<option value="<?= (int) $c['id'] ?>" <?= (old('coach_id', $group->coach_id)) == $c['id'] ? 'selected' : '' ?>><?= e($c['name_ar']) ?></option> <option value="<?= (int) $c['id'] ?>" <?= (old('coach_id', $group->coach_id)) == $c['id'] ? 'selected' : '' ?>><?= e($c['name_ar']) ?></option>
...@@ -114,6 +124,49 @@ ...@@ -114,6 +124,49 @@
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons(); if (typeof lucide !== 'undefined') lucide.createIcons();
var disciplineEl = document.getElementById('discipline_id');
var programEl = document.getElementById('program_id');
var coachEl = document.getElementById('coach_id');
var currentProgramId = '<?= (int) old('program_id', $group->program_id) ?>';
var currentCoachId = '<?= (int) old('coach_id', $group->coach_id) ?>';
var allPrograms = <?= json_encode(array_map(fn($p) => ['id' => (int)$p['id'], 'name_ar' => $p['name_ar']], $programs)) ?>;
var allCoaches = <?= json_encode(array_map(fn($c) => ['id' => (int)$c['id'], 'name_ar' => $c['name_ar']], $coaches)) ?>;
disciplineEl.addEventListener('change', function() {
var did = this.value;
if (!did) {
rebuildSelect(programEl, allPrograms, currentProgramId);
rebuildSelect(coachEl, allCoaches, currentCoachId);
return;
}
fetch('/api/sa/activities/programs-by-discipline?discipline_id=' + did)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) rebuildSelect(programEl, data.programs, currentProgramId);
});
fetch('/api/sa/activities/coaches-by-discipline?discipline_id=' + did)
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) rebuildSelect(coachEl, data.coaches, currentCoachId);
});
});
function rebuildSelect(el, items, selectedId) {
var placeholder = el === programEl ? '-- اختر البرنامج --' : '-- اختر المدرب --';
var html = '<option value="">' + placeholder + '</option>';
items.forEach(function(item) {
var sel = String(item.id) === String(selectedId) ? ' selected' : '';
html += '<option value="' + item.id + '"' + sel + '>' + escHtml(item.name_ar) + '</option>';
});
el.innerHTML = html;
}
function escHtml(str) {
var d = document.createElement('div');
d.textContent = str || '';
return d.innerHTML;
}
}); });
</script> </script>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
...@@ -330,6 +330,122 @@ $relationLabels = ['father' => 'أب', 'mother' => 'أم', 'brother' => 'أخ', ...@@ -330,6 +330,122 @@ $relationLabels = ['father' => 'أب', 'mother' => 'أم', 'brother' => 'أخ',
<?php endif; ?> <?php endif; ?>
</div> </div>
<!-- Activity Map: Enrolled Groups -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="users" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">المجموعات المسجل بها</h3>
<span style="margin-right:auto;background:#EFF6FF;color:#2563EB;padding:2px 8px;border-radius:8px;font-size:11px;"><?= count($enrolledGroups) ?></span>
</div>
<?php if (!empty($enrolledGroups)): ?>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#F9FAFB;">
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">المجموعة</th>
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">البرنامج</th>
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">المدرب</th>
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">تاريخ الانضمام</th>
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">الحالة</th>
</tr>
</thead>
<tbody>
<?php foreach ($enrolledGroups as $eg): ?>
<tr style="border-top:1px solid #F3F4F6;">
<td style="padding:10px 12px;"><a href="/sa/groups/<?= (int) $eg['group_id'] ?>" style="color:#2563EB;font-size:13px;font-weight:600;text-decoration:none;"><?= e($eg['group_name']) ?></a></td>
<td style="padding:10px 12px;font-size:13px;"><?= e($eg['program_name'] ?? '—') ?></td>
<td style="padding:10px 12px;font-size:13px;"><?= e($eg['coach_name'] ?? '—') ?></td>
<td style="padding:10px 12px;font-size:12px;color:#6B7280;"><?= e($eg['joined_at'] ?? '—') ?></td>
<td style="padding:10px 12px;">
<?php $eColor = $eg['enrollment_status'] === 'active' ? '#059669' : '#F59E0B'; ?>
<span style="padding:2px 8px;border-radius:8px;font-size:11px;background:<?= $eColor ?>15;color:<?= $eColor ?>;">
<?= $eg['enrollment_status'] === 'active' ? 'نشط' : 'في انتظار الدفع' ?>
</span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<p style="color:#9CA3AF;text-align:center;padding:20px 0;margin:0;font-size:13px;">لم يتم التسجيل في أي مجموعة بعد</p>
<?php endif; ?>
</div>
<!-- Recent Bookings -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="calendar-check" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">آخر الحجوزات</h3>
</div>
<?php if (!empty($recentBookings)): ?>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#F9FAFB;">
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">التاريخ</th>
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">الوقت</th>
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">المرفق</th>
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">النوع</th>
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">الحالة</th>
</tr>
</thead>
<tbody>
<?php foreach ($recentBookings as $rb): ?>
<tr style="border-top:1px solid #F3F4F6;">
<td style="padding:10px 12px;font-size:13px;"><?= e($rb['booking_date']) ?></td>
<td style="padding:10px 12px;font-size:12px;direction:ltr;text-align:right;"><?= e($rb['start_time']) ?> - <?= e($rb['end_time']) ?></td>
<td style="padding:10px 12px;font-size:13px;"><?= e($rb['facility_name']) ?><?= e($rb['unit_name']) ?></td>
<td style="padding:10px 12px;font-size:12px;color:#6B7280;"><?= e($rb['booking_type']) ?></td>
<td style="padding:10px 12px;font-size:12px;"><?= e($rb['status']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<p style="color:#9CA3AF;text-align:center;padding:20px 0;margin:0;font-size:13px;">لا توجد حجوزات</p>
<?php endif; ?>
</div>
<!-- Gate Access History -->
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="scan-line" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">سجل الدخول</h3>
</div>
<?php if (!empty($gateHistory)): ?>
<div style="overflow-x:auto;">
<table style="width:100%;border-collapse:collapse;">
<thead>
<tr style="background:#F9FAFB;">
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">الوقت</th>
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">البوابة</th>
<th style="padding:10px 12px;text-align:right;font-size:12px;color:#6B7280;">النتيجة</th>
</tr>
</thead>
<tbody>
<?php foreach ($gateHistory as $gh): ?>
<tr style="border-top:1px solid #F3F4F6;">
<td style="padding:10px 12px;font-size:12px;color:#6B7280;"><?= e($gh['scan_time']) ?></td>
<td style="padding:10px 12px;font-size:13px;"><?= e($gh['gate_name'] ?? '—') ?></td>
<td style="padding:10px 12px;">
<?php if ($gh['access_granted']): ?>
<span style="color:#059669;font-size:12px;">● مسموح</span>
<?php else: ?>
<span style="color:#DC2626;font-size:12px;">● مرفوض<?= $gh['denial_reason'] ? ' — ' . e($gh['denial_reason']) : '' ?></span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<p style="color:#9CA3AF;text-align:center;padding:20px 0;margin:0;font-size:13px;">لا يوجد سجل دخول</p>
<?php endif; ?>
</div>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') lucide.createIcons(); if (typeof lucide !== 'undefined') lucide.createIcons();
......
...@@ -145,6 +145,16 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0); ...@@ -145,6 +145,16 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
@keyframes pgspin{to{transform:rotate(360deg);}} @keyframes pgspin{to{transform:rotate(360deg);}}
</style> </style>
<!-- Pool Tabs -->
<?php if (!empty($allPools) && count($allPools) > 1): ?>
<div style="display:flex;gap:0;border-bottom:2px solid #E5E7EB;margin-bottom:12px;">
<?php foreach ($allPools as $pool): ?>
<a href="/sa/pool-grid/<?= (int) $pool['id'] ?>?date=<?= e($date) ?>"
style="padding:8px 16px;font-size:13px;font-weight:600;text-decoration:none;border-bottom:2px solid transparent;margin-bottom:-2px;<?= (int) $pool['id'] === (int) $facility['id'] ? 'color:#2563EB;border-bottom-color:#2563EB;' : 'color:#6B7280;' ?>"><?= e($pool['name_ar']) ?></a>
<?php endforeach; ?>
</div>
<?php endif; ?>
<!-- Date Navigation + Actions --> <!-- Date Navigation + Actions -->
<div class="pg-top"> <div class="pg-top">
<div class="date-nav"> <div class="date-nav">
...@@ -190,6 +200,7 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0); ...@@ -190,6 +200,7 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
<button class="btn-maintenance" id="pgBtnMaint" disabled><i data-lucide="wrench" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> صيانة</button> <button class="btn-maintenance" id="pgBtnMaint" disabled><i data-lucide="wrench" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> صيانة</button>
<button class="btn-copy" id="pgBtnCopy" title="نسخ من فترة أخرى"><i data-lucide="copy" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> نسخ</button> <button class="btn-copy" id="pgBtnCopy" title="نسخ من فترة أخرى"><i data-lucide="copy" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> نسخ</button>
<button class="btn-clear" id="pgBtnClear" disabled><i data-lucide="x" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> مسح</button> <button class="btn-clear" id="pgBtnClear" disabled><i data-lucide="x" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> مسح</button>
<button style="background:#E0E7FF;color:#3730A3;border-radius:6px;padding:8px 14px;font-size:13px;font-weight:500;cursor:pointer;border:none;" id="pgBtnRepeat" disabled title="تكرار أسبوعي"><i data-lucide="repeat" style="width:14px;height:14px;vertical-align:middle;margin-left:2px;"></i> تكرار أسبوعي</button>
</div> </div>
<!-- Keyboard Shortcuts --> <!-- Keyboard Shortcuts -->
...@@ -594,6 +605,7 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0); ...@@ -594,6 +605,7 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
document.getElementById('pgBtnBlocked').disabled = !has; document.getElementById('pgBtnBlocked').disabled = !has;
document.getElementById('pgBtnMaint').disabled = !has; document.getElementById('pgBtnMaint').disabled = !has;
document.getElementById('pgBtnClear').disabled = !has; document.getElementById('pgBtnClear').disabled = !has;
document.getElementById('pgBtnRepeat').disabled = !has;
gridOuter.querySelectorAll('.pg-cell').forEach(function(el){ gridOuter.querySelectorAll('.pg-cell').forEach(function(el){
el.classList.toggle('selected', selectedCells.has(el.dataset.key)); el.classList.toggle('selected', selectedCells.has(el.dataset.key));
...@@ -1049,6 +1061,56 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0); ...@@ -1049,6 +1061,56 @@ $gridCols = (int) ($facility['pool_grid_cols'] ?? 0);
function esc(s){ return s ? String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : ''; } function esc(s){ return s ? String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : ''; }
// --- Quick Repeat ---
document.getElementById('pgBtnRepeat').onclick = function(){
if(selectedCells.size === 0){ alert('حدد خلايا أولاً'); return; }
var modal = document.createElement('div');
modal.className = 'pg-modal-bg show';
modal.innerHTML = '<div class="pg-modal">' +
'<h3>تكرار أسبوعي</h3>' +
'<p style="font-size:13px;color:#6B7280;margin:0 0 12px;">سيتم تطبيق نفس التعيين كل ' + getDayName(currentDate()) + ' حتى التاريخ المحدد.</p>' +
'<label>تكرار حتى:</label>' +
'<input type="date" id="pgRepeatEndDate" min="' + currentDate() + '" value="' + addWeeks(currentDate(), 4) + '">' +
'<label>الإجراء:</label>' +
'<select id="pgRepeatAction"><option value="training">تدريب</option><option value="hourly">ساعة</option><option value="blocked">حجب</option><option value="maintenance">صيانة</option></select>' +
'<p style="font-size:12px;color:#6B7280;margin:8px 0 0;">' + selectedCells.size + ' خلية × ' + activeSlotIndices.size + ' فترة = ' + (selectedCells.size * activeSlotIndices.size) + ' مربع/أسبوع</p>' +
'<div class="m-actions"><button class="m-cancel" id="pgRepeatCancel">إلغاء</button><button class="m-confirm" id="pgRepeatConfirm">تأكيد التكرار</button></div>' +
'</div>';
document.body.appendChild(modal);
document.getElementById('pgRepeatCancel').onclick = function(){ modal.remove(); };
document.getElementById('pgRepeatConfirm').onclick = function(){
var endDate = document.getElementById('pgRepeatEndDate').value;
var action = document.getElementById('pgRepeatAction').value;
if(!endDate){ alert('حدد تاريخ النهاية'); return; }
modal.remove();
showLoading();
apiPost('/api/sa/pool-grid/' + FACILITY_ID + '/quick-repeat', {
date: currentDate(),
end_date: endDate,
action: action,
cells: buildCells(),
slots: buildSlots(),
group_id: null
}).then(function(res){
hideLoading();
if(res.error){ alert(res.error); return; }
toast('تم التكرار: ' + (res.assigned || 0) + ' مربع عبر ' + (res.weeks || 0) + ' أسبوع (تخطي: ' + (res.skipped || 0) + ')', 'success');
clearSelection();
loadState();
});
};
};
function getDayName(dateStr){
var days = ['الأحد','الاثنين','الثلاثاء','الأربعاء','الخميس','الجمعة','السبت'];
return days[new Date(dateStr).getDay()] || '';
}
function addWeeks(dateStr, w){
var d = new Date(dateStr);
d.setDate(d.getDate() + (w * 7));
return d.toISOString().split('T')[0];
}
// --- Init --- // --- Init ---
if(GRID_ROWS > 0 && GRID_COLS > 0) loadState(); if(GRID_ROWS > 0 && GRID_COLS > 0) loadState();
else renderGrid(); else renderGrid();
......
This diff is collapsed.
...@@ -10,6 +10,9 @@ ...@@ -10,6 +10,9 @@
<i data-lucide="refresh-cw" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> توليد الاشتراكات <i data-lucide="refresh-cw" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> توليد الاشتراكات
</button> </button>
</form> </form>
<button type="button" id="previewBtn" class="btn btn-outline" style="margin-right:8px;">
<i data-lucide="eye" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> معاينة الشهر القادم
</button>
<?php endif; ?> <?php endif; ?>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
...@@ -110,9 +113,96 @@ ...@@ -110,9 +113,96 @@
<?php endif; ?> <?php endif; ?>
</div> </div>
<div id="previewModal" style="position:fixed;inset:0;background:rgba(0,0,0,.4);z-index:1000;display:none;align-items:center;justify-content:center;">
<div style="background:#fff;border-radius:12px;padding:24px;width:700px;max-width:90vw;max-height:85vh;overflow-y:auto;box-shadow:0 20px 60px rgba(0,0,0,.2);">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
<h3 style="margin:0;font-size:18px;">معاينة اشتراكات الشهر القادم</h3>
<button id="previewClose" style="background:none;border:none;cursor:pointer;font-size:20px;color:#6B7280;"></button>
</div>
<div id="previewContent" style="font-size:13px;">جاري التحميل...</div>
</div>
</div>
<script> <script>
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') { lucide.createIcons(); } if (typeof lucide !== 'undefined') { lucide.createIcons(); }
var previewBtn = document.getElementById('previewBtn');
var previewModal = document.getElementById('previewModal');
var previewClose = document.getElementById('previewClose');
var previewContent = document.getElementById('previewContent');
if (previewBtn) {
previewBtn.addEventListener('click', function() {
previewModal.style.display = 'flex';
previewContent.innerHTML = '<p style="text-align:center;color:#6B7280;">جاري التحميل...</p>';
var nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
var month = nextMonth.getFullYear() + '-' + String(nextMonth.getMonth() + 1).padStart(2, '0');
fetch('/api/sa/subscriptions/preview?month=' + month)
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) { previewContent.innerHTML = '<p style="color:#DC2626;">' + (data.error || 'خطأ') + '</p>'; return; }
var html = '<div style="display:flex;gap:16px;margin-bottom:12px;flex-wrap:wrap;">';
html += '<span style="background:#D1FAE5;padding:4px 12px;border-radius:6px;font-size:12px;font-weight:600;color:#065F46;">' + data.count + ' اشتراك</span>';
html += '<span style="background:#EFF6FF;padding:4px 12px;border-radius:6px;font-size:12px;font-weight:600;color:#1D4ED8;">الإجمالي: ' + data.total_amount.toFixed(2) + ' ج.م</span>';
if (data.exceptions.length) html += '<span style="background:#FEF3C7;padding:4px 12px;border-radius:6px;font-size:12px;font-weight:600;color:#92400E;">' + data.exceptions.length + ' استثناء</span>';
html += '</div>';
if (data.items.length) {
html += '<table style="width:100%;border-collapse:collapse;font-size:12px;"><thead><tr style="background:#F9FAFB;">';
html += '<th style="padding:8px;text-align:right;">اللاعب</th><th style="padding:8px;text-align:right;">المجموعة</th><th style="padding:8px;text-align:right;">المبلغ</th><th style="padding:8px;text-align:center;">تأجيل</th>';
html += '</tr></thead><tbody>';
data.items.forEach(function(item) {
html += '<tr style="border-top:1px solid #F3F4F6;">';
html += '<td style="padding:8px;">' + esc(item.player_name) + (item.prorated ? ' <span style="color:#F59E0B;font-size:10px;">نصف</span>' : '') + '</td>';
html += '<td style="padding:8px;">' + esc(item.group_name) + '</td>';
html += '<td style="padding:8px;direction:ltr;text-align:right;">' + item.amount.toFixed(2) + '</td>';
html += '<td style="padding:8px;text-align:center;"><button class="pause-btn" data-player="' + item.player_id + '" data-group="' + item.group_id + '" data-month="' + data.month + '" style="background:#FEF3C7;border:1px solid #F59E0B;border-radius:4px;padding:2px 8px;font-size:11px;cursor:pointer;">تأجيل</button></td>';
html += '</tr>';
});
html += '</tbody></table>';
}
if (data.exceptions.length) {
html += '<h4 style="margin:16px 0 8px;font-size:13px;color:#92400E;">الاستثناءات:</h4><ul style="margin:0;padding:0 16px;">';
data.exceptions.forEach(function(ex) {
html += '<li style="font-size:12px;color:#6B7280;">' + esc(ex.player_name) + ' — ' + esc(ex.group_name) + ': ' + esc(ex.reason) + '</li>';
});
html += '</ul>';
}
previewContent.innerHTML = html;
previewContent.querySelectorAll('.pause-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var self = this;
fetch('/api/sa/subscriptions/pause', {
method: 'POST',
headers: {'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest'},
body: JSON.stringify({player_id: parseInt(self.dataset.player), group_id: parseInt(self.dataset.group), month: self.dataset.month})
})
.then(function(r) { return r.json(); })
.then(function(res) {
if (res.success) {
self.textContent = 'تم التأجيل ✓';
self.disabled = true;
self.style.background = '#D1FAE5';
self.style.borderColor = '#059669';
self.style.color = '#065F46';
}
});
});
});
});
});
}
if (previewClose) previewClose.addEventListener('click', function() { previewModal.style.display = 'none'; });
previewModal.addEventListener('click', function(e) { if (e.target === previewModal) previewModal.style.display = 'none'; });
function esc(s) { var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML; }
}); });
</script> </script>
<?php $__template->endSection(); ?> <?php $__template->endSection(); ?>
...@@ -17,7 +17,8 @@ MenuRegistry::register('sports_activity', [ ...@@ -17,7 +17,8 @@ MenuRegistry::register('sports_activity', [
'order' => 200, 'order' => 200,
'children' => [ 'children' => [
['label_ar' => 'لوحة التحكم', 'label_en' => 'Dashboard', 'route' => '/sa', 'permission' => 'sa.dashboard', 'order' => 1], ['label_ar' => 'لوحة التحكم', 'label_en' => 'Dashboard', 'route' => '/sa', 'permission' => 'sa.dashboard', 'order' => 1],
['label_ar' => 'مكتب التسجيل', 'label_en' => 'Registration', 'route' => '/sa/registration', 'permission' => 'sa.registration.manage','order' => 2], ['label_ar' => 'مكتب الخدمة', 'label_en' => 'Service Desk', 'route' => '/sa/service-desk', 'permission' => 'sa.registration.manage','order' => 2],
['label_ar' => 'متصفح الأنشطة', 'label_en' => 'Activity Browser','route' => '/sa/activities', 'permission' => 'sa.discipline.view', 'order' => 2.5],
['label_ar' => 'البوابة', 'label_en' => 'Gate', 'route' => '/sa/gate', 'permission' => 'sa.gate.view', 'order' => 3], ['label_ar' => 'البوابة', 'label_en' => 'Gate', 'route' => '/sa/gate', 'permission' => 'sa.gate.view', 'order' => 3],
['label_ar' => 'المراية', 'label_en' => 'Mirror', 'route' => '/sa/mirror', 'permission' => 'sa.mirror.view', 'order' => 4], ['label_ar' => 'المراية', 'label_en' => 'Mirror', 'route' => '/sa/mirror', 'permission' => 'sa.mirror.view', 'order' => 4],
['label_ar' => 'شبكة حمام السباحة', 'label_en' => 'Pool Grid', 'route' => '/sa/pool-grid', 'permission' => 'sa.pool-grid.manage', 'order' => 5], ['label_ar' => 'شبكة حمام السباحة', 'label_en' => 'Pool Grid', 'route' => '/sa/pool-grid', 'permission' => 'sa.pool-grid.manage', 'order' => 5],
...@@ -158,3 +159,15 @@ EventBus::listen('sa.player.absence_threshold', function (array $data): void { ...@@ -158,3 +159,15 @@ EventBus::listen('sa.player.absence_threshold', function (array $data): void {
EventBus::listen('sa.gate.access_denied', function (array $data): void { EventBus::listen('sa.gate.access_denied', function (array $data): void {
Logger::warning('SA Gate denial: ' . ($data['player_name'] ?? 'unknown') . ' - ' . ($data['reason'] ?? ''), $data); Logger::warning('SA Gate denial: ' . ($data['player_name'] ?? 'unknown') . ' - ' . ($data['reason'] ?? ''), $data);
}, 50); }, 50);
EventBus::listen('sa.waitlist.offer', function (array $data): void {
$phone = $data['phone'] ?? '';
if ($phone !== '') {
\App\Modules\Notifications\Services\SmsNotificationService::send(
$phone,
'توجد مقعد شاغر في مجموعة ' . ($data['group_name'] ?? '') .
' للاعب ' . ($data['player_name'] ?? '') .
'. الرجاء التأكيد خلال 48 ساعة عبر مكتب التسجيل.'
);
}
}, 80);
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\App;
use App\Core\Database;
use App\Core\Logger;
use App\Modules\SportsActivity\Services\ScheduleGeneratorService;
class SaSessionForwardGeneratorJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool
{
return (int) date('w') === 0;
}
public function run(): array
{
App::getInstance()->setDb($this->db);
$fromDate = date('Y-m-d');
$toDate = date('Y-m-d', strtotime('+4 weeks'));
$groups = $this->db->select(
"SELECT DISTINCT gs.group_id
FROM sa_group_schedule gs
INNER JOIN sa_groups g ON g.id = gs.group_id
WHERE gs.is_active = 1 AND g.status = 'active' AND g.is_archived = 0",
[]
);
$totalGenerated = 0;
$totalSkipped = 0;
$errors = 0;
foreach ($groups as $g) {
$result = ScheduleGeneratorService::generateForGroup((int) $g['group_id'], $fromDate, $toDate);
if ($result['success']) {
$totalGenerated += (int) ($result['generated'] ?? 0);
$totalSkipped += (int) ($result['skipped'] ?? 0);
} else {
$errors++;
}
}
Logger::info("SaSessionForwardGeneratorJob: {$totalGenerated} sessions generated, {$totalSkipped} skipped, {$errors} errors across " . count($groups) . " groups");
return [
'groups_processed' => count($groups),
'generated' => $totalGenerated,
'skipped' => $totalSkipped,
'errors' => $errors,
];
}
}
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\App;
use App\Core\Database;
use App\Core\Logger;
use App\Modules\SportsActivity\Services\WaitlistAutoOfferService;
class SaWaitlistExpiryJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool
{
return true;
}
public function run(): array
{
App::getInstance()->setDb($this->db);
$now = date('Y-m-d H:i:s');
$expired = $this->db->select(
"SELECT id, group_id FROM sa_waitlist WHERE status = 'offered' AND expires_at < ?",
[$now]
);
$expiredCount = 0;
$offeredCount = 0;
foreach ($expired as $entry) {
$this->db->update('sa_waitlist', [
'status' => 'expired',
'updated_at' => $now,
], 'id = ?', [(int) $entry['id']]);
$expiredCount++;
$offered = WaitlistAutoOfferService::offerNextInLine((int) $entry['group_id']);
if ($offered) {
$offeredCount++;
}
}
Logger::info("SaWaitlistExpiryJob: {$expiredCount} expired, {$offeredCount} new offers made");
return ['expired' => $expiredCount, 'offered' => $offeredCount];
}
}
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE `sa_group_players`
ADD COLUMN `paused_months` JSON NULL COMMENT 'Array of YYYY-MM strings where subscription generation is skipped' AFTER `transfer_reason`;
ALTER TABLE `sa_waitlist`
ADD COLUMN `accepted_at` TIMESTAMP NULL AFTER `expires_at`;
ALTER TABLE `sa_group_schedule`
ADD INDEX `idx_sa_gs_unit_day_active` (`facility_unit_id`, `day_of_week`, `is_active`);
",
'down' => "
ALTER TABLE `sa_group_players` DROP COLUMN `paused_months`;
ALTER TABLE `sa_waitlist` DROP COLUMN `accepted_at`;
ALTER TABLE `sa_group_schedule` DROP INDEX `idx_sa_gs_unit_day_active`;
",
];
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