Commit b6b61bdb authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat(sports): implement full sports module enhancement (Phases 0-10)

- Phase 0: Schema — training attendance, makeup sessions, institutions tables + sport_type on disciplines
- Phase 1: Player lifecycle — pause/resume months, medical grace enforcement with auto-suspend cron
- Phase 2: Medical grace integration — grace deadline on enrollment, extend grace endpoint
- Phase 3: Training attendance — group-session-based system (separate from booking-based sa_attendance)
- Phase 4: Make-up sessions — create from absence, schedule, complete, auto-expire cron
- Phase 5: Institution management — full CRUD with booking linkage and contract tracking
- Phase 6: Subscription renewal — medical verification flag on auto-generated subscriptions
- Phase 7: Treasury auto-sync — already existed via SaEventListenerService (verified)
- Phase 8: Recreational differentiation — sport_type enum on disciplines (training/recreational)
- Phase 9: Dashboard — added medical grace expiry + makeup session alerts
- Phase 10: Schedule copy — copy between groups, shift times, program-wide schedule propagation
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent e83886e0
...@@ -69,6 +69,26 @@ class DashboardController extends Controller ...@@ -69,6 +69,26 @@ class DashboardController extends Controller
$alerts[] = ['type' => 'warning', 'icon' => 'credit-card', 'text' => $expiringCards . ' كارت ينتهي خلال أسبوع', 'link' => '/sa/cards', 'count' => $expiringCards]; $alerts[] = ['type' => 'warning', 'icon' => 'credit-card', 'text' => $expiringCards . ' كارت ينتهي خلال أسبوع', 'link' => '/sa/cards', 'count' => $expiringCards];
} }
$medicalGraceExpiring = (int) $db->selectOne(
"SELECT COUNT(*) as c FROM sa_group_players gp
JOIN sa_players sp ON sp.id = gp.player_id
WHERE gp.status = 'active' AND gp.medical_grace_deadline IS NOT NULL
AND gp.medical_grace_deadline BETWEEN ? AND DATE_ADD(?, INTERVAL 3 DAY)
AND sp.medical_status NOT IN ('fit','conditional')"
, [$today, $today]
)['c'];
if ($medicalGraceExpiring > 0) {
$alerts[] = ['type' => 'danger', 'icon' => 'heart-pulse', 'text' => $medicalGraceExpiring . ' لاعب تنتهي مهلتهم الطبية خلال 3 أيام', 'link' => '/sa/players', 'count' => $medicalGraceExpiring];
}
$eligibleMakeups = (int) $db->selectOne(
"SELECT COUNT(*) as c FROM sa_makeup_sessions WHERE status = 'eligible' AND expires_at >= ?",
[$today]
)['c'];
if ($eligibleMakeups > 0) {
$alerts[] = ['type' => 'info', 'icon' => 'refresh-ccw', 'text' => $eligibleMakeups . ' حصة تعويضية بانتظار الجدولة', 'link' => '/sa/makeup-sessions?status=eligible', 'count' => $eligibleMakeups];
}
// ─── Live Now: What's happening right now ─────────────────────────── // ─── Live Now: What's happening right now ───────────────────────────
$liveNow = $db->select( $liveNow = $db->select(
"SELECT b.start_time, b.end_time, b.booking_type, b.booker_name, b.booker_type, "SELECT b.start_time, b.end_time, b.booking_type, b.booker_name, b.booker_type,
......
...@@ -134,11 +134,17 @@ class DisciplineController extends Controller ...@@ -134,11 +134,17 @@ class DisciplineController extends Controller
return $this->redirect('/sa/disciplines/create'); return $this->redirect('/sa/disciplines/create');
} }
$sportType = trim((string) $request->post('sport_type', 'training'));
if (!in_array($sportType, ['training', 'recreational'], true)) {
$sportType = 'training';
}
$discipline = Discipline::create([ $discipline = Discipline::create([
'code' => $code, 'code' => $code,
'name_ar' => $nameAr, 'name_ar' => $nameAr,
'name_en' => $nameEn ?: null, 'name_en' => $nameEn ?: null,
'category' => $category ?: null, 'category' => $category ?: null,
'sport_type' => $sportType,
'icon' => $icon ?: null, 'icon' => $icon ?: null,
'description_ar' => $descriptionAr ?: null, 'description_ar' => $descriptionAr ?: null,
'sort_order' => $sortOrder, 'sort_order' => $sortOrder,
...@@ -231,11 +237,17 @@ class DisciplineController extends Controller ...@@ -231,11 +237,17 @@ class DisciplineController extends Controller
return $this->redirect('/sa/disciplines/' . $id . '/edit'); return $this->redirect('/sa/disciplines/' . $id . '/edit');
} }
$sportType = trim((string) $request->post('sport_type', 'training'));
if (!in_array($sportType, ['training', 'recreational'], true)) {
$sportType = 'training';
}
$discipline->update([ $discipline->update([
'code' => $code, 'code' => $code,
'name_ar' => $nameAr, 'name_ar' => $nameAr,
'name_en' => $nameEn ?: null, 'name_en' => $nameEn ?: null,
'category' => $category ?: null, 'category' => $category ?: null,
'sport_type' => $sportType,
'icon' => $icon ?: null, 'icon' => $icon ?: null,
'description_ar' => $descriptionAr ?: null, 'description_ar' => $descriptionAr ?: null,
'sort_order' => $sortOrder, 'sort_order' => $sortOrder,
......
<?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;
class InstitutionController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$search = trim((string) $request->get('search', ''));
$type = trim((string) $request->get('type', ''));
$where = "is_archived = 0";
$params = [];
if ($search !== '') {
$where .= " AND (name_ar LIKE ? OR name_en LIKE ? OR code LIKE ?)";
$params[] = "%{$search}%";
$params[] = "%{$search}%";
$params[] = "%{$search}%";
}
if ($type !== '') {
$where .= " AND institution_type = ?";
$params[] = $type;
}
$institutions = $db->select(
"SELECT *,
(SELECT COUNT(*) FROM sa_bookings b WHERE b.institution_id = sa_institutions.id AND b.status != 'cancelled') as booking_count
FROM sa_institutions
WHERE {$where}
ORDER BY name_ar ASC",
$params
);
return $this->view('SportsActivity.Views.institutions.index', [
'institutions' => $institutions,
'filters' => ['search' => $search, 'type' => $type],
]);
}
public function create(Request $request): Response
{
return $this->view('SportsActivity.Views.institutions.form', [
'institution' => null,
'mode' => 'create',
]);
}
public function store(Request $request): Response
{
$data = $this->validateInstitution($request);
if ($data instanceof Response) {
return $data;
}
$db = App::getInstance()->db();
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$existing = $db->selectOne(
"SELECT id FROM sa_institutions WHERE code = ?",
[$data['code']]
);
if ($existing) {
return $this->redirect('/sa/institutions/create')->withError('كود المؤسسة مستخدم بالفعل');
}
$data['created_at'] = date('Y-m-d H:i:s');
$data['updated_at'] = date('Y-m-d H:i:s');
$data['created_by'] = $employeeId;
$db->insert('sa_institutions', $data);
return $this->redirect('/sa/institutions')->withSuccess('تم إضافة المؤسسة بنجاح');
}
public function edit(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$institution = $db->selectOne(
"SELECT * FROM sa_institutions WHERE id = ? AND is_archived = 0",
[(int) $id]
);
if (!$institution) {
return $this->redirect('/sa/institutions')->withError('المؤسسة غير موجودة');
}
return $this->view('SportsActivity.Views.institutions.form', [
'institution' => $institution,
'mode' => 'edit',
]);
}
public function update(Request $request, string $id): Response
{
$data = $this->validateInstitution($request);
if ($data instanceof Response) {
return $data;
}
$db = App::getInstance()->db();
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$existing = $db->selectOne(
"SELECT id FROM sa_institutions WHERE code = ? AND id != ?",
[$data['code'], (int) $id]
);
if ($existing) {
return $this->redirect("/sa/institutions/{$id}/edit")->withError('كود المؤسسة مستخدم بالفعل');
}
$data['updated_at'] = date('Y-m-d H:i:s');
$data['updated_by'] = $employeeId;
$db->update('sa_institutions', $data, 'id = ?', [(int) $id]);
return $this->redirect('/sa/institutions')->withSuccess('تم تحديث بيانات المؤسسة');
}
public function show(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$institution = $db->selectOne(
"SELECT * FROM sa_institutions WHERE id = ?",
[(int) $id]
);
if (!$institution) {
return $this->redirect('/sa/institutions')->withError('المؤسسة غير موجودة');
}
$bookings = $db->select(
"SELECT b.*, fu.name_ar as unit_name
FROM sa_bookings b
JOIN sa_facility_units fu ON fu.id = b.facility_unit_id
WHERE b.institution_id = ?
ORDER BY b.booking_date DESC
LIMIT 50",
[(int) $id]
);
$stats = $db->selectOne(
"SELECT
COUNT(*) as total_bookings,
SUM(CASE WHEN status = 'confirmed' THEN 1 ELSE 0 END) as confirmed,
SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = 'cancelled' THEN 1 ELSE 0 END) as cancelled,
SUM(total_amount) as total_revenue
FROM sa_bookings WHERE institution_id = ?",
[(int) $id]
);
return $this->view('SportsActivity.Views.institutions.show', [
'institution' => $institution,
'bookings' => $bookings,
'stats' => $stats,
]);
}
public function archive(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$db->update('sa_institutions', [
'is_archived' => 1,
'archived_at' => date('Y-m-d H:i:s'),
'archived_by' => $employeeId,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $id]);
return $this->redirect('/sa/institutions')->withSuccess('تم أرشفة المؤسسة');
}
private function validateInstitution(Request $request)
{
$code = trim((string) $request->post('code', ''));
$nameAr = trim((string) $request->post('name_ar', ''));
$type = trim((string) $request->post('institution_type', 'school'));
if ($code === '' || $nameAr === '') {
return $this->redirect('/sa/institutions')->withError('الكود والاسم مطلوبان');
}
$validTypes = ['school', 'university', 'company', 'club', 'government', 'other'];
if (!in_array($type, $validTypes, true)) {
$type = 'other';
}
return [
'code' => $code,
'name_ar' => $nameAr,
'name_en' => trim((string) $request->post('name_en', '')) ?: null,
'institution_type' => $type,
'contact_person' => trim((string) $request->post('contact_person', '')) ?: null,
'phone' => trim((string) $request->post('phone', '')) ?: null,
'email' => trim((string) $request->post('email', '')) ?: null,
'address' => trim((string) $request->post('address', '')) ?: null,
'contract_start' => trim((string) $request->post('contract_start', '')) ?: null,
'contract_end' => trim((string) $request->post('contract_end', '')) ?: null,
'hourly_rate' => trim((string) $request->post('hourly_rate', '')) ?: null,
'discount_percent' => (float) $request->post('discount_percent', 0),
'max_participants' => ((int) $request->post('max_participants', 0)) ?: null,
'notes' => trim((string) $request->post('notes', '')) ?: null,
'is_active' => (int) $request->post('is_active', 1),
];
}
}
<?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\MakeupSessionService;
class MakeupSessionController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$status = trim((string) $request->get('status', ''));
$groupId = trim((string) $request->get('group_id', ''));
$where = "1=1";
$params = [];
if ($status !== '') {
$where .= " AND ms.status = ?";
$params[] = $status;
}
if ($groupId !== '') {
$where .= " AND ms.original_group_id = ?";
$params[] = (int) $groupId;
}
$makeups = $db->select(
"SELECT ms.*, sp.full_name_ar as player_name,
g1.name_ar as original_group_name, g2.name_ar as makeup_group_name
FROM sa_makeup_sessions ms
JOIN sa_players sp ON sp.id = ms.player_id
JOIN sa_groups g1 ON g1.id = ms.original_group_id
LEFT JOIN sa_groups g2 ON g2.id = ms.makeup_group_id
WHERE {$where}
ORDER BY ms.created_at DESC
LIMIT 200",
$params
);
$groups = $db->select(
"SELECT id, name_ar FROM sa_groups WHERE status = 'active' AND is_archived = 0 ORDER BY name_ar"
);
return $this->view('SportsActivity.Views.makeup-sessions.index', [
'makeups' => $makeups,
'groups' => $groups,
'filters' => ['status' => $status, 'group_id' => $groupId],
]);
}
public function create(Request $request): Response
{
$db = App::getInstance()->db();
$playerId = trim((string) $request->get('player_id', ''));
$groupId = trim((string) $request->get('group_id', ''));
$player = null;
$absences = [];
if ($playerId !== '' && $groupId !== '') {
$player = $db->selectOne("SELECT * FROM sa_players WHERE id = ?", [(int) $playerId]);
$absences = $db->select(
"SELECT ta.session_date, ta.status
FROM sa_training_attendance ta
WHERE ta.player_id = ? AND ta.group_id = ? AND ta.status IN ('absent','excused')
AND NOT EXISTS (
SELECT 1 FROM sa_makeup_sessions ms
WHERE ms.player_id = ta.player_id AND ms.original_group_id = ta.group_id
AND ms.missed_session_date = ta.session_date AND ms.status NOT IN ('cancelled','expired')
)
ORDER BY ta.session_date DESC
LIMIT 30",
[(int) $playerId, (int) $groupId]
);
}
$groups = $db->select(
"SELECT id, name_ar FROM sa_groups WHERE status = 'active' AND is_archived = 0 ORDER BY name_ar"
);
return $this->view('SportsActivity.Views.makeup-sessions.create', [
'player' => $player,
'absences' => $absences,
'groups' => $groups,
'filters' => ['player_id' => $playerId, 'group_id' => $groupId],
]);
}
public function store(Request $request): Response
{
$playerId = (int) $request->post('player_id', 0);
$groupId = (int) $request->post('group_id', 0);
$missedDate = trim((string) $request->post('missed_session_date', ''));
$reason = trim((string) $request->post('reason', ''));
if (!$playerId || !$groupId || !$missedDate) {
return $this->redirect('/sa/makeup-sessions/create')->withError('جميع الحقول مطلوبة');
}
$result = MakeupSessionService::createFromAbsence($playerId, $groupId, $missedDate, $reason);
if (!$result['success']) {
return $this->redirect("/sa/makeup-sessions/create?player_id={$playerId}&group_id={$groupId}")
->withError($result['error']);
}
return $this->redirect('/sa/makeup-sessions')
->withSuccess("تم إنشاء حصة تعويضية — تنتهي في {$result['expires_at']}");
}
public function schedule(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$makeup = $db->selectOne(
"SELECT ms.*, sp.full_name_ar as player_name, g.name_ar as group_name,
p.discipline_id
FROM sa_makeup_sessions ms
JOIN sa_players sp ON sp.id = ms.player_id
JOIN sa_groups g ON g.id = ms.original_group_id
JOIN sa_programs p ON p.id = g.program_id
WHERE ms.id = ? AND ms.status = 'eligible'",
[(int) $id]
);
if (!$makeup) {
return $this->redirect('/sa/makeup-sessions')->withError('الحصة التعويضية غير متاحة');
}
$availableGroups = $db->select(
"SELECT g.id, g.name_ar, g.code, c.full_name_ar as coach_name
FROM sa_groups g
JOIN sa_programs p ON p.id = g.program_id
LEFT JOIN sa_coaches c ON c.id = g.coach_id
WHERE g.status = 'active' AND g.is_archived = 0
AND p.discipline_id = ?
ORDER BY g.name_ar",
[(int) $makeup['discipline_id']]
);
return $this->view('SportsActivity.Views.makeup-sessions.schedule', [
'makeup' => $makeup,
'availableGroups' => $availableGroups,
]);
}
public function confirmSchedule(Request $request, string $id): Response
{
$makeupGroupId = (int) $request->post('makeup_group_id', 0);
$makeupDate = trim((string) $request->post('makeup_date', ''));
if (!$makeupGroupId || !$makeupDate) {
return $this->redirect("/sa/makeup-sessions/{$id}/schedule")
->withError('يجب اختيار المجموعة والتاريخ');
}
$result = MakeupSessionService::schedule((int) $id, $makeupGroupId, $makeupDate);
if (!$result['success']) {
return $this->redirect("/sa/makeup-sessions/{$id}/schedule")
->withError($result['error']);
}
return $this->redirect('/sa/makeup-sessions')
->withSuccess('تم جدولة الحصة التعويضية بنجاح');
}
public function complete(Request $request, string $id): Response
{
$result = MakeupSessionService::complete((int) $id);
if (!$result['success']) {
return $this->redirect('/sa/makeup-sessions')->withError($result['error']);
}
return $this->redirect('/sa/makeup-sessions')->withSuccess('تم تأكيد حضور الحصة التعويضية');
}
public function cancel(Request $request, string $id): Response
{
$result = MakeupSessionService::cancel((int) $id);
if (!$result['success']) {
return $this->redirect('/sa/makeup-sessions')->withError($result['error']);
}
return $this->redirect('/sa/makeup-sessions')->withSuccess('تم إلغاء الحصة التعويضية');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Modules\SportsActivity\Services\PlayerLifecycleService;
class PlayerLifecycleController extends Controller
{
public function pause(Request $request, string $pid, string $gid): Response
{
$month = trim((string) $request->post('month', ''));
$reason = trim((string) $request->post('reason', ''));
if ($month === '') {
return $this->redirect("/sa/players/{$pid}")->withError('يجب تحديد الشهر');
}
$result = PlayerLifecycleService::pause((int) $gid, (int) $pid, $month, $reason);
if (!$result['success']) {
return $this->redirect("/sa/players/{$pid}")->withError($result['error']);
}
return $this->redirect("/sa/players/{$pid}")->withSuccess("تم إيقاف الاشتراك لشهر {$month}");
}
public function resume(Request $request, string $pid, string $gid): Response
{
$month = trim((string) $request->post('month', ''));
if ($month === '') {
return $this->redirect("/sa/players/{$pid}")->withError('يجب تحديد الشهر');
}
$result = PlayerLifecycleService::resume((int) $gid, (int) $pid, $month);
if (!$result['success']) {
return $this->redirect("/sa/players/{$pid}")->withError($result['error']);
}
return $this->redirect("/sa/players/{$pid}")->withSuccess("تم استئناف الاشتراك لشهر {$month}");
}
public function extendGrace(Request $request, string $pid, string $gid): Response
{
$days = (int) $request->post('extra_days', 14);
$enrollment = \App\Core\App::getInstance()->db()->selectOne(
"SELECT id FROM sa_group_players WHERE group_id = ? AND player_id = ? AND status IN ('active','pending_payment')",
[(int) $gid, (int) $pid]
);
if (!$enrollment) {
return $this->redirect("/sa/players/{$pid}")->withError('التسجيل غير موجود');
}
$result = PlayerLifecycleService::extendMedicalGrace((int) $enrollment['id'], $days);
if (!$result['success']) {
return $this->redirect("/sa/players/{$pid}")->withError($result['error']);
}
return $this->redirect("/sa/players/{$pid}")
->withSuccess("تم تمديد مهلة الشهادة الطبية حتى {$result['new_deadline']}");
}
}
...@@ -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\Modules\SportsActivity\Services\ScheduleGeneratorService; use App\Modules\SportsActivity\Services\ScheduleGeneratorService;
use App\Modules\SportsActivity\Services\ScheduleCopyService;
class ScheduleController extends Controller class ScheduleController extends Controller
{ {
...@@ -189,4 +190,43 @@ class ScheduleController extends Controller ...@@ -189,4 +190,43 @@ class ScheduleController extends Controller
return $this->redirect('/sa/schedule/daily/' . ($fromDate ?: date('Y-m-d')))->withError($result['error']); return $this->redirect('/sa/schedule/daily/' . ($fromDate ?: date('Y-m-d')))->withError($result['error']);
} }
public function copySchedule(Request $request): Response
{
$sourceGroupId = (int) $request->post('source_group_id', 0);
$targetGroupId = (int) $request->post('target_group_id', 0);
if (!$sourceGroupId || !$targetGroupId) {
return $this->redirect('/sa/schedule')->withError('يجب اختيار المجموعة المصدر والمستهدفة');
}
$result = ScheduleCopyService::copyGroupSchedule($sourceGroupId, $targetGroupId);
if (!$result['success']) {
return $this->redirect('/sa/schedule')->withError($result['error']);
}
return $this->redirect('/sa/schedule')
->withSuccess("تم نسخ {$result['copied']} حصة من الجدول بنجاح");
}
public function shiftTime(Request $request): Response
{
$groupId = (int) $request->post('group_id', 0);
$minutes = (int) $request->post('minutes_delta', 0);
if (!$groupId || !$minutes) {
return $this->redirect('/sa/schedule')->withError('يجب تحديد المجموعة وعدد الدقائق');
}
$result = ScheduleCopyService::shiftScheduleTime($groupId, $minutes);
if (!$result['success']) {
return $this->redirect('/sa/schedule')->withError($result['error']);
}
$direction = $minutes > 0 ? 'تقديم' : 'تأخير';
return $this->redirect('/sa/schedule')
->withSuccess("تم {$direction} مواعيد المجموعة بـ " . abs($minutes) . " دقيقة");
}
} }
<?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\TrainingAttendanceService;
class TrainingAttendanceController extends Controller
{
public function index(Request $request): Response
{
$db = App::getInstance()->db();
$date = trim((string) $request->get('date', date('Y-m-d')));
$disciplineId = trim((string) $request->get('discipline_id', ''));
$where = "g.status = 'active' AND g.is_archived = 0";
$params = [];
if ($disciplineId !== '') {
$where .= " AND p.discipline_id = ?";
$params[] = (int) $disciplineId;
}
$dayOfWeek = (int) date('w', strtotime($date));
$groups = $db->select(
"SELECT DISTINCT g.id, g.name_ar, g.code, p.name_ar as program_name,
d.name_ar as discipline_name, c.full_name_ar as coach_name,
gs.start_time, gs.end_time, fu.name_ar as unit_name,
g.current_count,
(SELECT COUNT(*) FROM sa_training_attendance ta
WHERE ta.group_id = g.id AND ta.session_date = ?) as attendance_recorded
FROM sa_groups g
JOIN sa_programs p ON p.id = g.program_id
JOIN sa_disciplines d ON d.id = p.discipline_id
LEFT JOIN sa_coaches c ON c.id = g.coach_id
JOIN sa_group_schedule gs ON gs.group_id = g.id AND gs.day_of_week = ? AND gs.is_active = 1
LEFT JOIN sa_facility_units fu ON fu.id = gs.facility_unit_id
WHERE {$where}
ORDER BY gs.start_time ASC, g.name_ar ASC",
array_merge([$date, $dayOfWeek], $params)
);
$disciplines = $db->select(
"SELECT id, name_ar FROM sa_disciplines WHERE is_archived = 0 AND is_active = 1 ORDER BY name_ar"
);
return $this->view('SportsActivity.Views.training-attendance.index', [
'groups' => $groups,
'date' => $date,
'disciplines' => $disciplines,
'filters' => ['discipline_id' => $disciplineId],
]);
}
public function record(Request $request, string $groupId): Response
{
$db = App::getInstance()->db();
$date = trim((string) $request->get('date', date('Y-m-d')));
$group = $db->selectOne(
"SELECT g.*, p.name_ar as program_name, d.name_ar as discipline_name,
c.full_name_ar as coach_name
FROM sa_groups g
JOIN sa_programs p ON p.id = g.program_id
JOIN sa_disciplines d ON d.id = p.discipline_id
LEFT JOIN sa_coaches c ON c.id = g.coach_id
WHERE g.id = ?",
[(int) $groupId]
);
if (!$group) {
return $this->redirect('/sa/training-attendance')->withError('المجموعة غير موجودة');
}
$sessions = TrainingAttendanceService::getGroupSessionsForDate((int) $groupId, $date);
$players = TrainingAttendanceService::getActivePlayers((int) $groupId);
$existingAttendance = TrainingAttendanceService::getExistingAttendance((int) $groupId, $date);
return $this->view('SportsActivity.Views.training-attendance.record', [
'group' => $group,
'date' => $date,
'sessions' => $sessions,
'players' => $players,
'existingAttendance' => $existingAttendance,
]);
}
public function store(Request $request, string $groupId): Response
{
$date = trim((string) $request->post('session_date', date('Y-m-d')));
$playerIds = $request->post('player_ids', []);
$statuses = $request->post('statuses', []);
if (!is_array($playerIds) || !is_array($statuses)) {
return $this->redirect("/sa/training-attendance/record/{$groupId}?date={$date}")
->withError('بيانات غير صالحة');
}
$attendanceData = [];
foreach ($playerIds as $i => $pid) {
$attendanceData[] = [
'player_id' => (int) $pid,
'status' => $statuses[$i] ?? 'absent',
];
}
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$result = TrainingAttendanceService::recordBulk((int) $groupId, $date, $attendanceData, $employeeId);
if (!$result['success']) {
return $this->redirect("/sa/training-attendance/record/{$groupId}?date={$date}")
->withError($result['error']);
}
return $this->redirect("/sa/training-attendance?date={$date}")
->withSuccess("تم تسجيل حضور {$result['recorded']} لاعب بنجاح");
}
public function report(Request $request): Response
{
$db = App::getInstance()->db();
$groupId = trim((string) $request->get('group_id', ''));
$playerId = trim((string) $request->get('player_id', ''));
$dateFrom = trim((string) $request->get('date_from', date('Y-m-01')));
$dateTo = trim((string) $request->get('date_to', date('Y-m-d')));
$records = [];
$summary = null;
$hasFilter = $groupId !== '' || $playerId !== '';
if ($hasFilter) {
$where = "ta.session_date BETWEEN ? AND ?";
$params = [$dateFrom, $dateTo];
if ($groupId !== '') {
$where .= " AND ta.group_id = ?";
$params[] = (int) $groupId;
}
if ($playerId !== '') {
$where .= " AND ta.player_id = ?";
$params[] = (int) $playerId;
}
$records = $db->select(
"SELECT ta.*, sp.full_name_ar as player_name, g.name_ar as group_name
FROM sa_training_attendance ta
JOIN sa_players sp ON sp.id = ta.player_id
JOIN sa_groups g ON g.id = ta.group_id
WHERE {$where}
ORDER BY ta.session_date DESC, sp.full_name_ar ASC
LIMIT 500",
$params
);
$summary = $db->selectOne(
"SELECT
COUNT(*) as total,
SUM(CASE WHEN ta.status = 'present' THEN 1 ELSE 0 END) as present_count,
SUM(CASE WHEN ta.status = 'absent' THEN 1 ELSE 0 END) as absent_count,
SUM(CASE WHEN ta.status = 'excused' THEN 1 ELSE 0 END) as excused_count,
SUM(CASE WHEN ta.status = 'late' THEN 1 ELSE 0 END) as late_count,
SUM(CASE WHEN ta.status = 'makeup' THEN 1 ELSE 0 END) as makeup_count
FROM sa_training_attendance ta WHERE {$where}",
$params
);
}
$groups = $db->select(
"SELECT id, name_ar FROM sa_groups WHERE status = 'active' AND is_archived = 0 ORDER BY name_ar"
);
return $this->view('SportsActivity.Views.training-attendance.report', [
'records' => $records,
'summary' => $summary,
'groups' => $groups,
'filters' => [
'group_id' => $groupId,
'player_id' => $playerId,
'date_from' => $dateFrom,
'date_to' => $dateTo,
],
]);
}
}
...@@ -15,7 +15,7 @@ class Discipline extends Model ...@@ -15,7 +15,7 @@ class Discipline extends Model
protected static bool $autoTrackAuthor = true; protected static bool $autoTrackAuthor = true;
protected static array $fillable = [ protected static array $fillable = [
'code', 'name_ar', 'name_en', 'category', 'icon', 'code', 'name_ar', 'name_en', 'category', 'sport_type', 'icon',
'description_ar', 'config_json', 'sort_order', 'is_active', 'description_ar', 'config_json', 'sort_order', 'is_active',
]; ];
......
...@@ -110,6 +110,8 @@ return [ ...@@ -110,6 +110,8 @@ return [
['GET', '/sa/schedule/daily/{date}', 'SportsActivity\Controllers\ScheduleController@daily', ['auth'], 'sa.schedule.view'], ['GET', '/sa/schedule/daily/{date}', 'SportsActivity\Controllers\ScheduleController@daily', ['auth'], 'sa.schedule.view'],
['GET', '/sa/schedule/weekly', 'SportsActivity\Controllers\ScheduleController@weekly', ['auth'], 'sa.schedule.view'], ['GET', '/sa/schedule/weekly', 'SportsActivity\Controllers\ScheduleController@weekly', ['auth'], 'sa.schedule.view'],
['POST', '/sa/schedule/generate', 'SportsActivity\Controllers\ScheduleController@generate', ['auth', 'csrf'], 'sa.schedule.manage'], ['POST', '/sa/schedule/generate', 'SportsActivity\Controllers\ScheduleController@generate', ['auth', 'csrf'], 'sa.schedule.manage'],
['POST', '/sa/schedule/copy', 'SportsActivity\Controllers\ScheduleController@copySchedule', ['auth', 'csrf'], 'sa.schedule.manage'],
['POST', '/sa/schedule/shift-time', 'SportsActivity\Controllers\ScheduleController@shiftTime', ['auth', 'csrf'], 'sa.schedule.manage'],
// Recreational Games // Recreational Games
['GET', '/sa/games', 'SportsActivity\Controllers\GameController@index', ['auth'], 'sa.game.view'], ['GET', '/sa/games', 'SportsActivity\Controllers\GameController@index', ['auth'], 'sa.game.view'],
...@@ -327,4 +329,33 @@ return [ ...@@ -327,4 +329,33 @@ return [
['GET', '/sa/academy-pricing/lane-rentals', 'SportsActivity\Controllers\AcademyPricingController@laneRentals', ['auth'], 'sa.pricing.view'], ['GET', '/sa/academy-pricing/lane-rentals', 'SportsActivity\Controllers\AcademyPricingController@laneRentals', ['auth'], 'sa.pricing.view'],
['GET', '/sa/academy-pricing/facility-rentals', 'SportsActivity\Controllers\AcademyPricingController@facilityRentals', ['auth'], 'sa.pricing.view'], ['GET', '/sa/academy-pricing/facility-rentals', 'SportsActivity\Controllers\AcademyPricingController@facilityRentals', ['auth'], 'sa.pricing.view'],
['POST', '/sa/academy-pricing/calculate', 'SportsActivity\Controllers\AcademyPricingController@calculate', ['auth', 'csrf'], 'sa.pricing.view'], ['POST', '/sa/academy-pricing/calculate', 'SportsActivity\Controllers\AcademyPricingController@calculate', ['auth', 'csrf'], 'sa.pricing.view'],
// ─── Training Attendance (group-session based) ──────────────────────────────
['GET', '/sa/training-attendance', 'SportsActivity\Controllers\TrainingAttendanceController@index', ['auth'], 'sa.attendance.view'],
['GET', '/sa/training-attendance/record/{groupId:\d+}', 'SportsActivity\Controllers\TrainingAttendanceController@record', ['auth'], 'sa.attendance.manage'],
['POST', '/sa/training-attendance/record/{groupId:\d+}', 'SportsActivity\Controllers\TrainingAttendanceController@store', ['auth', 'csrf'], 'sa.attendance.manage'],
['GET', '/sa/training-attendance/report', 'SportsActivity\Controllers\TrainingAttendanceController@report', ['auth'], 'sa.attendance.view'],
// ─── Make-up Sessions ───────────────────────────────────────────────────────
['GET', '/sa/makeup-sessions', 'SportsActivity\Controllers\MakeupSessionController@index', ['auth'], 'sa.makeup.view'],
['GET', '/sa/makeup-sessions/create', 'SportsActivity\Controllers\MakeupSessionController@create', ['auth'], 'sa.makeup.manage'],
['POST', '/sa/makeup-sessions', 'SportsActivity\Controllers\MakeupSessionController@store', ['auth', 'csrf'], 'sa.makeup.manage'],
['GET', '/sa/makeup-sessions/{id:\d+}/schedule', 'SportsActivity\Controllers\MakeupSessionController@schedule', ['auth'], 'sa.makeup.manage'],
['POST', '/sa/makeup-sessions/{id:\d+}/schedule', 'SportsActivity\Controllers\MakeupSessionController@confirmSchedule', ['auth', 'csrf'], 'sa.makeup.manage'],
['POST', '/sa/makeup-sessions/{id:\d+}/complete', 'SportsActivity\Controllers\MakeupSessionController@complete', ['auth', 'csrf'], 'sa.makeup.manage'],
['POST', '/sa/makeup-sessions/{id:\d+}/cancel', 'SportsActivity\Controllers\MakeupSessionController@cancel', ['auth', 'csrf'], 'sa.makeup.manage'],
// ─── Institutions ───────────────────────────────────────────────────────────
['GET', '/sa/institutions', 'SportsActivity\Controllers\InstitutionController@index', ['auth'], 'sa.institution.view'],
['GET', '/sa/institutions/create', 'SportsActivity\Controllers\InstitutionController@create', ['auth'], 'sa.institution.manage'],
['POST', '/sa/institutions', 'SportsActivity\Controllers\InstitutionController@store', ['auth', 'csrf'], 'sa.institution.manage'],
['GET', '/sa/institutions/{id:\d+}', 'SportsActivity\Controllers\InstitutionController@show', ['auth'], 'sa.institution.view'],
['GET', '/sa/institutions/{id:\d+}/edit', 'SportsActivity\Controllers\InstitutionController@edit', ['auth'], 'sa.institution.manage'],
['POST', '/sa/institutions/{id:\d+}', 'SportsActivity\Controllers\InstitutionController@update', ['auth', 'csrf'], 'sa.institution.manage'],
['POST', '/sa/institutions/{id:\d+}/archive', 'SportsActivity\Controllers\InstitutionController@archive', ['auth', 'csrf'], 'sa.institution.manage'],
// ─── Player Lifecycle (Pause/Resume) ────────────────────────────────────────
['POST', '/sa/players/{pid:\d+}/groups/{gid:\d+}/pause', 'SportsActivity\Controllers\PlayerLifecycleController@pause', ['auth', 'csrf'], 'sa.enrollment.manage'],
['POST', '/sa/players/{pid:\d+}/groups/{gid:\d+}/resume', 'SportsActivity\Controllers\PlayerLifecycleController@resume', ['auth', 'csrf'], 'sa.enrollment.manage'],
['POST', '/sa/players/{pid:\d+}/groups/{gid:\d+}/extend-grace', 'SportsActivity\Controllers\PlayerLifecycleController@extendGrace', ['auth', 'csrf'], 'sa.enrollment.manage'],
]; ];
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\SportsActivity\SaConstants;
final class MakeupSessionService
{
public static function createFromAbsence(int $playerId, int $groupId, string $missedDate, string $reason = ''): array
{
$db = App::getInstance()->db();
$attendance = $db->selectOne(
"SELECT id FROM sa_training_attendance
WHERE group_id = ? AND player_id = ? AND session_date = ? AND status IN ('absent','excused')",
[$groupId, $playerId, $missedDate]
);
if (!$attendance) {
return ['success' => false, 'error' => 'لا يوجد سجل غياب لهذا التاريخ'];
}
$existing = $db->selectOne(
"SELECT id FROM sa_makeup_sessions
WHERE player_id = ? AND original_group_id = ? AND missed_session_date = ? AND status NOT IN ('cancelled','expired')",
[$playerId, $groupId, $missedDate]
);
if ($existing) {
return ['success' => false, 'error' => 'يوجد بالفعل حصة تعويضية لهذا الغياب'];
}
$expiryDaysRow = $db->selectOne(
"SELECT config_value FROM system_config WHERE config_key = 'sa.makeup_expiry_days'",
[]
);
$expiryDays = (int) ($expiryDaysRow['config_value'] ?? 30);
$expiresAt = date('Y-m-d', strtotime($missedDate . " +{$expiryDays} days"));
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$id = $db->insert('sa_makeup_sessions', [
'player_id' => $playerId,
'original_group_id' => $groupId,
'missed_session_date' => $missedDate,
'missed_reason' => $reason ?: null,
'status' => 'eligible',
'expires_at' => $expiresAt,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employeeId,
]);
return ['success' => true, 'makeup_id' => $id, 'expires_at' => $expiresAt];
}
public static function schedule(int $makeupId, int $makeupGroupId, string $makeupDate): array
{
$db = App::getInstance()->db();
$makeup = $db->selectOne(
"SELECT * FROM sa_makeup_sessions WHERE id = ? AND status = 'eligible'",
[$makeupId]
);
if (!$makeup) {
return ['success' => false, 'error' => 'الحصة التعويضية غير متاحة للجدولة'];
}
if ($makeupDate > $makeup['expires_at']) {
return ['success' => false, 'error' => 'تاريخ التعويض بعد تاريخ انتهاء الصلاحية'];
}
$group = $db->selectOne(
"SELECT id, current_count, max_capacity FROM sa_groups WHERE id = ? AND status = ? AND is_archived = 0",
[$makeupGroupId, SaConstants::GROUP_ACTIVE]
);
if (!$group) {
return ['success' => false, 'error' => 'المجموعة المستهدفة غير موجودة أو غير نشطة'];
}
$dayOfWeek = (int) date('w', strtotime($makeupDate));
$hasSession = $db->selectOne(
"SELECT id FROM sa_group_schedule WHERE group_id = ? AND day_of_week = ? AND is_active = 1",
[$makeupGroupId, $dayOfWeek]
);
if (!$hasSession) {
return ['success' => false, 'error' => 'لا يوجد تمرين للمجموعة في هذا اليوم'];
}
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
$db->update('sa_makeup_sessions', [
'makeup_group_id' => $makeupGroupId,
'makeup_date' => $makeupDate,
'status' => 'scheduled',
'scheduled_by' => $employeeId,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$makeupId]);
return ['success' => true];
}
public static function complete(int $makeupId): array
{
$db = App::getInstance()->db();
$makeup = $db->selectOne(
"SELECT * FROM sa_makeup_sessions WHERE id = ? AND status = 'scheduled'",
[$makeupId]
);
if (!$makeup) {
return ['success' => false, 'error' => 'الحصة التعويضية غير مجدولة'];
}
$db->update('sa_makeup_sessions', [
'status' => 'completed',
'completed_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$makeupId]);
$db->insert('sa_training_attendance', [
'group_id' => (int) $makeup['makeup_group_id'],
'player_id' => (int) $makeup['player_id'],
'session_date' => $makeup['makeup_date'],
'day_of_week' => (int) date('w', strtotime($makeup['makeup_date'])),
'status' => 'makeup',
'makeup_session_id' => $makeupId,
'recorded_by' => (int) (App::getInstance()->session()->get('employee_id') ?? 0),
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
return ['success' => true];
}
public static function cancel(int $makeupId): array
{
$db = App::getInstance()->db();
$makeup = $db->selectOne(
"SELECT id FROM sa_makeup_sessions WHERE id = ? AND status IN ('eligible','scheduled')",
[$makeupId]
);
if (!$makeup) {
return ['success' => false, 'error' => 'الحصة التعويضية غير قابلة للإلغاء'];
}
$db->update('sa_makeup_sessions', [
'status' => 'cancelled',
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$makeupId]);
return ['success' => true];
}
public static function expireOverdue(): array
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
$result = $db->query(
"UPDATE sa_makeup_sessions SET status = 'expired', updated_at = NOW()
WHERE status IN ('eligible','scheduled') AND expires_at < ?",
[$today]
);
return ['success' => true, 'expired_count' => $result->rowCount()];
}
public static function getPlayerMakeups(int $playerId, ?string $status = null): array
{
$db = App::getInstance()->db();
$where = "ms.player_id = ?";
$params = [$playerId];
if ($status) {
$where .= " AND ms.status = ?";
$params[] = $status;
}
return $db->select(
"SELECT ms.*, g1.name_ar as original_group_name, g2.name_ar as makeup_group_name
FROM sa_makeup_sessions ms
LEFT JOIN sa_groups g1 ON g1.id = ms.original_group_id
LEFT JOIN sa_groups g2 ON g2.id = ms.makeup_group_id
WHERE {$where}
ORDER BY ms.created_at DESC",
$params
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\SportsActivity\SaConstants;
final class PlayerLifecycleService
{
public static function pause(int $groupId, int $playerId, string $month, string $reason = ''): array
{
$db = App::getInstance()->db();
$enrollment = $db->selectOne(
"SELECT id, paused_months FROM sa_group_players WHERE group_id = ? AND player_id = ? AND status = ?",
[$groupId, $playerId, SaConstants::STATUS_ACTIVE]
);
if (!$enrollment) {
return ['success' => false, 'error' => 'اللاعب غير مسجل أو غير نشط في هذه المجموعة'];
}
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
return ['success' => false, 'error' => 'صيغة الشهر غير صحيحة (YYYY-MM)'];
}
$pausedMonths = json_decode($enrollment['paused_months'] ?? '[]', true) ?: [];
if (in_array($month, $pausedMonths, true)) {
return ['success' => false, 'error' => 'الشهر متوقف بالفعل'];
}
$maxPauses = self::getConfig('sa.max_pause_months', 3);
if (count($pausedMonths) >= $maxPauses) {
return ['success' => false, 'error' => "تم تجاوز الحد الأقصى لعدد شهور الإيقاف ({$maxPauses} شهور)"];
}
$pausedMonths[] = $month;
sort($pausedMonths);
$db->update('sa_group_players', [
'paused_months' => json_encode($pausedMonths),
'notes' => $reason ? ($enrollment['notes'] ?? '') . "\nإيقاف {$month}: {$reason}" : null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $enrollment['id']]);
$existingSub = $db->selectOne(
"SELECT id, payment_status FROM sa_subscriptions WHERE player_id = ? AND group_id = ? AND period_start = ?",
[$playerId, $groupId, $month . '-01']
);
if ($existingSub && $existingSub['payment_status'] === SaConstants::PAYMENT_UNPAID) {
$db->update('sa_subscriptions', [
'exemption_reason' => 'إيقاف مؤقت',
'final_amount' => '0.00',
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $existingSub['id']]);
}
EventBus::dispatch('sa.player.paused', [
'player_id' => $playerId,
'group_id' => $groupId,
'month' => $month,
'reason' => $reason,
]);
return ['success' => true, 'paused_months' => $pausedMonths];
}
public static function resume(int $groupId, int $playerId, string $month): array
{
$db = App::getInstance()->db();
$enrollment = $db->selectOne(
"SELECT id, paused_months FROM sa_group_players WHERE group_id = ? AND player_id = ? AND status = ?",
[$groupId, $playerId, SaConstants::STATUS_ACTIVE]
);
if (!$enrollment) {
return ['success' => false, 'error' => 'اللاعب غير مسجل أو غير نشط في هذه المجموعة'];
}
$pausedMonths = json_decode($enrollment['paused_months'] ?? '[]', true) ?: [];
$key = array_search($month, $pausedMonths, true);
if ($key === false) {
return ['success' => false, 'error' => 'هذا الشهر غير متوقف'];
}
array_splice($pausedMonths, $key, 1);
$db->update('sa_group_players', [
'paused_months' => !empty($pausedMonths) ? json_encode($pausedMonths) : null,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $enrollment['id']]);
EventBus::dispatch('sa.player.resumed', [
'player_id' => $playerId,
'group_id' => $groupId,
'month' => $month,
]);
return ['success' => true, 'paused_months' => $pausedMonths];
}
public static function checkMedicalGraceExpired(): array
{
$db = App::getInstance()->db();
$today = date('Y-m-d');
$expired = $db->select(
"SELECT gp.id, gp.player_id, gp.group_id, sp.full_name_ar, 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 gp.medical_grace_deadline IS NOT NULL AND gp.medical_grace_deadline < ?
AND sp.medical_status NOT IN (?, ?)",
[
SaConstants::STATUS_ACTIVE,
$today,
SaConstants::MEDICAL_FIT,
SaConstants::MEDICAL_CONDITIONAL,
]
);
$suspended = 0;
foreach ($expired as $row) {
$db->update('sa_group_players', [
'status' => SaConstants::STATUS_WITHDRAWN,
'left_at' => $today,
'notes' => 'إيقاف تلقائي — انتهاء مهلة الشهادة الطبية',
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $row['id']]);
$group = $db->selectOne(
"SELECT current_count FROM sa_groups WHERE id = ?",
[(int) $row['group_id']]
);
$newCount = max(0, (int) ($group['current_count'] ?? 0) - 1);
$db->update('sa_groups', [
'current_count' => $newCount,
'is_full' => 0,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $row['group_id']]);
EventBus::dispatch('sa.medical_grace.expired', [
'player_id' => (int) $row['player_id'],
'group_id' => (int) $row['group_id'],
'player_name' => $row['full_name_ar'],
]);
$suspended++;
}
return ['success' => true, 'suspended' => $suspended];
}
public static function extendMedicalGrace(int $enrollmentId, int $extraDays): array
{
$db = App::getInstance()->db();
$enrollment = $db->selectOne(
"SELECT * FROM sa_group_players WHERE id = ? AND status IN (?, ?)",
[$enrollmentId, SaConstants::STATUS_ACTIVE, SaConstants::STATUS_PENDING_PAYMENT]
);
if (!$enrollment) {
return ['success' => false, 'error' => 'التسجيل غير موجود'];
}
$baseDate = !empty($enrollment['medical_grace_deadline'])
? max($enrollment['medical_grace_deadline'], date('Y-m-d'))
: date('Y-m-d');
$newDeadline = date('Y-m-d', strtotime($baseDate . " +{$extraDays} days"));
$db->update('sa_group_players', [
'medical_grace_deadline' => $newDeadline,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$enrollmentId]);
return ['success' => true, 'new_deadline' => $newDeadline];
}
private static function getConfig(string $key, $default = null)
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT config_value FROM system_config WHERE config_key = ?",
[$key]
);
return $row ? $row['config_value'] : $default;
}
}
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
final class ScheduleCopyService
{
public static function copyGroupSchedule(int $sourceGroupId, int $targetGroupId): array
{
$db = App::getInstance()->db();
$source = $db->selectOne(
"SELECT id, name_ar FROM sa_groups WHERE id = ? AND is_archived = 0",
[$sourceGroupId]
);
if (!$source) {
return ['success' => false, 'error' => 'المجموعة المصدر غير موجودة'];
}
$target = $db->selectOne(
"SELECT id, name_ar FROM sa_groups WHERE id = ? AND is_archived = 0",
[$targetGroupId]
);
if (!$target) {
return ['success' => false, 'error' => 'المجموعة المستهدفة غير موجودة'];
}
$schedules = $db->select(
"SELECT * FROM sa_group_schedule WHERE group_id = ? AND is_active = 1",
[$sourceGroupId]
);
if (empty($schedules)) {
return ['success' => false, 'error' => 'المجموعة المصدر ليس لها جدول'];
}
$db->beginTransaction();
try {
$db->query(
"UPDATE sa_group_schedule SET is_active = 0, updated_at = NOW() WHERE group_id = ?",
[$targetGroupId]
);
$copied = 0;
$now = date('Y-m-d H:i:s');
foreach ($schedules as $sch) {
$db->insert('sa_group_schedule', [
'group_id' => $targetGroupId,
'facility_unit_id' => (int) $sch['facility_unit_id'],
'day_of_week' => (int) $sch['day_of_week'],
'start_time' => $sch['start_time'],
'end_time' => $sch['end_time'],
'is_active' => 1,
'created_at' => $now,
'updated_at' => $now,
]);
$copied++;
}
$db->commit();
return ['success' => true, 'copied' => $copied];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل نسخ الجدول: ' . $e->getMessage()];
}
}
public static function copyProgramScheduleToAllGroups(int $programId): array
{
$db = App::getInstance()->db();
$groups = $db->select(
"SELECT id FROM sa_groups WHERE program_id = ? AND status = 'active' AND is_archived = 0",
[$programId]
);
if (count($groups) < 2) {
return ['success' => false, 'error' => 'يجب وجود مجموعتين على الأقل في البرنامج'];
}
$templateGroup = $db->selectOne(
"SELECT g.id FROM sa_groups g
INNER JOIN sa_group_schedule gs ON gs.group_id = g.id AND gs.is_active = 1
WHERE g.program_id = ? AND g.status = 'active' AND g.is_archived = 0
GROUP BY g.id
ORDER BY COUNT(gs.id) DESC
LIMIT 1",
[$programId]
);
if (!$templateGroup) {
return ['success' => false, 'error' => 'لا توجد مجموعة بها جدول يمكن نسخه'];
}
$sourceId = (int) $templateGroup['id'];
$totalCopied = 0;
$errors = [];
foreach ($groups as $g) {
if ((int) $g['id'] === $sourceId) {
continue;
}
$result = self::copyGroupSchedule($sourceId, (int) $g['id']);
if ($result['success']) {
$totalCopied += $result['copied'];
} else {
$errors[] = "مجموعة #{$g['id']}: {$result['error']}";
}
}
return [
'success' => true,
'source_id' => $sourceId,
'total_copied' => $totalCopied,
'groups_updated' => count($groups) - 1,
'errors' => $errors,
];
}
public static function shiftScheduleTime(int $groupId, int $minutesDelta): array
{
$db = App::getInstance()->db();
$schedules = $db->select(
"SELECT id, start_time, end_time FROM sa_group_schedule WHERE group_id = ? AND is_active = 1",
[$groupId]
);
if (empty($schedules)) {
return ['success' => false, 'error' => 'لا يوجد جدول لهذه المجموعة'];
}
$db->beginTransaction();
try {
foreach ($schedules as $sch) {
$newStart = date('H:i:s', strtotime($sch['start_time']) + ($minutesDelta * 60));
$newEnd = date('H:i:s', strtotime($sch['end_time']) + ($minutesDelta * 60));
$db->update('sa_group_schedule', [
'start_time' => $newStart,
'end_time' => $newEnd,
'updated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [(int) $sch['id']]);
}
$db->commit();
return ['success' => true, 'shifted' => count($schedules), 'delta_minutes' => $minutesDelta];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل تعديل المواعيد: ' . $e->getMessage()];
}
}
}
...@@ -18,6 +18,7 @@ final class SubscriptionGeneratorService ...@@ -18,6 +18,7 @@ final class SubscriptionGeneratorService
$groupPlayers = $db->select( $groupPlayers = $db->select(
"SELECT gp.player_id, gp.group_id, gp.enrolled_at, gp.paused_months, "SELECT gp.player_id, gp.group_id, gp.enrolled_at, gp.paused_months,
sp.player_type, sp.full_name_ar as player_name, sp.player_type, sp.full_name_ar as player_name,
sp.medical_status, sp.medical_expiry_date,
p.monthly_fee_member, p.monthly_fee_nonmember, g.name_ar as group_name p.monthly_fee_member, p.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
...@@ -39,6 +40,7 @@ final class SubscriptionGeneratorService ...@@ -39,6 +40,7 @@ final class SubscriptionGeneratorService
$generated = 0; $generated = 0;
$skipped = 0; $skipped = 0;
$medicalBlocked = 0;
$employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0); $employeeId = (int) (App::getInstance()->session()->get('employee_id') ?? 0);
foreach ($groupPlayers as $gp) { foreach ($groupPlayers as $gp) {
...@@ -54,6 +56,8 @@ final class SubscriptionGeneratorService ...@@ -54,6 +56,8 @@ final class SubscriptionGeneratorService
continue; continue;
} }
$medicalVerified = self::checkMedicalForRenewal($gp);
$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'];
...@@ -84,11 +88,16 @@ final class SubscriptionGeneratorService ...@@ -84,11 +88,16 @@ final class SubscriptionGeneratorService
'final_amount' => $amount, 'final_amount' => $amount,
'payment_status' => SaConstants::PAYMENT_UNPAID, 'payment_status' => SaConstants::PAYMENT_UNPAID,
'paid_amount' => 0.00, 'paid_amount' => 0.00,
'auto_generated' => 1,
'medical_verified' => $medicalVerified ? 1 : 0,
'created_at' => date('Y-m-d H:i:s'), 'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'), 'updated_at' => date('Y-m-d H:i:s'),
'created_by' => $employeeId, 'created_by' => $employeeId,
]); ]);
if (!$medicalVerified) {
$medicalBlocked++;
}
$generated++; $generated++;
} }
...@@ -96,7 +105,19 @@ final class SubscriptionGeneratorService ...@@ -96,7 +105,19 @@ final class SubscriptionGeneratorService
'success' => true, 'success' => true,
'generated' => $generated, 'generated' => $generated,
'skipped' => $skipped, 'skipped' => $skipped,
'medical_blocked' => $medicalBlocked,
'month' => $yearMonth, 'month' => $yearMonth,
]; ];
} }
private static function checkMedicalForRenewal(array $gp): bool
{
if (in_array($gp['medical_status'], [SaConstants::MEDICAL_FIT, SaConstants::MEDICAL_CONDITIONAL], true)) {
if (!empty($gp['medical_expiry_date']) && $gp['medical_expiry_date'] < date('Y-m-d')) {
return false;
}
return true;
}
return false;
}
} }
<?php
declare(strict_types=1);
namespace App\Modules\SportsActivity\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Modules\SportsActivity\SaConstants;
final class TrainingAttendanceService
{
public static function getGroupSessionsForDate(int $groupId, string $date): array
{
$db = App::getInstance()->db();
$dayOfWeek = (int) date('w', strtotime($date));
return $db->select(
"SELECT gs.*, fu.name_ar as unit_name, f.name_ar as facility_name
FROM sa_group_schedule gs
JOIN sa_facility_units fu ON fu.id = gs.facility_unit_id
JOIN sa_facilities f ON f.id = fu.facility_id
WHERE gs.group_id = ? AND gs.day_of_week = ? AND gs.is_active = 1",
[$groupId, $dayOfWeek]
);
}
public static function getActivePlayers(int $groupId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT gp.id as enrollment_id, gp.player_id, gp.medical_grace_deadline, gp.paused_months,
sp.full_name_ar, sp.registration_serial, sp.medical_status, sp.medical_expiry_date,
sp.photo_path, sp.player_type
FROM sa_group_players gp
JOIN sa_players sp ON sp.id = gp.player_id
WHERE gp.group_id = ? AND gp.status = ?
ORDER BY sp.full_name_ar ASC",
[$groupId, SaConstants::STATUS_ACTIVE]
);
}
public static function getExistingAttendance(int $groupId, string $date): array
{
$db = App::getInstance()->db();
$rows = $db->select(
"SELECT * FROM sa_training_attendance WHERE group_id = ? AND session_date = ?",
[$groupId, $date]
);
$map = [];
foreach ($rows as $row) {
$map[(int) $row['player_id']] = $row;
}
return $map;
}
public static function recordBulk(int $groupId, string $date, array $attendanceData, int $recordedBy): array
{
$db = App::getInstance()->db();
$dayOfWeek = (int) date('w', strtotime($date));
$schedule = $db->selectOne(
"SELECT id FROM sa_group_schedule WHERE group_id = ? AND day_of_week = ? AND is_active = 1 LIMIT 1",
[$groupId, $dayOfWeek]
);
$db->beginTransaction();
try {
$db->query(
"DELETE FROM sa_training_attendance WHERE group_id = ? AND session_date = ?",
[$groupId, $date]
);
$recorded = 0;
$absentees = [];
$now = date('Y-m-d H:i:s');
foreach ($attendanceData as $entry) {
$playerId = (int) ($entry['player_id'] ?? 0);
$status = $entry['status'] ?? 'absent';
if (!in_array($status, ['present', 'absent', 'excused', 'late', 'makeup'], true)) {
$status = 'absent';
}
$db->insert('sa_training_attendance', [
'group_id' => $groupId,
'player_id' => $playerId,
'session_date' => $date,
'day_of_week' => $dayOfWeek,
'schedule_id' => $schedule ? (int) $schedule['id'] : null,
'status' => $status,
'check_in_time' => $entry['check_in_time'] ?? null,
'check_out_time' => $entry['check_out_time'] ?? null,
'excuse_reason' => $status === 'excused' ? ($entry['excuse_reason'] ?? null) : null,
'recorded_by' => $recordedBy,
'created_at' => $now,
'updated_at' => $now,
]);
if ($status === 'absent') {
$absentees[] = $playerId;
}
$recorded++;
}
$db->commit();
if (!empty($absentees)) {
self::checkAbsenceThreshold($groupId, $absentees, $date);
}
return ['success' => true, 'recorded' => $recorded];
} catch (\Throwable $e) {
$db->rollBack();
return ['success' => false, 'error' => 'فشل تسجيل الحضور: ' . $e->getMessage()];
}
}
public static function getPlayerAttendanceSummary(int $playerId, int $groupId, ?string $from = null, ?string $to = null): array
{
$db = App::getInstance()->db();
$where = "group_id = ? AND player_id = ?";
$params = [$groupId, $playerId];
if ($from) {
$where .= " AND session_date >= ?";
$params[] = $from;
}
if ($to) {
$where .= " AND session_date <= ?";
$params[] = $to;
}
$row = $db->selectOne(
"SELECT
COUNT(*) as total_sessions,
SUM(CASE WHEN status = 'present' THEN 1 ELSE 0 END) as present_count,
SUM(CASE WHEN status = 'absent' THEN 1 ELSE 0 END) as absent_count,
SUM(CASE WHEN status = 'excused' THEN 1 ELSE 0 END) as excused_count,
SUM(CASE WHEN status = 'late' THEN 1 ELSE 0 END) as late_count,
SUM(CASE WHEN status = 'makeup' THEN 1 ELSE 0 END) as makeup_count
FROM sa_training_attendance WHERE {$where}",
$params
);
$total = (int) ($row['total_sessions'] ?? 0);
$present = (int) ($row['present_count'] ?? 0) + (int) ($row['late_count'] ?? 0) + (int) ($row['makeup_count'] ?? 0);
return [
'total' => $total,
'present' => (int) ($row['present_count'] ?? 0),
'absent' => (int) ($row['absent_count'] ?? 0),
'excused' => (int) ($row['excused_count'] ?? 0),
'late' => (int) ($row['late_count'] ?? 0),
'makeup' => (int) ($row['makeup_count'] ?? 0),
'attendance_rate' => $total > 0 ? round(($present / $total) * 100, 1) : 0.0,
];
}
private static function checkAbsenceThreshold(int $groupId, array $playerIds, string $date): void
{
$db = App::getInstance()->db();
$thresholdRow = $db->selectOne(
"SELECT config_value FROM system_config WHERE config_key = 'sa.absence_threshold'",
[]
);
$threshold = (int) ($thresholdRow['config_value'] ?? 5);
$monthStart = date('Y-m-01', strtotime($date));
$monthEnd = date('Y-m-t', strtotime($date));
foreach ($playerIds as $playerId) {
$absentCount = $db->selectOne(
"SELECT COUNT(*) as cnt FROM sa_training_attendance
WHERE group_id = ? AND player_id = ? AND status = 'absent'
AND session_date BETWEEN ? AND ?",
[$groupId, $playerId, $monthStart, $monthEnd]
);
if ((int) ($absentCount['cnt'] ?? 0) >= $threshold) {
EventBus::dispatch('sa.absence_threshold.reached', [
'player_id' => $playerId,
'group_id' => $groupId,
'count' => (int) $absentCount['cnt'],
'month' => date('Y-m', strtotime($date)),
]);
}
}
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?><?= $mode === 'edit' ? 'تعديل مؤسسة' : 'إضافة مؤسسة جديدة' ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sa/institutions" 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'); ?>
<div class="card" style="padding:20px;max-width:700px;">
<form method="POST" action="<?= $mode === 'edit' ? '/sa/institutions/' . (int)$institution['id'] : '/sa/institutions' ?>">
<?= csrf_field() ?>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:15px;">
<div>
<label class="form-label">الكود <span style="color:#DC2626;">*</span></label>
<input type="text" name="code" class="form-control" value="<?= e($institution['code'] ?? old('code', '')) ?>" required maxlength="30">
</div>
<div>
<label class="form-label">النوع <span style="color:#DC2626;">*</span></label>
<select name="institution_type" class="form-select" required>
<?php
$types = ['school'=>'مدرسة','university'=>'جامعة','company'=>'شركة','club'=>'نادي','government'=>'حكومي','other'=>'أخرى'];
$current = $institution['institution_type'] ?? 'school';
foreach ($types as $val => $label): ?>
<option value="<?= $val ?>" <?= $current === $val ? 'selected' : '' ?>><?= $label ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:15px;">
<div>
<label class="form-label">الاسم بالعربي <span style="color:#DC2626;">*</span></label>
<input type="text" name="name_ar" class="form-control" value="<?= e($institution['name_ar'] ?? old('name_ar', '')) ?>" required>
</div>
<div>
<label class="form-label">الاسم بالإنجليزي</label>
<input type="text" name="name_en" class="form-control" value="<?= e($institution['name_en'] ?? old('name_en', '')) ?>">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:15px;">
<div>
<label class="form-label">المسؤول</label>
<input type="text" name="contact_person" class="form-control" value="<?= e($institution['contact_person'] ?? '') ?>">
</div>
<div>
<label class="form-label">الهاتف</label>
<input type="text" name="phone" class="form-control" value="<?= e($institution['phone'] ?? '') ?>">
</div>
</div>
<div style="margin-bottom:15px;">
<label class="form-label">البريد الإلكتروني</label>
<input type="email" name="email" class="form-control" value="<?= e($institution['email'] ?? '') ?>">
</div>
<div style="margin-bottom:15px;">
<label class="form-label">العنوان</label>
<textarea name="address" class="form-control" rows="2"><?= e($institution['address'] ?? '') ?></textarea>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:15px;margin-bottom:15px;">
<div>
<label class="form-label">بداية العقد</label>
<input type="date" name="contract_start" class="form-control" value="<?= e($institution['contract_start'] ?? '') ?>">
</div>
<div>
<label class="form-label">نهاية العقد</label>
<input type="date" name="contract_end" class="form-control" value="<?= e($institution['contract_end'] ?? '') ?>">
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:15px;margin-bottom:15px;">
<div>
<label class="form-label">سعر الساعة</label>
<input type="number" step="0.01" name="hourly_rate" class="form-control" value="<?= e($institution['hourly_rate'] ?? '') ?>">
</div>
<div>
<label class="form-label">نسبة الخصم %</label>
<input type="number" step="0.01" name="discount_percent" class="form-control" value="<?= e($institution['discount_percent'] ?? '0') ?>">
</div>
<div>
<label class="form-label">الحد الأقصى</label>
<input type="number" name="max_participants" class="form-control" value="<?= e($institution['max_participants'] ?? '') ?>" placeholder="عدد المشاركين">
</div>
</div>
<div style="margin-bottom:15px;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-control" rows="3"><?= e($institution['notes'] ?? '') ?></textarea>
</div>
<div style="margin-bottom:20px;">
<label style="display:flex;align-items:center;gap:8px;">
<input type="hidden" name="is_active" value="0">
<input type="checkbox" name="is_active" value="1" <?= (int)($institution['is_active'] ?? 1) ? 'checked' : '' ?>>
<span>نشط</span>
</label>
</div>
<button type="submit" class="btn btn-primary" style="padding:10px 30px;">
<i data-lucide="save" style="width:16px;height:16px;vertical-align:middle;margin-left:6px;"></i>
<?= $mode === 'edit' ? 'حفظ التعديلات' : 'إضافة المؤسسة' ?>
</button>
</form>
</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'); ?>المؤسسات<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<?php if (can('sa.institution.manage')): ?>
<a href="/sa/institutions/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> إضافة مؤسسة</a>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:15px;padding:12px 15px;">
<form method="GET" action="/sa/institutions" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="min-width:200px;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="search" class="form-control" value="<?= e($filters['search'] ?? '') ?>" placeholder="اسم أو كود...">
</div>
<div style="min-width:140px;">
<label class="form-label" style="font-size:12px;">النوع</label>
<select name="type" class="form-select">
<option value="">-- الكل --</option>
<option value="school" <?= ($filters['type'] ?? '') === 'school' ? 'selected' : '' ?>>مدرسة</option>
<option value="university" <?= ($filters['type'] ?? '') === 'university' ? 'selected' : '' ?>>جامعة</option>
<option value="company" <?= ($filters['type'] ?? '') === 'company' ? 'selected' : '' ?>>شركة</option>
<option value="club" <?= ($filters['type'] ?? '') === 'club' ? 'selected' : '' ?>>نادي</option>
<option value="government" <?= ($filters['type'] ?? '') === 'government' ? 'selected' : '' ?>>حكومي</option>
<option value="other" <?= ($filters['type'] ?? '') === 'other' ? 'selected' : '' ?>>أخرى</option>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:14px;height:14px;"></i> بحث</button>
</form>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>الكود</th>
<th>الاسم</th>
<th>النوع</th>
<th>المسؤول</th>
<th>الهاتف</th>
<th>عدد الحجوزات</th>
<th>الحالة</th>
<th>إجراءات</th>
</tr>
</thead>
<tbody>
<?php if (!empty($institutions)): ?>
<?php foreach ($institutions as $inst): ?>
<?php
$typeMap = ['school'=>'مدرسة','university'=>'جامعة','company'=>'شركة','club'=>'نادي','government'=>'حكومي','other'=>'أخرى'];
?>
<tr>
<td style="font-family:monospace;font-size:12px;"><?= e($inst['code']) ?></td>
<td style="font-weight:500;"><a href="/sa/institutions/<?= (int)$inst['id'] ?>"><?= e($inst['name_ar']) ?></a></td>
<td><span style="background:#EFF6FF;color:#2563EB;padding:2px 8px;border-radius:8px;font-size:11px;"><?= e($typeMap[$inst['institution_type']] ?? $inst['institution_type']) ?></span></td>
<td><?= e($inst['contact_person'] ?? '—') ?></td>
<td style="direction:ltr;text-align:right;"><?= e($inst['phone'] ?? '—') ?></td>
<td style="text-align:center;"><?= (int)($inst['booking_count'] ?? 0) ?></td>
<td>
<?php if ((int)$inst['is_active']): ?>
<span style="background:#ECFDF5;color:#059669;padding:2px 8px;border-radius:8px;font-size:11px;">نشط</span>
<?php else: ?>
<span style="background:#F3F4F6;color:#6B7280;padding:2px 8px;border-radius:8px;font-size:11px;">معطل</span>
<?php endif; ?>
</td>
<td style="white-space:nowrap;">
<a href="/sa/institutions/<?= (int)$inst['id'] ?>" class="btn btn-sm btn-outline" style="font-size:11px;">عرض</a>
<?php if (can('sa.institution.manage')): ?>
<a href="/sa/institutions/<?= (int)$inst['id'] ?>/edit" class="btn btn-sm btn-outline" style="font-size:11px;">تعديل</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="8" style="text-align:center;padding:40px;color:#6B7280;">لا توجد مؤسسات مسجلة</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</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($institution['name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<?php if (can('sa.institution.manage')): ?>
<a href="/sa/institutions/<?= (int)$institution['id'] ?>/edit" class="btn btn-outline"><i data-lucide="edit" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> تعديل</a>
<form method="POST" action="/sa/institutions/<?= (int)$institution['id'] ?>/archive" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-outline" style="color:#DC2626;" onclick="return confirm('هل تريد أرشفة هذه المؤسسة؟')"><i data-lucide="archive" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> أرشفة</button>
</form>
<?php endif; ?>
<a href="/sa/institutions" class="btn btn-outline"><i data-lucide="arrow-right" style="width:14px;height:14px;vertical-align:middle;margin-left:4px;"></i> العودة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php $typeMap = ['school'=>'مدرسة','university'=>'جامعة','company'=>'شركة','club'=>'نادي','government'=>'حكومي','other'=>'أخرى']; ?>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px;margin-bottom:15px;">
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:22px;font-weight:700;"><?= (int)($stats['total_bookings'] ?? 0) ?></div>
<div style="font-size:12px;color:#6B7280;">إجمالي الحجوزات</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#059669;"><?= (int)($stats['completed'] ?? 0) ?></div>
<div style="font-size:12px;color:#6B7280;">مكتملة</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#2563EB;"><?= (int)($stats['confirmed'] ?? 0) ?></div>
<div style="font-size:12px;color:#6B7280;">مؤكدة</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:22px;font-weight:700;color:#D97706;"><?= money((float)($stats['total_revenue'] ?? 0)) ?></div>
<div style="font-size:12px;color:#6B7280;">إجمالي الإيرادات</div>
</div>
</div>
<div class="card" style="padding:20px;margin-bottom:15px;">
<h3 style="margin-bottom:15px;border-bottom:1px solid #E5E7EB;padding-bottom:10px;">بيانات المؤسسة</h3>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:14px;">
<div><strong>الكود:</strong> <?= e($institution['code']) ?></div>
<div><strong>النوع:</strong> <?= e($typeMap[$institution['institution_type']] ?? '') ?></div>
<div><strong>المسؤول:</strong> <?= e($institution['contact_person'] ?? '—') ?></div>
<div><strong>الهاتف:</strong> <?= e($institution['phone'] ?? '—') ?></div>
<div><strong>البريد:</strong> <?= e($institution['email'] ?? '—') ?></div>
<div><strong>بداية العقد:</strong> <?= e($institution['contract_start'] ?? '—') ?></div>
<div><strong>نهاية العقد:</strong> <?= e($institution['contract_end'] ?? '—') ?></div>
<div><strong>سعر الساعة:</strong> <?= $institution['hourly_rate'] ? money((float)$institution['hourly_rate']) : '—' ?></div>
<div><strong>نسبة الخصم:</strong> <?= e($institution['discount_percent'] ?? 0) ?>%</div>
<div><strong>الحد الأقصى:</strong> <?= e($institution['max_participants'] ?? '—') ?></div>
</div>
<?php if (!empty($institution['address'])): ?>
<div style="margin-top:10px;"><strong>العنوان:</strong> <?= e($institution['address']) ?></div>
<?php endif; ?>
<?php if (!empty($institution['notes'])): ?>
<div style="margin-top:10px;"><strong>ملاحظات:</strong> <?= e($institution['notes']) ?></div>
<?php endif; ?>
</div>
<div class="card">
<h3 style="padding:15px 20px;border-bottom:1px solid #E5E7EB;margin:0;">آخر الحجوزات</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>رقم الحجز</th>
<th>التاريخ</th>
<th>المرفق</th>
<th>الوقت</th>
<th>المبلغ</th>
<th>الحالة</th>
</tr>
</thead>
<tbody>
<?php if (!empty($bookings)): ?>
<?php foreach ($bookings as $b): ?>
<tr>
<td style="font-family:monospace;font-size:12px;"><?= e($b['booking_number']) ?></td>
<td><?= e($b['booking_date']) ?></td>
<td><?= e($b['unit_name'] ?? '') ?></td>
<td><?= e($b['start_time'] ?? '') ?> - <?= e($b['end_time'] ?? '') ?></td>
<td><?= money((float)($b['total_amount'] ?? 0)) ?></td>
<td>
<?php
$bStatus = match($b['status'] ?? '') {
'confirmed' => '<span style="background:#EFF6FF;color:#2563EB;padding:2px 8px;border-radius:8px;font-size:11px;">مؤكد</span>',
'completed' => '<span style="background:#ECFDF5;color:#059669;padding:2px 8px;border-radius:8px;font-size:11px;">مكتمل</span>',
'cancelled' => '<span style="background:#FEF2F2;color:#DC2626;padding:2px 8px;border-radius:8px;font-size:11px;">ملغي</span>',
default => '<span style="background:#F3F4F6;color:#6B7280;padding:2px 8px;border-radius:8px;font-size:11px;">' . e($b['status'] ?? '') . '</span>',
};
echo $bStatus;
?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="6" style="text-align:center;padding:30px;color:#6B7280;">لا توجد حجوزات</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</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'); ?>إنشاء حصة تعويضية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sa/makeup-sessions" 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'); ?>
<div class="card" style="padding:20px;margin-bottom:15px;">
<form method="GET" action="/sa/makeup-sessions/create" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="min-width:180px;">
<label class="form-label" style="font-size:12px;">المجموعة</label>
<select name="group_id" class="form-select" data-searchable="true" required>
<option value="">-- اختر مجموعة --</option>
<?php foreach ($groups as $g): ?>
<option value="<?= (int)$g['id'] ?>" <?= ($filters['group_id'] ?? '') == $g['id'] ? 'selected' : '' ?>><?= e($g['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:180px;">
<label class="form-label" style="font-size:12px;">رقم اللاعب (ID)</label>
<input type="number" name="player_id" class="form-control" value="<?= e($filters['player_id'] ?? '') ?>" placeholder="أدخل رقم اللاعب">
</div>
<button type="submit" class="btn btn-outline">بحث عن الغياب</button>
</form>
</div>
<?php if ($player && !empty($absences)): ?>
<div class="card" style="padding:20px;">
<h3 style="margin-bottom:15px;">غيابات <?= e($player['full_name_ar'] ?? '') ?> بدون حصة تعويضية</h3>
<form method="POST" action="/sa/makeup-sessions">
<?= csrf_field() ?>
<input type="hidden" name="player_id" value="<?= (int)($filters['player_id'] ?? 0) ?>">
<input type="hidden" name="group_id" value="<?= (int)($filters['group_id'] ?? 0) ?>">
<div class="table-responsive" style="margin-bottom:15px;">
<table class="data-table">
<thead><tr><th>اختر</th><th>التاريخ</th><th>الحالة</th></tr></thead>
<tbody>
<?php foreach ($absences as $a): ?>
<tr>
<td><input type="radio" name="missed_session_date" value="<?= e($a['session_date']) ?>" required></td>
<td><?= e($a['session_date']) ?></td>
<td><?= $a['status'] === 'excused' ? 'عذر' : 'غياب' ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<div style="margin-bottom:15px;">
<label class="form-label">سبب (اختياري)</label>
<input type="text" name="reason" class="form-control" placeholder="سبب الغياب أو ملاحظات">
</div>
<button type="submit" class="btn btn-primary">إنشاء حصة تعويضية</button>
</form>
</div>
<?php elseif ($player && empty($absences)): ?>
<div class="card" style="padding:30px;text-align:center;color:#6B7280;">
لا توجد غيابات بدون حصة تعويضية لهذا اللاعب في المجموعة المحددة
</div>
<?php endif; ?>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>الحصص التعويضية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<?php if (can('sa.makeup.manage')): ?>
<a href="/sa/makeup-sessions/create" class="btn btn-primary"><i data-lucide="plus" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> حصة تعويضية جديدة</a>
<?php endif; ?>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:15px;padding:12px 15px;">
<form method="GET" action="/sa/makeup-sessions" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="min-width:140px;">
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-select">
<option value="">-- الكل --</option>
<option value="eligible" <?= ($filters['status'] ?? '') === 'eligible' ? 'selected' : '' ?>>متاحة</option>
<option value="scheduled" <?= ($filters['status'] ?? '') === 'scheduled' ? 'selected' : '' ?>>مجدولة</option>
<option value="completed" <?= ($filters['status'] ?? '') === 'completed' ? 'selected' : '' ?>>مكتملة</option>
<option value="expired" <?= ($filters['status'] ?? '') === 'expired' ? 'selected' : '' ?>>منتهية</option>
<option value="cancelled" <?= ($filters['status'] ?? '') === 'cancelled' ? 'selected' : '' ?>>ملغاة</option>
</select>
</div>
<div style="min-width:180px;">
<label class="form-label" style="font-size:12px;">المجموعة الأصلية</label>
<select name="group_id" class="form-select" data-searchable="true">
<option value="">-- الكل --</option>
<?php foreach ($groups as $g): ?>
<option value="<?= (int)$g['id'] ?>" <?= ($filters['group_id'] ?? '') == $g['id'] ? 'selected' : '' ?>><?= e($g['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="filter" style="width:14px;height:14px;"></i> تصفية</button>
</form>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>اللاعب</th>
<th>المجموعة الأصلية</th>
<th>تاريخ الغياب</th>
<th>مجموعة التعويض</th>
<th>تاريخ التعويض</th>
<th>ينتهي في</th>
<th>الحالة</th>
<th>إجراءات</th>
</tr>
</thead>
<tbody>
<?php if (!empty($makeups)): ?>
<?php foreach ($makeups as $m): ?>
<?php
$statusBadge = match($m['status']) {
'eligible' => '<span style="background:#EFF6FF;color:#2563EB;padding:2px 8px;border-radius:8px;font-size:11px;">متاحة</span>',
'scheduled' => '<span style="background:#FEF3C7;color:#D97706;padding:2px 8px;border-radius:8px;font-size:11px;">مجدولة</span>',
'completed' => '<span style="background:#ECFDF5;color:#059669;padding:2px 8px;border-radius:8px;font-size:11px;">مكتملة</span>',
'expired' => '<span style="background:#FEF2F2;color:#DC2626;padding:2px 8px;border-radius:8px;font-size:11px;">منتهية</span>',
'cancelled' => '<span style="background:#F3F4F6;color:#6B7280;padding:2px 8px;border-radius:8px;font-size:11px;">ملغاة</span>',
default => e($m['status']),
};
?>
<tr>
<td style="font-weight:500;"><?= e($m['player_name'] ?? '') ?></td>
<td><?= e($m['original_group_name'] ?? '') ?></td>
<td><?= e($m['missed_session_date']) ?></td>
<td><?= e($m['makeup_group_name'] ?? '—') ?></td>
<td><?= e($m['makeup_date'] ?? '—') ?></td>
<td style="font-size:12px;"><?= e($m['expires_at']) ?></td>
<td><?= $statusBadge ?></td>
<td style="white-space:nowrap;">
<?php if ($m['status'] === 'eligible' && can('sa.makeup.manage')): ?>
<a href="/sa/makeup-sessions/<?= (int)$m['id'] ?>/schedule" class="btn btn-sm btn-outline" style="font-size:11px;">جدولة</a>
<form method="POST" action="/sa/makeup-sessions/<?= (int)$m['id'] ?>/cancel" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm" style="font-size:11px;color:#DC2626;" onclick="return confirm('هل أنت متأكد؟')">إلغاء</button>
</form>
<?php elseif ($m['status'] === 'scheduled' && can('sa.makeup.manage')): ?>
<form method="POST" action="/sa/makeup-sessions/<?= (int)$m['id'] ?>/complete" style="display:inline;">
<?= csrf_field() ?>
<button type="submit" class="btn btn-sm btn-primary" style="font-size:11px;">تأكيد الحضور</button>
</form>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="8" style="text-align:center;padding:40px;color:#6B7280;">لا توجد حصص تعويضية</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</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'); ?>جدولة حصة تعويضية<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sa/makeup-sessions" 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'); ?>
<div class="card" style="padding:15px;margin-bottom:15px;background:#EFF6FF;border-right:4px solid #2563EB;">
<div style="display:flex;justify-content:space-between;flex-wrap:wrap;gap:10px;">
<div>
<strong>اللاعب:</strong> <?= e($makeup['player_name'] ?? '') ?>
<span style="margin-right:15px;"><strong>المجموعة الأصلية:</strong> <?= e($makeup['group_name'] ?? '') ?></span>
</div>
<div style="font-size:13px;color:#4B5563;">
<strong>تاريخ الغياب:</strong> <?= e($makeup['missed_session_date']) ?>
| <strong>ينتهي في:</strong> <?= e($makeup['expires_at']) ?>
</div>
</div>
</div>
<div class="card" style="padding:20px;">
<form method="POST" action="/sa/makeup-sessions/<?= (int)$makeup['id'] ?>/schedule">
<?= csrf_field() ?>
<div style="margin-bottom:15px;">
<label class="form-label">المجموعة المستهدفة للتعويض</label>
<select name="makeup_group_id" class="form-select" data-searchable="true" required>
<option value="">-- اختر مجموعة --</option>
<?php foreach ($availableGroups as $g): ?>
<option value="<?= (int)$g['id'] ?>"><?= e($g['name_ar']) ?> (<?= e($g['coach_name'] ?? '') ?>)</option>
<?php endforeach; ?>
</select>
<small style="color:#6B7280;">يجب أن تكون في نفس النشاط الرياضي</small>
</div>
<div style="margin-bottom:15px;">
<label class="form-label">تاريخ التعويض</label>
<input type="date" name="makeup_date" class="form-control" required
min="<?= date('Y-m-d') ?>" max="<?= e($makeup['expires_at']) ?>">
<small style="color:#6B7280;">يجب أن يكون يوم تدريب المجموعة المختارة وقبل <?= e($makeup['expires_at']) ?></small>
</div>
<button type="submit" class="btn btn-primary">تأكيد الجدولة</button>
</form>
</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'); ?>حضور التدريب<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sa/training-attendance/report" class="btn btn-outline"><i data-lucide="bar-chart-2" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i> التقارير</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<div class="card" style="margin-bottom:15px;padding:12px 15px;">
<form method="GET" action="/sa/training-attendance" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="min-width:160px;">
<label class="form-label" style="font-size:12px;">التاريخ</label>
<input type="date" name="date" class="form-control" value="<?= e($date) ?>">
</div>
<div style="min-width:160px;">
<label class="form-label" style="font-size:12px;">النشاط</label>
<select name="discipline_id" class="form-select">
<option value="">-- الكل --</option>
<?php foreach ($disciplines as $d): ?>
<option value="<?= (int) $d['id'] ?>" <?= ($filters['discipline_id'] ?? '') == $d['id'] ? 'selected' : '' ?>><?= e($d['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="filter" style="width:14px;height:14px;vertical-align:middle;"></i> عرض</button>
</form>
</div>
<div class="card" style="margin-bottom:15px;padding:15px;background:#F0FDF4;border-right:4px solid #059669;">
<strong>مجموعات التدريب ليوم:</strong> <?= e($date) ?>
<span style="color:#6B7280;margin-right:10px;">(<?= ['الأحد','الإثنين','الثلاثاء','الأربعاء','الخميس','الجمعة','السبت'][(int)date('w', strtotime($date))] ?>)</span>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>الوقت</th>
<th>المجموعة</th>
<th>النشاط</th>
<th>المدرب</th>
<th>المرفق</th>
<th>اللاعبين</th>
<th>الحضور</th>
<th>إجراءات</th>
</tr>
</thead>
<tbody>
<?php if (!empty($groups)): ?>
<?php foreach ($groups as $g): ?>
<tr>
<td style="font-weight:600;white-space:nowrap;"><?= e($g['start_time'] ?? '') ?> - <?= e($g['end_time'] ?? '') ?></td>
<td><?= e($g['name_ar'] ?? '') ?></td>
<td><span style="background:#EFF6FF;color:#1D4ED8;padding:2px 8px;border-radius:8px;font-size:11px;"><?= e($g['discipline_name'] ?? '') ?></span></td>
<td><?= e($g['coach_name'] ?? '—') ?></td>
<td><?= e($g['unit_name'] ?? '—') ?></td>
<td style="text-align:center;"><?= (int) ($g['current_count'] ?? 0) ?></td>
<td style="text-align:center;">
<?php if ((int)($g['attendance_recorded'] ?? 0) > 0): ?>
<span style="background:#ECFDF5;color:#059669;padding:3px 10px;border-radius:10px;font-size:12px;">✓ مسجل (<?= (int)$g['attendance_recorded'] ?>)</span>
<?php else: ?>
<span style="background:#FEF3C7;color:#92400E;padding:3px 10px;border-radius:10px;font-size:12px;">لم يسجل</span>
<?php endif; ?>
</td>
<td>
<?php if (can('sa.attendance.manage')): ?>
<a href="/sa/training-attendance/record/<?= (int) $g['id'] ?>?date=<?= e($date) ?>" class="btn btn-sm btn-primary" style="font-size:12px;padding:4px 12px;">
<i data-lucide="clipboard-check" style="width:13px;height:13px;vertical-align:middle;margin-left:4px;"></i> تسجيل
</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="8" style="text-align:center;padding:40px;color:#6B7280;">
<i data-lucide="calendar-x" style="width:36px;height:36px;color:#D1D5DB;display:block;margin:0 auto 10px;"></i>
لا توجد مجموعات تدريب في هذا اليوم
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</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($group['name_ar']) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sa/training-attendance?date=<?= e($date) ?>" 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'); ?>
<div class="card" style="margin-bottom:15px;padding:15px;background:#EFF6FF;border-right:4px solid #2563EB;">
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:10px;">
<div>
<strong><?= e($group['name_ar']) ?></strong>
<span style="color:#6B7280;margin-right:8px;"><?= e($group['discipline_name'] ?? '') ?><?= e($group['coach_name'] ?? '') ?></span>
</div>
<div style="font-size:13px;color:#4B5563;">
<strong>التاريخ:</strong> <?= e($date) ?>
<?php if (!empty($sessions)): ?>
| <strong>الوقت:</strong> <?= e($sessions[0]['start_time'] ?? '') ?> - <?= e($sessions[0]['end_time'] ?? '') ?>
| <strong>المرفق:</strong> <?= e($sessions[0]['unit_name'] ?? '') ?>
<?php endif; ?>
</div>
</div>
</div>
<?php if (empty($players)): ?>
<div class="card" style="padding:40px;text-align:center;color:#6B7280;">
<i data-lucide="users" style="width:36px;height:36px;color:#D1D5DB;display:block;margin:0 auto 10px;"></i>
لا يوجد لاعبين مسجلين في هذه المجموعة
</div>
<?php else: ?>
<form method="POST" action="/sa/training-attendance/record/<?= (int) $group['id'] ?>">
<?= csrf_field() ?>
<input type="hidden" name="session_date" value="<?= e($date) ?>">
<div class="card" style="margin-bottom:15px;padding:10px 15px;display:flex;gap:15px;align-items:center;flex-wrap:wrap;">
<button type="button" onclick="setAll('present')" class="btn btn-sm" style="background:#ECFDF5;color:#059669;border:1px solid #059669;">تحضير الكل</button>
<button type="button" onclick="setAll('absent')" class="btn btn-sm" style="background:#FEF2F2;color:#DC2626;border:1px solid #DC2626;">غياب الكل</button>
<span style="color:#6B7280;font-size:13px;">عدد اللاعبين: <strong><?= count($players) ?></strong></span>
</div>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th style="width:40px;">#</th>
<th>اللاعب</th>
<th>الكود</th>
<th>الحالة الطبية</th>
<th style="min-width:180px;">الحضور</th>
</tr>
</thead>
<tbody>
<?php foreach ($players as $i => $p): ?>
<?php
$existing = $existingAttendance[(int) $p['player_id']] ?? null;
$currentStatus = $existing ? $existing['status'] : 'present';
$medicalBadge = match($p['medical_status'] ?? 'pending') {
'fit' => '<span style="background:#ECFDF5;color:#059669;padding:2px 8px;border-radius:8px;font-size:11px;">لائق</span>',
'conditional' => '<span style="background:#FEF3C7;color:#92400E;padding:2px 8px;border-radius:8px;font-size:11px;">مشروط</span>',
'expired' => '<span style="background:#FEF2F2;color:#DC2626;padding:2px 8px;border-radius:8px;font-size:11px;">منتهي</span>',
default => '<span style="background:#F3F4F6;color:#6B7280;padding:2px 8px;border-radius:8px;font-size:11px;">بانتظار</span>',
};
?>
<tr>
<td><?= $i + 1 ?></td>
<td style="font-weight:500;"><?= e($p['full_name_ar']) ?></td>
<td style="font-size:12px;color:#6B7280;"><?= e($p['registration_serial'] ?? '—') ?></td>
<td><?= $medicalBadge ?></td>
<td>
<input type="hidden" name="player_ids[]" value="<?= (int) $p['player_id'] ?>">
<div style="display:flex;gap:4px;">
<?php
$options = [
'present' => ['label' => 'حاضر', 'color' => '#059669', 'bg' => '#ECFDF5'],
'absent' => ['label' => 'غائب', 'color' => '#DC2626', 'bg' => '#FEF2F2'],
'late' => ['label' => 'متأخر', 'color' => '#D97706', 'bg' => '#FEF3C7'],
'excused' => ['label' => 'عذر', 'color' => '#6B7280', 'bg' => '#F3F4F6'],
];
foreach ($options as $val => $opt): ?>
<label style="cursor:pointer;">
<input type="radio" name="statuses[<?= $i ?>]" value="<?= $val ?>" <?= $currentStatus === $val ? 'checked' : '' ?> class="status-radio" data-index="<?= $i ?>" style="display:none;">
<span class="att-btn att-btn-<?= $val ?>" style="display:inline-block;padding:4px 10px;border-radius:8px;font-size:12px;border:2px solid transparent;transition:all 0.15s;"><?= $opt['label'] ?></span>
</label>
<?php endforeach; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<div style="margin-top:15px;display:flex;justify-content:end;">
<button type="submit" class="btn btn-primary" style="padding:10px 30px;font-size:15px;">
<i data-lucide="save" style="width:16px;height:16px;vertical-align:middle;margin-left:6px;"></i> حفظ الحضور
</button>
</div>
</form>
<?php endif; ?>
<style>
.att-btn { background:#F9FAFB; color:#6B7280; }
.att-btn-present { background:#ECFDF5; color:#059669; }
.att-btn-absent { background:#FEF2F2; color:#DC2626; }
.att-btn-late { background:#FEF3C7; color:#D97706; }
.att-btn-excused { background:#F3F4F6; color:#6B7280; }
input.status-radio:checked + .att-btn { border-color:currentColor; font-weight:600; box-shadow:0 0 0 1px currentColor; }
</style>
<script>
function setAll(status) {
document.querySelectorAll('.status-radio[value="' + status + '"]').forEach(r => { r.checked = true; });
}
document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>تقرير حضور التدريب<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/sa/training-attendance" 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'); ?>
<div class="card" style="margin-bottom:15px;padding:12px 15px;">
<form method="GET" action="/sa/training-attendance/report" style="display:flex;gap:10px;align-items:end;flex-wrap:wrap;">
<div style="min-width:180px;">
<label class="form-label" style="font-size:12px;">المجموعة</label>
<select name="group_id" class="form-select" data-searchable="true" data-placeholder="-- اختر مجموعة --">
<option value="">-- الكل --</option>
<?php foreach ($groups as $g): ?>
<option value="<?= (int)$g['id'] ?>" <?= ($filters['group_id'] ?? '') == $g['id'] ? 'selected' : '' ?>><?= e($g['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:120px;">
<label class="form-label" style="font-size:12px;">من</label>
<input type="date" name="date_from" class="form-control" value="<?= e($filters['date_from'] ?? '') ?>">
</div>
<div style="min-width:120px;">
<label class="form-label" style="font-size:12px;">إلى</label>
<input type="date" name="date_to" class="form-control" value="<?= e($filters['date_to'] ?? '') ?>">
</div>
<button type="submit" class="btn btn-primary"><i data-lucide="search" style="width:14px;height:14px;vertical-align:middle;"></i> بحث</button>
</form>
</div>
<?php if ($summary): ?>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:10px;margin-bottom:15px;">
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#1F2937;"><?= (int)$summary['total'] ?></div>
<div style="font-size:12px;color:#6B7280;">إجمالي السجلات</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#059669;"><?= (int)$summary['present_count'] ?></div>
<div style="font-size:12px;color:#6B7280;">حاضر</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#DC2626;"><?= (int)$summary['absent_count'] ?></div>
<div style="font-size:12px;color:#6B7280;">غائب</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#D97706;"><?= (int)$summary['late_count'] ?></div>
<div style="font-size:12px;color:#6B7280;">متأخر</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#6B7280;"><?= (int)$summary['excused_count'] ?></div>
<div style="font-size:12px;color:#6B7280;">عذر</div>
</div>
<div class="card" style="padding:15px;text-align:center;">
<div style="font-size:24px;font-weight:700;color:#7C3AED;"><?= (int)$summary['makeup_count'] ?></div>
<div style="font-size:12px;color:#6B7280;">تعويضي</div>
</div>
</div>
<?php endif; ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>التاريخ</th>
<th>اللاعب</th>
<th>المجموعة</th>
<th>الحالة</th>
</tr>
</thead>
<tbody>
<?php if (!empty($records)): ?>
<?php foreach ($records as $r): ?>
<tr>
<td><?= e($r['session_date']) ?></td>
<td><?= e($r['player_name'] ?? '') ?></td>
<td><?= e($r['group_name'] ?? '') ?></td>
<td>
<?php
$statusBadge = match($r['status']) {
'present' => '<span style="background:#ECFDF5;color:#059669;padding:2px 8px;border-radius:8px;font-size:11px;">حاضر</span>',
'absent' => '<span style="background:#FEF2F2;color:#DC2626;padding:2px 8px;border-radius:8px;font-size:11px;">غائب</span>',
'late' => '<span style="background:#FEF3C7;color:#D97706;padding:2px 8px;border-radius:8px;font-size:11px;">متأخر</span>',
'excused' => '<span style="background:#F3F4F6;color:#6B7280;padding:2px 8px;border-radius:8px;font-size:11px;">عذر</span>',
'makeup' => '<span style="background:#EDE9FE;color:#7C3AED;padding:2px 8px;border-radius:8px;font-size:11px;">تعويضي</span>',
default => '<span style="background:#F3F4F6;color:#6B7280;padding:2px 8px;border-radius:8px;font-size:11px;">' . e($r['status']) . '</span>',
};
echo $statusBadge;
?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr><td colspan="4" style="text-align:center;padding:40px;color:#6B7280;">اختر مجموعة أو لاعب لعرض التقرير</td></tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
<script>document.addEventListener('DOMContentLoaded', function() { if (typeof lucide !== 'undefined') lucide.createIcons(); });</script>
<?php $__template->endSection(); ?>
...@@ -37,6 +37,9 @@ MenuRegistry::register('sports_activity', [ ...@@ -37,6 +37,9 @@ MenuRegistry::register('sports_activity', [
['label_ar' => 'أسعار الأكاديميات', 'label_en' => 'Academy Pricing', 'route' => '/sa/academy-pricing','permission' => 'sa.pricing.view', 'order' => 16.5], ['label_ar' => 'أسعار الأكاديميات', 'label_en' => 'Academy Pricing', 'route' => '/sa/academy-pricing','permission' => 'sa.pricing.view', 'order' => 16.5],
['label_ar' => 'الاشتراكات', 'label_en' => 'Subscriptions', 'route' => '/sa/subscriptions', 'permission' => 'sa.subscription.view', 'order' => 17], ['label_ar' => 'الاشتراكات', 'label_en' => 'Subscriptions', 'route' => '/sa/subscriptions', 'permission' => 'sa.subscription.view', 'order' => 17],
['label_ar' => 'الحضور', 'label_en' => 'Attendance', 'route' => '/sa/attendance', 'permission' => 'sa.attendance.view', 'order' => 18], ['label_ar' => 'الحضور', 'label_en' => 'Attendance', 'route' => '/sa/attendance', 'permission' => 'sa.attendance.view', 'order' => 18],
['label_ar' => 'حضور التدريب', 'label_en' => 'Training Attendance','route' => '/sa/training-attendance','permission' => 'sa.attendance.view','order' => 18.5],
['label_ar' => 'الحصص التعويضية', 'label_en' => 'Makeup Sessions', 'route' => '/sa/makeup-sessions','permission' => 'sa.makeup.view', 'order' => 18.7],
['label_ar' => 'المؤسسات', 'label_en' => 'Institutions', 'route' => '/sa/institutions', 'permission' => 'sa.institution.view', 'order' => 18.9],
['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],
...@@ -111,6 +114,11 @@ PermissionRegistry::register('sports_activity', [ ...@@ -111,6 +114,11 @@ PermissionRegistry::register('sports_activity', [
'sa.swimming.dashboard' => ['ar' => 'لوحة تحكم السباحة', 'en' => 'Swimming Dashboard'], 'sa.swimming.dashboard' => ['ar' => 'لوحة تحكم السباحة', 'en' => 'Swimming Dashboard'],
'sa.swimming.register' => ['ar' => 'تسجيل لاعب سباحة', 'en' => 'Register Swimming Player'], 'sa.swimming.register' => ['ar' => 'تسجيل لاعب سباحة', 'en' => 'Register Swimming Player'],
'sa.swimming.assign' => ['ar' => 'تعيين لاعب في مجموعة سباحة','en' => 'Assign Swimming Player to Group'], 'sa.swimming.assign' => ['ar' => 'تعيين لاعب في مجموعة سباحة','en' => 'Assign Swimming Player to Group'],
'sa.makeup.view' => ['ar' => 'عرض الحصص التعويضية', 'en' => 'View Makeup Sessions'],
'sa.makeup.manage' => ['ar' => 'إدارة الحصص التعويضية', 'en' => 'Manage Makeup Sessions'],
'sa.institution.view' => ['ar' => 'عرض المؤسسات', 'en' => 'View Institutions'],
'sa.institution.manage' => ['ar' => 'إدارة المؤسسات', 'en' => 'Manage Institutions'],
'sa.enrollment.manage' => ['ar' => 'إدارة تسجيلات اللاعبين', 'en' => 'Manage Player Enrollments'],
]); ]);
// ─── Event Listeners ──────────────────────────────────────────────────────── // ─── Event Listeners ────────────────────────────────────────────────────────
......
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\Logger;
use App\Modules\SportsActivity\Services\MakeupSessionService;
class SaMakeupExpiryJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool
{
return true;
}
public function run(): array
{
$result = MakeupSessionService::expireOverdue();
Logger::info("SaMakeupExpiryJob: expired {$result['expired_count']} makeup sessions");
return ['processed' => $result['expired_count']];
}
}
<?php
declare(strict_types=1);
namespace CronJobs;
use App\Core\Database;
use App\Core\Logger;
use App\Modules\SportsActivity\Services\PlayerLifecycleService;
class SaMedicalGraceJob
{
private Database $db;
public function __construct(Database $db) { $this->db = $db; }
public function shouldRun(): bool
{
return true;
}
public function run(): array
{
$result = PlayerLifecycleService::checkMedicalGraceExpired();
Logger::info("SaMedicalGraceJob: suspended {$result['suspended']} players");
return ['processed' => $result['suspended']];
}
}
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS sa_training_attendance (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
group_id BIGINT UNSIGNED NOT NULL,
player_id BIGINT UNSIGNED NOT NULL,
session_date DATE NOT NULL,
day_of_week TINYINT UNSIGNED NOT NULL COMMENT '0=Sun,1=Mon...6=Sat',
schedule_id BIGINT UNSIGNED NULL COMMENT 'ref sa_group_schedule',
status ENUM('present','absent','excused','late','makeup') NOT NULL DEFAULT 'absent',
check_in_time TIME NULL,
check_out_time TIME NULL,
excuse_reason VARCHAR(500) NULL,
makeup_session_id BIGINT UNSIGNED NULL COMMENT 'ref sa_makeup_sessions if status=makeup',
recorded_by BIGINT UNSIGNED NULL,
notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_ta_group_date (group_id, session_date),
INDEX idx_ta_player_date (player_id, session_date),
INDEX idx_ta_status (status),
UNIQUE KEY uq_ta_player_session (group_id, player_id, session_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS sa_training_attendance"
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS sa_makeup_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
player_id BIGINT UNSIGNED NOT NULL,
original_group_id BIGINT UNSIGNED NOT NULL,
missed_session_date DATE NOT NULL,
missed_reason VARCHAR(500) NULL,
makeup_group_id BIGINT UNSIGNED NULL COMMENT 'group where makeup will be taken',
makeup_date DATE NULL,
status ENUM('eligible','scheduled','completed','expired','cancelled') NOT NULL DEFAULT 'eligible',
expires_at DATE NOT NULL COMMENT 'deadline to use this makeup',
scheduled_by BIGINT UNSIGNED NULL,
completed_at TIMESTAMP NULL,
notes TEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by BIGINT UNSIGNED NULL,
INDEX idx_ms_player (player_id),
INDEX idx_ms_status (status),
INDEX idx_ms_expires (expires_at),
INDEX idx_ms_original_group (original_group_id, missed_session_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS sa_makeup_sessions"
];
<?php
declare(strict_types=1);
return [
'up' => "
CREATE TABLE IF NOT EXISTS sa_institutions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(30) NOT NULL,
name_ar VARCHAR(300) NOT NULL,
name_en VARCHAR(300) NULL,
institution_type ENUM('school','university','company','club','government','other') NOT NULL DEFAULT 'school',
contact_person VARCHAR(200) NULL,
phone VARCHAR(30) NULL,
email VARCHAR(200) NULL,
address TEXT NULL,
contract_start DATE NULL,
contract_end DATE NULL,
hourly_rate DECIMAL(10,2) NULL COMMENT 'default rate per hour',
discount_percent DECIMAL(5,2) NOT NULL DEFAULT 0.00,
max_participants INT UNSIGNED NULL,
notes TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
is_archived TINYINT(1) NOT NULL DEFAULT 0,
archived_at TIMESTAMP NULL,
archived_by BIGINT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
created_by BIGINT UNSIGNED NULL,
updated_by BIGINT UNSIGNED NULL,
UNIQUE KEY uq_inst_code (code),
INDEX idx_inst_type (institution_type),
INDEX idx_inst_active (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
'down' => "DROP TABLE IF EXISTS sa_institutions"
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE sa_disciplines
ADD COLUMN sport_type ENUM('training','recreational') NOT NULL DEFAULT 'training' AFTER category",
'down' => "
ALTER TABLE sa_disciplines DROP COLUMN sport_type"
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE sa_bookings
ADD COLUMN institution_id BIGINT UNSIGNED NULL AFTER organization_contact,
ADD INDEX idx_bk_institution (institution_id)",
'down' => "
ALTER TABLE sa_bookings
DROP INDEX idx_bk_institution,
DROP COLUMN institution_id"
];
<?php
declare(strict_types=1);
return [
'up' => "
ALTER TABLE sa_subscriptions
ADD COLUMN auto_generated TINYINT(1) NOT NULL DEFAULT 0 AFTER receipt_number,
ADD COLUMN renewal_of_id BIGINT UNSIGNED NULL AFTER auto_generated,
ADD COLUMN medical_verified TINYINT(1) NOT NULL DEFAULT 0 AFTER renewal_of_id",
'down' => "
ALTER TABLE sa_subscriptions
DROP COLUMN medical_verified,
DROP COLUMN renewal_of_id,
DROP COLUMN auto_generated"
];
# Sports Module — Production Execution Plan
## Document Purpose
This is a living engineering specification that transforms the Sports Module System Design into implementable phases. It resolves the fragmentation problem (17 overlapping modules), fills design gaps, defines exact database schemas, specifies UX flows, and enumerates edge cases.
**Canonical Module:** `app/Modules/SportsActivity/` — all new work happens here. Satellite modules are deprecated in-place (routes redirect, no data migration needed initially).
---
## Guiding Principles
1. **Schema is truth** — Every table is defined once here. Migrations reference this document. No column exists without being in this plan.
2. **State machines are explicit** — Every entity with a `status` column has a formal state machine with allowed transitions.
3. **No orphan records** — Foreign keys are enforced. Soft-delete cascades are documented.
4. **Treasury is the financial authority** — Sports module creates requests, listens for confirmations. Never stores payment state independently.
5. **One source per concept** — Players, Groups, Coaches, Academies, Facilities each live in exactly one table.
6. **Idempotent migrations** — Check existence before creating. Use `IF NOT EXISTS` and `information_schema` queries.
---
## Phase 0: Schema Alignment & Consolidation
### Objective
Establish the canonical database schema. Identify which existing tables are the source of truth. Create alignment migrations that add missing columns without breaking existing data.
### 0.1 Canonical Table Map
Every table below is either EXISTING (keep as-is), EXISTING+ALTER (add columns), or NEW (create from scratch).
#### Core Entities
| Table | Status | Source Module |
|-------|--------|--------------|
| `sa_disciplines` | EXISTING | SportsActivity |
| `sa_facilities` | EXISTING | SportsActivity |
| `sa_facility_units` | EXISTING | SportsActivity |
| `sa_programs` | EXISTING | SportsActivity |
| `sa_groups` | EXISTING | SportsActivity |
| `sa_coaches` | EXISTING | SportsActivity |
| `sa_players` | EXISTING | SportsActivity |
| `sa_academies` | EXISTING | SportsActivity |
| `sa_academy_contracts` | EXISTING | SportsActivity |
#### Scheduling & Attendance
| Table | Status | Source Module |
|-------|--------|--------------|
| `sa_group_schedules` | EXISTING | SportsActivity |
| `facility_grids` | EXISTING | FacilityGrids |
| `facility_zone_schedules` | EXISTING | FacilityGrids |
| `facility_monthly_plans` | EXISTING | FacilityGrids |
| `sa_attendance` | NEW | — |
| `sa_makeup_sessions` | NEW | — |
#### Bookings & Subscriptions
| Table | Status | Source Module |
|-------|--------|--------------|
| `sa_bookings` | EXISTING | SportsActivity |
| `sa_subscriptions` | EXISTING | SportsActivity |
| `sa_institution_bookings` | NEW | — |
| `sa_institutions` | NEW | — |
| `sa_pricing_rules` | EXISTING | SportsActivity |
#### Player Lifecycle
| Table | Status | Source Module |
|-------|--------|--------------|
| `sa_player_documents` | EXISTING | SportsActivity |
| `sa_player_medical` | NEW | — |
| `sa_player_assignments` | NEW | — |
| `sa_group_players` | EXISTING | SportsActivity |
| `sa_waitlist` | EXISTING | SportsActivity |
### 0.2 Schema Mismatch Prevention Protocol
**Rule 1: Single Source of Column Truth**
Every migration file MUST reference this document's schema definition. Before writing a migration:
1. Check this document for the canonical column list.
2. Check the live database with `DESCRIBE table_name` via SSH.
3. If they conflict, trust the live DB and update this document.
**Rule 2: Defensive Migrations**
```php
// ALWAYS check before adding columns
$exists = $db->selectOne(
"SELECT 1 FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ? AND column_name = ?",
['sa_players', 'medical_status']
);
if (!$exists) {
$db->raw("ALTER TABLE sa_players ADD COLUMN medical_status ...");
}
```
**Rule 3: Migration Naming Convention**
```
Phase_25_001_sports_create_attendance_table.php
Phase_25_002_sports_create_makeup_sessions_table.php
Phase_25_003_sports_create_institutions_table.php
Phase_25_004_sports_add_medical_columns_to_players.php
Phase_25_005_sports_create_player_assignments_table.php
```
All sports-phase migrations use Phase_25_XXX prefix.
**Rule 4: Schema Validation Command**
Add a CLI command that compares this document's schema against the live database and reports drift:
```bash
php cli.php schema:validate sports
```
Output: list of columns defined here but missing from DB, and columns in DB not defined here.
---
## Phase 1: Player Lifecycle & State Machine
### 1.1 Player State Machine
```
┌──────────────┐
Register │ POTENTIAL │
─────────► │ (new entry) │
└──────┬───────┘
│ Assign to Program
┌──────────────┐
│ AWAITING │◄──── Cancel (if unpaid)
│ PAYMENT │────► POTENTIAL
└──────┬───────┘
│ Treasury confirms payment
┌──────────────┐
Renew ◄───│ ACTIVE │────► SUSPENDED (admin action)
(loop) │ │────► INACTIVE (subscription expired + grace)
└──────────────┘
│ Resume / Reactivate
┌──────┴───────┐
│ SUSPENDED │
└──────────────┘
INACTIVE ──► POTENTIAL (re-assignment flow)
ANY ──► ARCHIVED (admin only, preserves history)
```
**Allowed Transitions:**
| From | To | Trigger | Permission Required |
|------|----|---------|-------------------|
| — | POTENTIAL | Registration | `sa.player.create` |
| POTENTIAL | AWAITING_PAYMENT | Assignment + Treasury request | `sa.player.assign` |
| AWAITING_PAYMENT | ACTIVE | Treasury payment confirmed | System (EventBus) |
| AWAITING_PAYMENT | POTENTIAL | Assignment cancelled (unpaid) | `sa.player.assign` |
| ACTIVE | SUSPENDED | Admin suspend | `sa.player.suspend` |
| ACTIVE | INACTIVE | Subscription expired (auto, after grace) | System (Cron) |
| SUSPENDED | ACTIVE | Admin resume | `sa.player.suspend` |
| INACTIVE | POTENTIAL | Re-registration flow | `sa.player.assign` |
| ANY | ARCHIVED | Archive | `sa.player.archive` |
**Edge Cases:**
- Player assigned to multiple sports: each assignment has independent status. Player-level status = highest active status across all assignments.
- Payment fails after 30 days: status remains AWAITING_PAYMENT. Cron job marks as EXPIRED_REQUEST, returns to POTENTIAL.
- Player archived while AWAITING_PAYMENT: cancel pending treasury request via EventBus.
### 1.2 `sa_player_assignments` Table (NEW)
Tracks every sport/program/group assignment independently. One player can have multiple active assignments (multi-sport).
```sql
CREATE TABLE sa_player_assignments (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
player_id BIGINT UNSIGNED NOT NULL,
discipline_id INT UNSIGNED NOT NULL,
program_id INT UNSIGNED NOT NULL,
group_id INT UNSIGNED NULL,
-- State
status ENUM('pending_payment','active','suspended','expired','cancelled','transferred') NOT NULL DEFAULT 'pending_payment',
assigned_at DATETIME NOT NULL,
activated_at DATETIME NULL,
expired_at DATETIME NULL,
cancelled_at DATETIME NULL,
-- Financial
treasury_request_id BIGINT UNSIGNED NULL,
subscription_amount DECIMAL(10,2) NOT NULL,
discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
final_amount DECIMAL(10,2) NOT NULL,
-- Transfer tracking
transferred_from_id BIGINT UNSIGNED NULL,
transfer_reason TEXT NULL,
-- Metadata
assigned_by INT UNSIGNED NOT NULL,
notes TEXT NULL,
is_archived TINYINT(1) NOT NULL DEFAULT 0,
archived_at DATETIME NULL,
archived_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_player (player_id),
INDEX idx_group (group_id),
INDEX idx_status (status),
INDEX idx_discipline_program (discipline_id, program_id),
FOREIGN KEY (player_id) REFERENCES sa_players(id),
FOREIGN KEY (discipline_id) REFERENCES sa_disciplines(id),
FOREIGN KEY (program_id) REFERENCES sa_programs(id),
FOREIGN KEY (group_id) REFERENCES sa_groups(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
**Edge Cases Handled:**
- Duplicate prevention: Before creating, check `WHERE player_id = ? AND program_id = ? AND status IN ('pending_payment','active') AND is_archived = 0`
- Group capacity: Validated at service layer before insert. Race condition handled with `SELECT ... FOR UPDATE` on group row.
- Transfer: Creates new assignment with `transferred_from_id`, marks old as `status = 'transferred'`.
### 1.3 UX Flow: Player Registration
**Screen: `/sa/players/create`**
```
┌─────────────────────────────────────────────────────────────┐
│ تسجيل لاعب جديد │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─ Step 1: Member Check ─────────────────────────────────┐ │
│ │ ○ عضو في النادي ○ غير عضو │ │
│ │ │ │
│ │ [رقم العضوية ___________] [بحث] │ │
│ │ أو │ │
│ │ [الرقم القومي __________] [بحث] │ │
│ │ │ │
│ │ ── Auto-filled on match ── │ │
│ │ الاسم: أحمد محمد علي │ │
│ │ النوع: ذكر | تاريخ الميلاد: 2010-03-15 | العمر: 16 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Step 2: Personal Info (if non-member) ────────────────┐ │
│ │ [All fields from design §10.3] │ │
│ │ Guardian section auto-appears if age < 18 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Step 3: Documents ────────────────────────────────────┐ │
│ │ ☐ صورة شخصية (مطلوب) [اختر ملف] │ │
│ │ ☐ شهادة ميلاد (مطلوب) [اختر ملف] │ │
│ │ ☐ شهادة طبية [اختر ملف] │ │
│ │ ☐ بطاقة ولي الأمر [اختر ملف] │ │
│ │ [+ إضافة مستند آخر] │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ [حفظ كلاعب محتمل] │
│ │
│ ⓘ بعد الحفظ، يمكنك تعيين اللاعب لبرنامج من صفحة التعيين │
└─────────────────────────────────────────────────────────────┘
```
**UX Rules:**
- Member search is debounced (300ms) and searches by both membership_number and national_id simultaneously.
- If member found: fields are pre-filled and read-only (grey background). Only missing fields are editable.
- If duplicate National ID found: modal shows existing player profile with "هذا اللاعب مسجل بالفعل" and link.
- Guardian section: slides in with animation when DOB calculates age < configurable threshold (default 18).
- Documents: drag-and-drop zone with preview thumbnails. Max file size shown. Invalid files rejected client-side with Arabic error.
- Save button disabled until all required fields pass client-side validation.
- On save success: toast notification + redirect to assignment page with player pre-selected.
### 1.4 UX Flow: Player Assignment
**Screen: `/sa/assignments`**
```
┌─────────────────────────────────────────────────────────────┐
│ تعيين لاعب لبرنامج │
├─────────────────────────────────────────────────────────────┤
│ │
│ Step 1: اختر اللاعب │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ [🔍 بحث بالاسم أو الرقم القومي ___________] ││
│ │ ││
│ │ ┌─────┬─────────────┬────────┬──────┬────────────────┐ ││
│ │ │ صورة │ الاسم │ العمر │ النوع │ تاريخ التسجيل │ ││
│ │ ├─────┼─────────────┼────────┼──────┼────────────────┤ ││
│ │ │ 👤 │ أحمد محمد │ 12 │ ذكر │ 2026-07-20 │ ││
│ │ │ 👤 │ سارة أحمد │ 10 │ أنثى │ 2026-07-22 │ ││
│ │ └─────┴─────────────┴────────┴──────┴────────────────┘ ││
│ └─────────────────────────────────────────────────────────┘│
│ │
│ Step 2: اختر الرياضة │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ 🏊 سباحة │ │ ⚽ كرة قدم│ │ 🥋 كاراتيه│ │ 🏀 سلة │ │
│ │ 5 برامج │ │ 3 برامج │ │ 2 برامج │ │ 1 برنامج │ │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
│ │
│ Step 3: اختر البرنامج │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ برنامج 2014 │ │
│ │ عضو: 600 ج.م | غير عضو: 850 ج.م │ │
│ │ المجموعات: 3 | الأماكن المتاحة: 12 │ │
│ │ ⚠️ التسجيل مغلق بعد: 2026-09-01 │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ Step 4: اختر المجموعة │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ مجموعة أ | المدرب: محمد سعيد │ │
│ │ السعة: 18/20 | الأيام: أحد - ثلاثاء - خميس │ │
│ │ التوقيت: 4:00 م - 5:30 م │ │
│ │ [✓ اختيار] │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ Step 5: ملخص مالي │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ قيمة الاشتراك: 600.00 ج.م │ │
│ │ خصم العضوية: -50.00 ج.م │ │
│ │ ───────────────────────── │ │
│ │ المطلوب: 550.00 ج.م │ │
│ │ │ │
│ │ [إرسال للخزينة وتفعيل] │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
**UX Rules:**
- Steps are a horizontal stepper (not separate pages). Each step reveals the next via slide animation.
- Sport cards: only ACTIVE sports shown. Inactive sports hidden entirely.
- Program cards: show age range if configured. If player's age doesn't match, card shows warning icon with tooltip "عمر اللاعب خارج النطاق المسموح".
- Group selection: full groups show as disabled with "المجموعة ممتلئة". If waitlist enabled, show "إضافة لقائمة الانتظار" button instead.
- Financial summary: auto-calculates based on membership status. Shows breakdown.
- "Send to Treasury" button: single click, then disabled with spinner. Prevents double-submit.
- After success: redirect to player profile showing "في انتظار الدفع" badge.
**Edge Cases:**
- Player already in this program: Show existing assignment details, offer "تحويل مجموعة" instead.
- Program registration closed: Show disabled card with explanation. Manager role sees "تجاوز" override button.
- Group at capacity: Disabled unless `sa.assignment.override_capacity` permission.
- Player age outside range: Warning shown but assignment allowed if user has override permission.
- Concurrent capacity race: Service uses `SELECT current_count FROM sa_groups WHERE id = ? FOR UPDATE` before insert.
---
## Phase 2: Medical Management & Grace Period
### 2.1 `sa_player_medical` Table (NEW)
```sql
CREATE TABLE sa_player_medical (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
player_id BIGINT UNSIGNED NOT NULL,
-- Certificate Info
certificate_type ENUM('initial','renewal','replacement') NOT NULL,
document_path VARCHAR(500) NOT NULL,
submitted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- Review
status ENUM('pending','approved','rejected','expired') NOT NULL DEFAULT 'pending',
reviewed_by INT UNSIGNED NULL,
reviewed_at DATETIME NULL,
medical_level TINYINT UNSIGNED NULL COMMENT '1, 2, or 3',
review_notes TEXT NULL,
rejection_reason TEXT NULL,
-- Validity
approval_date DATE NULL,
expiry_date DATE NULL,
grace_period_end DATE NULL COMMENT 'Calculated: submitted_at + grace_days',
-- Metadata
is_current TINYINT(1) NOT NULL DEFAULT 1 COMMENT 'Only one current per player',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_player (player_id),
INDEX idx_status (status),
INDEX idx_expiry (expiry_date),
INDEX idx_grace (grace_period_end),
INDEX idx_current (player_id, is_current),
FOREIGN KEY (player_id) REFERENCES sa_players(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 2.2 Medical State Machine
```
PENDING ──approve──► APPROVED ──time passes──► EXPIRED
│ │
│──reject──►REJECTED │──renew (new row)──► PENDING (new)
```
### 2.3 Grace Period Logic
```php
class MedicalGracePeriodService
{
// Called by SubscriptionRenewalService before allowing renewal
public static function canRenew(int $playerId): array
{
$current = self::getCurrentMedical($playerId);
// No medical record at all
if (!$current) {
$graceDays = RuleEngine::get('MEDICAL_GRACE_DAYS', 30);
$player = Player::find($playerId);
$graceEnd = date('Y-m-d', strtotime($player['created_at'] . " + {$graceDays} days"));
if (date('Y-m-d') <= $graceEnd) {
return ['allowed' => true, 'reason' => 'grace_period', 'grace_end' => $graceEnd];
}
return ['allowed' => false, 'reason' => 'no_medical_certificate'];
}
// Certificate approved and not expired
if ($current['status'] === 'approved' && $current['expiry_date'] >= date('Y-m-d')) {
return ['allowed' => true, 'reason' => 'valid_certificate'];
}
// Certificate expired
if ($current['status'] === 'expired' || $current['expiry_date'] < date('Y-m-d')) {
return ['allowed' => false, 'reason' => 'certificate_expired'];
}
// Certificate pending review — check grace period
if ($current['status'] === 'pending' && $current['grace_period_end'] >= date('Y-m-d')) {
return ['allowed' => true, 'reason' => 'grace_period', 'grace_end' => $current['grace_period_end']];
}
return ['allowed' => false, 'reason' => 'grace_period_exceeded'];
}
}
```
**Edge Cases:**
- Player uploads new cert while old is still valid: New cert becomes `is_current = 1`, old becomes `is_current = 0`. Old cert's expiry doesn't change.
- Certificate rejected: Player may upload replacement (type = 'replacement'). Grace period does NOT reset — it's still from original submission date.
- Multiple sports: Medical status is per-player (not per-assignment). One valid cert covers all sports.
- Cron job: Daily cron marks `status = 'expired'` where `expiry_date < CURDATE() AND status = 'approved'`.
### 2.4 UX: Medical Review Screen
```
┌─────────────────────────────────────────────────────────────┐
│ مراجعة الشهادات الطبية │
├──────┬──────────────────────────────────────────────────────┤
│ │ │
│ Queue│ ┌──────────────────────────────────────────────┐ │
│ │ │ 📄 شهادة طبية - أحمد محمد │ │
│ ● 12 │ │ │ │
│ طلب │ │ [Document Preview - PDF/Image viewer] │ │
│ معلق │ │ │ │
│ │ │ اللاعب: أحمد محمد علي │ │
│ ──── │ │ الرياضة: سباحة | البرنامج: 2014 │ │
│ │ │ تاريخ الرفع: 2026-08-01 │ │
│ List │ │ فترة السماح تنتهي: 2026-08-31 │ │
│ here │ │ │ │
│ │ │ ┌────────────────────────────────────────┐ │ │
│ │ │ │ المستوى الطبي: ○ 1 ○ 2 ○ 3 │ │ │
│ │ │ │ تاريخ الانتهاء: [__________] 📅 │ │ │
│ │ │ │ ملاحظات: [________________________] │ │ │
│ │ │ └────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ │ [✓ اعتماد] [✗ رفض] [↺ طلب استبدال] │ │
│ │ └──────────────────────────────────────────────┘ │
└──────┴──────────────────────────────────────────────────────┘
```
**UX Rules:**
- Left panel: queue of pending certificates sorted by grace_period_end (most urgent first).
- Urgency indicators: red dot = grace expires in < 7 days, yellow = < 14 days.
- Document viewer: inline PDF/image viewer, no download required.
- Reject requires: reason (dropdown of common reasons + free text).
- Approve requires: medical level selection + expiry date.
- Keyboard shortcuts: `A` = approve, `R` = reject, `N` = next in queue.
---
## Phase 3: Attendance System
### 3.1 `sa_attendance` Table (NEW)
```sql
CREATE TABLE sa_attendance (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
-- Session identification
group_id INT UNSIGNED NOT NULL,
session_date DATE NOT NULL,
schedule_id INT UNSIGNED NULL COMMENT 'FK to sa_group_schedules',
-- Player record
player_id BIGINT UNSIGNED NOT NULL,
assignment_id BIGINT UNSIGNED NOT NULL,
-- Attendance data
status ENUM('present','absent','excused_accepted','excused_rejected','late','left_early') NOT NULL,
arrival_time TIME NULL,
departure_time TIME NULL,
minutes_late SMALLINT UNSIGNED NULL,
excuse_reason TEXT NULL,
-- Metadata
recorded_by INT UNSIGNED NOT NULL,
recorded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
modified_by INT UNSIGNED NULL,
modified_at DATETIME NULL,
modification_reason TEXT NULL,
-- Cancellation (session-level, stored per-row for query simplicity)
session_cancelled TINYINT(1) NOT NULL DEFAULT 0,
cancellation_reason VARCHAR(255) NULL,
UNIQUE KEY uk_player_session (player_id, group_id, session_date),
INDEX idx_group_date (group_id, session_date),
INDEX idx_player (player_id),
INDEX idx_status (status),
INDEX idx_date (session_date),
FOREIGN KEY (player_id) REFERENCES sa_players(id),
FOREIGN KEY (group_id) REFERENCES sa_groups(id),
FOREIGN KEY (assignment_id) REFERENCES sa_player_assignments(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
**Unique constraint `uk_player_session`** prevents recording attendance twice for the same player on the same date in the same group. This is the primary schema-level guard against duplicates.
### 3.2 Attendance Recording UX
```
┌─────────────────────────────────────────────────────────────┐
│ تسجيل الحضور — الخميس 2026-08-05 │
├─────────────────────────────────────────────────────────────┤
│ │
│ اختر المجموعة: │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ 🏊 سباحة - مجموعة أ │ │ ⚽ كرة قدم - مجموعة ب │ │
│ │ 4:00 م - 5:30 م │ │ 5:00 م - 6:30 م │ │
│ │ المدرب: محمد سعيد │ │ المدرب: أحمد فتحي │ │
│ │ اللاعبون: 18 │ │ اللاعبون: 22 │ │
│ │ [⚠️ لم يُسجل الحضور] │ │ [✓ تم التسجيل] │ │
│ └─────────────────────────┘ └─────────────────────────┘ │
│ │
│ ══════════════════════════════════════════════════════════ │
│ │
│ سباحة - مجموعة أ | 4:00 م - 5:30 م │
│ │
│ [تحضير الكل ✓] [غياب الكل ✗] [مسح] │
│ │
│ ┌─────┬──────────────┬─────────────────────────────────┐ │
│ │ # │ اللاعب │ الحالة │ │
│ ├─────┼──────────────┼─────────────────────────────────┤ │
│ │ 1 │ أحمد محمد │ ○حاضر ○غائب ○عذر مقبول ○مرفوض │ │
│ │ 2 │ سارة أحمد │ ○حاضر ○غائب ○عذر مقبول ○مرفوض │ │
│ │ 3 │ يوسف خالد │ ●حاضر ○غائب ○عذر مقبول ○مرفوض │ │
│ │ ... │ │ │ │
│ └─────┴──────────────┴─────────────────────────────────┘ │
│ │
│ [💾 حفظ الحضور] [🚫 إلغاء الحصة] │
│ │
└─────────────────────────────────────────────────────────────┘
```
**UX Rules:**
- Only groups with active schedules for today appear (JOIN with `sa_group_schedules` where day matches).
- "Mark all present" is the default action (most common). Staff then mark exceptions.
- Excuse selection expands an inline text input for the reason.
- Session cancellation requires selecting a reason from dropdown (weather/maintenance/holiday/trainer/emergency).
- Cancelled session: all attendance records get `session_cancelled = 1`. Does NOT count toward statistics.
- Auto-save draft every 30 seconds (localStorage) to prevent data loss on page close.
- Mobile-optimized: large tap targets for radio buttons, swipe gestures for scrolling players.
**Edge Cases:**
- Trainer records attendance, manager edits later: modification fields track who/when/why.
- Player added to group mid-session: appears in list immediately (AJAX poll every 60s or manual refresh).
- Player removed from group: historical attendance records remain. Future dates don't show player.
- Double recording attempt: UNIQUE constraint rejects. UI shows "تم تسجيل الحضور مسبقاً لهذه الحصة".
- Schedule change after attendance recorded: attendance remains linked to original date, not to schedule.
---
## Phase 4: Make-up Sessions (تعويض الحصص)
### 4.1 `sa_makeup_sessions` Table (NEW)
```sql
CREATE TABLE sa_makeup_sessions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
-- Link to original absence
attendance_id BIGINT UNSIGNED NOT NULL COMMENT 'The excused_accepted attendance record',
player_id BIGINT UNSIGNED NOT NULL,
original_group_id INT UNSIGNED NOT NULL,
original_session_date DATE NOT NULL,
-- Makeup assignment
makeup_group_id INT UNSIGNED NULL COMMENT 'May differ from original group',
makeup_session_date DATE NULL,
-- State
status ENUM('eligible','scheduled','completed','expired','cancelled') NOT NULL DEFAULT 'eligible',
eligibility_expires_at DATE NOT NULL,
-- Attendance (when completed)
makeup_attendance_status ENUM('present','absent','excused_accepted','excused_rejected') NULL,
-- Metadata
scheduled_by INT UNSIGNED NULL,
scheduled_at DATETIME NULL,
completed_at DATETIME NULL,
cancelled_by INT UNSIGNED NULL,
cancelled_at DATETIME NULL,
cancellation_reason TEXT NULL,
notes TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_attendance (attendance_id),
INDEX idx_player (player_id),
INDEX idx_status (status),
INDEX idx_expires (eligibility_expires_at),
INDEX idx_makeup_group_date (makeup_group_id, makeup_session_date),
FOREIGN KEY (attendance_id) REFERENCES sa_attendance(id),
FOREIGN KEY (player_id) REFERENCES sa_players(id),
FOREIGN KEY (original_group_id) REFERENCES sa_groups(id),
FOREIGN KEY (makeup_group_id) REFERENCES sa_groups(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 4.2 Make-up Business Logic
```php
class MakeupSessionService
{
// Auto-triggered when attendance saved with status = 'excused_accepted'
public static function createEligibility(int $attendanceId): void
{
$attendance = Database::selectOne("SELECT * FROM sa_attendance WHERE id = ?", [$attendanceId]);
if ($attendance['status'] !== 'excused_accepted') return;
// Check no existing makeup for this attendance
$existing = Database::selectOne(
"SELECT 1 FROM sa_makeup_sessions WHERE attendance_id = ?",
[$attendanceId]
);
if ($existing) return;
$graceDays = RuleEngine::get('MAKEUP_ELIGIBILITY_DAYS', 30);
$expiresAt = date('Y-m-d', strtotime($attendance['session_date'] . " + {$graceDays} days"));
Database::insert('sa_makeup_sessions', [
'attendance_id' => $attendanceId,
'player_id' => $attendance['player_id'],
'original_group_id' => $attendance['group_id'],
'original_session_date' => $attendance['session_date'],
'status' => 'eligible',
'eligibility_expires_at' => $expiresAt,
]);
}
// Scheduling a makeup: validate capacity of target group
public static function schedule(int $makeupId, int $groupId, string $date, int $userId): array
{
$makeup = Database::selectOne("SELECT * FROM sa_makeup_sessions WHERE id = ?", [$makeupId]);
if ($makeup['status'] !== 'eligible') {
return ['success' => false, 'error' => 'not_eligible'];
}
if ($makeup['eligibility_expires_at'] < date('Y-m-d')) {
Database::update('sa_makeup_sessions', ['status' => 'expired'], 'id = ?', [$makeupId]);
return ['success' => false, 'error' => 'expired'];
}
// Validate group capacity for that date (existing players + already-scheduled makeups)
$group = Database::selectOne("SELECT * FROM sa_groups WHERE id = ?", [$groupId]);
$currentCount = self::getEffectiveCountForDate($groupId, $date);
if ($currentCount >= $group['max_capacity']) {
return ['success' => false, 'error' => 'group_full'];
}
Database::update('sa_makeup_sessions', [
'makeup_group_id' => $groupId,
'makeup_session_date' => $date,
'status' => 'scheduled',
'scheduled_by' => $userId,
'scheduled_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$makeupId]);
return ['success' => true];
}
}
```
**Edge Cases:**
- Player misses the makeup session: status stays 'scheduled', makeup_attendance_status = 'absent'. The makeup opportunity is consumed (no second chance) unless manager overrides.
- Player misses makeup WITH accepted excuse: Creates a NEW makeup record for that new absence — but only if club policy allows cascading makeups (configurable via `MAKEUP_ALLOW_CASCADE`, default: false).
- Eligibility expires: Cron job daily marks `status = 'expired'` where `eligibility_expires_at < CURDATE() AND status = 'eligible'`.
- Same player scheduled for makeup on a day they already have regular training: Allowed (they attend both). System does NOT prevent this — it's a legitimate scenario.
- Group from different sport: Blocked. Makeup group must belong to same discipline.
---
## Phase 5: Institution Bookings
### 5.1 `sa_institutions` Table (NEW)
```sql
CREATE TABLE sa_institutions (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code VARCHAR(20) NOT NULL UNIQUE,
name_ar VARCHAR(200) NOT NULL,
name_en VARCHAR(200) NULL,
institution_type ENUM('school','university','company','sports_club','government','private','other') NOT NULL,
-- Contact
contact_person VARCHAR(150) NULL,
phone VARCHAR(20) NULL,
alt_phone VARCHAR(20) NULL,
email VARCHAR(150) NULL,
address TEXT NULL,
-- Documents
contract_path VARCHAR(500) NULL,
commercial_reg_path VARCHAR(500) NULL,
tax_card_path VARCHAR(500) NULL,
-- State
status ENUM('active','suspended','inactive','archived') NOT NULL DEFAULT 'active',
notes TEXT NULL,
-- Audit
is_archived TINYINT(1) NOT NULL DEFAULT 0,
archived_at DATETIME NULL,
archived_by INT UNSIGNED NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
created_by INT UNSIGNED NOT NULL,
updated_by INT UNSIGNED NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 5.2 `sa_institution_bookings` Table (NEW)
```sql
CREATE TABLE sa_institution_bookings (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
booking_number VARCHAR(30) NOT NULL UNIQUE,
institution_id INT UNSIGNED NOT NULL,
-- Facility & Grid
facility_id INT UNSIGNED NOT NULL,
grid_area_json JSON NULL COMMENT '{"rows":[2,3,4],"cols":[1,2,3,4,5]}',
-- Schedule
booking_date DATE NOT NULL,
start_time TIME NOT NULL,
end_time TIME NOT NULL,
-- Pricing (custom per booking)
facility_fee DECIMAL(10,2) NOT NULL DEFAULT 0.00,
equipment_fee DECIMAL(10,2) NOT NULL DEFAULT 0.00,
staff_fee DECIMAL(10,2) NOT NULL DEFAULT 0.00,
cleaning_fee DECIMAL(10,2) NOT NULL DEFAULT 0.00,
discount_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00,
total_amount DECIMAL(10,2) NOT NULL,
-- Treasury
treasury_request_id BIGINT UNSIGNED NULL,
payment_status ENUM('pending','paid','partially_paid','cancelled','refunded') NOT NULL DEFAULT 'pending',
-- Booking state
status ENUM('draft','awaiting_payment','confirmed','in_progress','completed','cancelled','refunded') NOT NULL DEFAULT 'draft',
purpose TEXT NULL,
participant_count INT UNSIGNED NULL,
notes TEXT NULL,
-- Metadata
created_by INT UNSIGNED NOT NULL,
updated_by INT UNSIGNED NULL,
cancelled_by INT UNSIGNED NULL,
cancelled_at DATETIME NULL,
cancellation_reason TEXT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_institution (institution_id),
INDEX idx_facility_date (facility_id, booking_date),
INDEX idx_status (status),
INDEX idx_date (booking_date),
FOREIGN KEY (institution_id) REFERENCES sa_institutions(id),
FOREIGN KEY (facility_id) REFERENCES sa_facilities(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### 5.3 Conflict Detection for Institution Bookings
Institution bookings participate in the same conflict detection as all other facility assignments. The `FacilityConflictDetector` service must check:
```php
public static function hasConflict(int $facilityId, string $date, string $startTime, string $endTime, ?array $gridArea = null): array
{
$conflicts = [];
// 1. Check sa_group_schedules (training)
// 2. Check sa_bookings (recreational)
// 3. Check sa_institution_bookings
// 4. Check facility_zone_schedules (grid-level)
// 5. Check facility maintenance windows
// For grid-area conflicts: if both bookings specify grid_area_json,
// check if any row/col intersection exists.
// If either booking has NULL grid_area (full facility), it conflicts with everything.
return $conflicts; // Array of conflicting records with type + details
}
```
**Edge Cases:**
- Recurring institution booking (e.g., school uses pool every Tuesday): Create separate booking per date. Provide "batch create" UI that generates N bookings at once with conflict check for all dates.
- Institution booking overlaps maintenance: Reject with "المنشأة تحت الصيانة في هذا الوقت".
- Partial payment: Status stays `awaiting_payment`. Booking is confirmed only when treasury reports full payment (unless `ALLOW_PARTIAL_ACTIVATION` rule is true).
- Cancellation after payment: Auto-generates refund treasury request. Admin must approve.
---
## Phase 6: Subscription Management
### 6.1 Subscription State Machine
```
PENDING_PAYMENT ──pay──► ACTIVE ──time──► EXPIRED
│ │ │
│──cancel──► CANCELLED│──suspend──►SUSPENDED│──renew──► ACTIVE (new row)
│──transfer──► TRANSFERRED (new assignment created)
```
### 6.2 Subscription Renewal Logic
```php
class SubscriptionRenewalService
{
public static function canRenew(int $assignmentId): array
{
$assignment = Database::selectOne(
"SELECT pa.*, p.id as player_id FROM sa_player_assignments pa
JOIN sa_players p ON p.id = pa.player_id
WHERE pa.id = ? AND pa.status = 'active'",
[$assignmentId]
);
if (!$assignment) {
return ['allowed' => false, 'reason' => 'assignment_not_active'];
}
// Check medical
$medical = MedicalGracePeriodService::canRenew($assignment['player_id']);
if (!$medical['allowed']) {
return ['allowed' => false, 'reason' => $medical['reason']];
}
// Check active subscription exists and is near expiry (within renewal window)
$sub = Database::selectOne(
"SELECT * FROM sa_subscriptions
WHERE assignment_id = ? AND status = 'active'
ORDER BY end_date DESC LIMIT 1",
[$assignmentId]
);
if (!$sub) {
return ['allowed' => false, 'reason' => 'no_active_subscription'];
}
$renewalWindowDays = RuleEngine::get('RENEWAL_WINDOW_DAYS', 14);
$windowStart = date('Y-m-d', strtotime($sub['end_date'] . " - {$renewalWindowDays} days"));
if (date('Y-m-d') < $windowStart) {
return ['allowed' => false, 'reason' => 'too_early', 'earliest_date' => $windowStart];
}
return ['allowed' => true, 'current_end' => $sub['end_date'], 'amount' => $sub['amount']];
}
public static function createRenewal(int $assignmentId, int $userId): array
{
$check = self::canRenew($assignmentId);
if (!$check['allowed']) return $check;
// Calculate new period
$newStart = date('Y-m-d', strtotime($check['current_end'] . ' + 1 day'));
$newEnd = date('Y-m-d', strtotime($newStart . ' + 1 month - 1 day'));
// Get current program price
$assignment = Database::selectOne("SELECT * FROM sa_player_assignments WHERE id = ?", [$assignmentId]);
$program = Database::selectOne("SELECT * FROM sa_programs WHERE id = ?", [$assignment['program_id']]);
$amount = self::calculateAmount($assignment, $program);
// Create subscription record
$subId = Database::insert('sa_subscriptions', [
'assignment_id' => $assignmentId,
'player_id' => $assignment['player_id'],
'program_id' => $assignment['program_id'],
'start_date' => $newStart,
'end_date' => $newEnd,
'amount' => $amount['final'],
'discount_amount' => $amount['discount'],
'status' => 'pending_payment',
'created_by' => $userId,
]);
// Create treasury request
$treasuryId = PaymentRequestService::createRequest([
'type' => 'sports_subscription_renewal',
'entity_type' => 'sa_subscriptions',
'entity_id' => $subId,
'amount' => $amount['final'],
'player_id' => $assignment['player_id'],
'description' => "تجديد اشتراك - {$program['name_ar']}",
]);
Database::update('sa_subscriptions',
['treasury_request_id' => $treasuryId],
'id = ?', [$subId]
);
return ['success' => true, 'subscription_id' => $subId];
}
}
```
**Edge Cases:**
- Renewal before current subscription ends: New subscription starts day after current ends (not today). Prevents gap or overlap.
- Price changed since last renewal: Always uses current program price. Historical price preserved in subscription record.
- Player in grace period (medical pending): Renewal allowed during grace. Blocked only after grace expires.
- Multiple renewals queued: Only one PENDING_PAYMENT subscription allowed per assignment at a time. Second attempt returns error.
- Suspension: Adds `suspended_at` timestamp. When resumed, end_date is extended by (resume_date - suspended_at) days IF `SUSPEND_EXTENDS_SUBSCRIPTION` rule is true (default: false).
---
## Phase 7: Treasury Auto-Sync (EventBus Integration)
### 7.1 Event Listener Registration
In `SportsActivity/bootstrap.php`:
```php
EventBus::listen('payment.completed', function($data) {
SportsPaymentHandler::onPaymentCompleted($data);
}, priority: 10);
EventBus::listen('payment.cancelled', function($data) {
SportsPaymentHandler::onPaymentCancelled($data);
}, priority: 10);
EventBus::listen('payment.refunded', function($data) {
SportsPaymentHandler::onPaymentRefunded($data);
}, priority: 10);
```
### 7.2 Payment Handler
```php
class SportsPaymentHandler
{
private const HANDLED_TYPES = [
'sports_subscription',
'sports_subscription_renewal',
'sports_institution_booking',
'sports_recreational_booking',
];
public static function onPaymentCompleted(array $data): void
{
if (!in_array($data['type'] ?? '', self::HANDLED_TYPES)) return;
$entityType = $data['entity_type'] ?? '';
$entityId = $data['entity_id'] ?? null;
if (!$entityId) return;
match ($entityType) {
'sa_player_assignments' => self::activateAssignment($entityId),
'sa_subscriptions' => self::activateSubscription($entityId),
'sa_institution_bookings' => self::confirmInstitutionBooking($entityId),
'sa_bookings' => self::confirmRecreationalBooking($entityId),
default => null,
};
}
private static function activateAssignment(int $assignmentId): void
{
$assignment = Database::selectOne(
"SELECT * FROM sa_player_assignments WHERE id = ? AND status = 'pending_payment'",
[$assignmentId]
);
if (!$assignment) return;
Database::update('sa_player_assignments', [
'status' => 'active',
'activated_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$assignmentId]);
// Update player status to active (if this is their first active assignment)
$activeCount = Database::selectOne(
"SELECT COUNT(*) as cnt FROM sa_player_assignments
WHERE player_id = ? AND status = 'active'",
[$assignment['player_id']]
)['cnt'];
if ($activeCount >= 1) {
Database::update('sa_players', ['status' => 'active'], 'id = ?', [$assignment['player_id']]);
}
// Add player to group
Database::insert('sa_group_players', [
'group_id' => $assignment['group_id'],
'player_id' => $assignment['player_id'],
'assignment_id' => $assignmentId,
'joined_at' => date('Y-m-d H:i:s'),
'status' => 'active',
]);
EventBus::dispatch('sports.player.activated', [
'player_id' => $assignment['player_id'],
'assignment_id' => $assignmentId,
]);
}
}
```
**Edge Cases:**
- Payment event arrives for already-active assignment (duplicate event): Check status before updating. If already active, ignore silently.
- Payment event arrives for cancelled assignment (player cancelled while payment was processing): Log warning, do NOT activate. Treasury handles the refund.
- Partial payment: Only activate if `ALLOW_PARTIAL_ACTIVATION` rule is true AND paid percentage >= `MIN_ACTIVATION_PERCENTAGE` (default: 100%).
---
## Phase 8: Recreational Sports Differentiation
### 8.1 Discipline Type Flag
Add column to existing `sa_disciplines` table:
```sql
ALTER TABLE sa_disciplines
ADD COLUMN sport_type ENUM('training','recreational') NOT NULL DEFAULT 'training' AFTER category;
```
### 8.2 Recreational Booking Flow
Recreational sports use the existing `sa_bookings` table but with a simplified workflow:
1. No program/group selection
2. No attendance tracking
3. No medical requirement
4. Payment can be before or after activity (configurable per discipline)
The `sa_bookings` table already exists. Ensure it has:
```sql
-- Verify these columns exist (add if missing)
ALTER TABLE sa_bookings
ADD COLUMN IF NOT EXISTS pricing_method ENUM('hourly','per_game') NULL,
ADD COLUMN IF NOT EXISTS payment_timing ENUM('before_play','after_play') NOT NULL DEFAULT 'before_play',
ADD COLUMN IF NOT EXISTS actual_start_time DATETIME NULL,
ADD COLUMN IF NOT EXISTS actual_end_time DATETIME NULL;
```
**Edge Cases:**
- Customer leaves without paying (after_play mode): Booking status = `completed_unpaid`. Reception gets notification. Treasury request remains open.
- Booking extends beyond reserved time: Staff can extend. If next slot is free, auto-extend. If not, warn and suggest ending.
- Walk-in customer (no reservation): Create booking with start_time = now, open-ended. Close manually when done.
- Member vs non-member pricing: Determined at booking creation time. If membership lapses between booking and payment, original price applies.
---
## Phase 9: Consolidated Dashboard
### 9.1 Implementation Approach
The dashboard is NOT a separate module. It's a controller action within SportsActivity that aggregates data using optimized queries (not N+1).
### 9.2 Dashboard Data Service
```php
class SportsDashboardService
{
public static function getMetrics(int $branchId = null): array
{
$today = date('Y-m-d');
$monthStart = date('Y-m-01');
$yearStart = date('Y-01-01');
// Single query for player counts by status
$playerStats = Database::select(
"SELECT status, COUNT(*) as cnt FROM sa_players
WHERE is_archived = 0 GROUP BY status"
);
// Single query for today's sessions + attendance
$todaySessions = Database::select(
"SELECT g.id, g.name_ar, gs.start_time, gs.end_time,
(SELECT COUNT(*) FROM sa_group_players gp WHERE gp.group_id = g.id AND gp.status = 'active') as player_count,
(SELECT COUNT(*) FROM sa_attendance a WHERE a.group_id = g.id AND a.session_date = ?) as attendance_count
FROM sa_groups g
JOIN sa_group_schedules gs ON gs.group_id = g.id
WHERE gs.day_of_week = DAYOFWEEK(?) AND g.status = 'active'",
[$today, $today]
);
// Revenue (delegated to treasury)
// Medical pending count
// Subscription expiring in 7 days
// Full groups count
return [
'players' => self::mapCounts($playerStats),
'sessions_today' => $todaySessions,
'revenue_today' => self::getRevenue($today, $today),
'revenue_month' => self::getRevenue($monthStart, $today),
'medical_pending' => self::getMedicalPendingCount(),
'subscriptions_expiring' => self::getExpiringSubscriptions(7),
'attendance_today_pct' => self::getTodayAttendancePercentage(),
];
}
}
```
### 9.3 Role-Based Widget Visibility
```php
// In DashboardController
$widgets = match(true) {
$user->hasPermission('sa.dashboard.full') => ['all'],
$user->hasPermission('sa.coach.view') => ['my_groups', 'my_attendance', 'my_schedule'],
$user->hasPermission('sa.medical.review') => ['medical_pending', 'medical_expiring'],
$user->hasPermission('treasury.view') => ['revenue', 'pending_payments'],
default => ['basic_stats'],
};
```
---
## Phase 10: Copy Schedule & Recurring Assignments
### 10.1 Implementation
Add to `FacilityGrids` (it already owns scheduling):
```php
class ScheduleCopyService
{
public static function copyWeek(int $facilityId, string $sourceWeekStart, string $targetWeekStart, array $options): array
{
$sourceEnd = date('Y-m-d', strtotime($sourceWeekStart . ' + 6 days'));
$assignments = Database::select(
"SELECT * FROM facility_zone_schedules
WHERE facility_grid_id = (SELECT id FROM facility_grids WHERE facility_id = ?)
AND schedule_date BETWEEN ? AND ?
AND is_archived = 0",
[$facilityId, $sourceWeekStart, $sourceEnd]
);
$conflicts = [];
$created = 0;
foreach ($assignments as $a) {
$dayOffset = (strtotime($a['schedule_date']) - strtotime($sourceWeekStart)) / 86400;
$newDate = date('Y-m-d', strtotime($targetWeekStart . " + {$dayOffset} days"));
// Check for conflicts
$conflict = FacilityConflictDetector::hasConflict(
$facilityId, $newDate, $a['start_time'], $a['end_time'], json_decode($a['zone_data'], true)
);
if ($conflict && $options['on_conflict'] === 'skip') {
$conflicts[] = ['date' => $newDate, 'time' => $a['start_time'], 'reason' => $conflict];
continue;
}
if ($conflict && $options['on_conflict'] === 'replace') {
// Delete conflicting assignment
Database::update('facility_zone_schedules', ['is_archived' => 1], 'id = ?', [$conflict['id']]);
}
// Create new assignment
$newData = $a;
unset($newData['id']);
$newData['schedule_date'] = $newDate;
$newData['created_at'] = date('Y-m-d H:i:s');
Database::insert('facility_zone_schedules', $newData);
$created++;
}
return ['created' => $created, 'conflicts' => $conflicts, 'skipped' => count($conflicts)];
}
}
```
**Edge Cases:**
- Source week has no assignments: Return early with "لا يوجد جدول للنسخ".
- Target week partially occupied: Depending on `on_conflict` option (skip/replace/abort).
- Copying to a date where facility is under maintenance: Treat maintenance as immovable conflict — always skip those slots.
- Copying recurring: "Copy week X to weeks X+1 through X+4" — loop with individual conflict checks per week.
---
## Schema Mismatch Prevention Checklist
For EVERY migration before writing:
- [ ] Column name matches this document exactly (snake_case, no abbreviations)
- [ ] Data type matches (ENUM values are identical, including order)
- [ ] NULL/NOT NULL matches
- [ ] DEFAULT value matches
- [ ] Foreign key references valid existing table
- [ ] Index name doesn't conflict with existing indexes
- [ ] Migration checks `information_schema` before ALTER
- [ ] Table engine is InnoDB
- [ ] Charset is utf8mb4
For EVERY model after writing:
- [ ] `$table` matches the actual table name
- [ ] `$fillable` includes only columns that exist
- [ ] No reference to columns not in the schema
- [ ] Timestamps column names match (`created_at`/`updated_at` not `created`/`modified`)
- [ ] Soft delete column matches (`is_archived` not `deleted_at`)
For EVERY controller/service:
- [ ] SQL column names in queries match live DB (verify with `DESCRIBE` if uncertain)
- [ ] JOIN conditions reference correct FK columns
- [ ] ENUM values in PHP match ENUM values in DB exactly
- [ ] No assumption about column existence without checking migration
---
## Execution Order
| # | Phase | Dependencies | Estimated Routes | Priority |
|---|-------|--------------|-----------------|----------|
| 0 | Schema Alignment | None | 0 | P0 - Do First |
| 1 | Player Lifecycle | Phase 0 | 8 | P0 |
| 7 | Treasury Auto-Sync | Phase 1 | 0 (EventBus only) | P0 |
| 2 | Medical Management | Phase 1 | 6 | P1 |
| 3 | Attendance | Phase 1 | 5 | P1 |
| 4 | Make-up Sessions | Phase 3 | 4 | P1 |
| 6 | Subscriptions | Phase 1, 2 | 5 | P1 |
| 5 | Institution Bookings | Phase 0 | 8 | P2 |
| 8 | Recreational Differentiation | Phase 0 | 3 | P2 |
| 9 | Dashboard | All above | 2 | P3 |
| 10 | Copy Schedule | Phase 0 | 3 | P3 |
**Total new routes: ~44**
**Total new tables: 5**
**Total new migrations: ~10**
---
## Deprecation Strategy for Satellite Modules
| Module | Action | Timeline |
|--------|--------|----------|
| `PlayerAffairs` | Keep for evaluations/fitness/injuries. Attendance routes redirect to SportsActivity. | Phase 3 |
| `TrainingGroups` | Routes redirect to SportsActivity groups. Data stays in existing tables. | Phase 1 |
| `Coaches` (standalone) | Routes redirect to SportsActivity coaches. | Phase 1 |
| `Academies` (standalone) | Routes redirect to SportsActivity academies. | Phase 1 |
| `Disciplines` (standalone) | Routes redirect to SportsActivity disciplines. | Phase 0 |
| `MedicalBoard` | Absorbed into Phase 2 medical management. | Phase 2 |
| `FacilityGrids` | KEEP as-is — it's the scheduling engine. SportsActivity calls its services. | Never deprecated |
| `ActivitySubscriptions` | Absorbed into Phase 6. Routes redirect. | Phase 6 |
Redirect pattern (in deprecated module's Routes.php):
```php
['GET', '/old-path', 'RedirectController@toNew', ['auth'], null]
// RedirectController returns: $this->redirect('/sa/new-path', 301)
```
---
## Testing Protocol (No Test Framework)
Since there's no PHPUnit, validation happens via:
1. **Browser testing** — Every UX flow tested manually via live URL.
2. **CLI validation**`php cli.php schema:validate sports` checks for drift.
3. **Smoke routes** — Hit every new route with curl and verify 200/302 (not 500).
4. **Edge case reproduction** — Create specific data states via seed scripts, then test UI behavior.
```bash
# Smoke test all new routes (add to cli.php as a command)
php cli.php smoke:sports
# Hits every SA route, reports status codes
```
---
## Final Notes
This plan prioritizes:
1. **Data integrity** over speed of delivery — schema is validated before code touches it.
2. **UX clarity** — Arabic-first, RTL-aware, minimal clicks for daily operations.
3. **Edge case resilience** — every state transition has guards, every concurrent access has locks.
4. **Incremental delivery** — each phase is independently deployable and adds immediate value.
The design document's vision is preserved but grounded in engineering reality. Where the design over-specifies UI (dashboard having 17 widget sections), this plan implements the high-value subset first and makes it extensible. Where the design under-specifies logic (Treasury "auto-sync"), this plan defines exact event contracts.
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