# 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.
